Compare commits
26 Commits
v1.15.1
...
daf3a524c7
| Author | SHA1 | Date | |
|---|---|---|---|
| daf3a524c7 | |||
| 7c278bcaf0 | |||
| 6687802f19 | |||
| b76fcb1390 | |||
| 80d41c9487 | |||
| b67a384923 | |||
| d691b71c44 | |||
| 5e58d53416 | |||
| 1a301409de | |||
| 12cbbd1b80 | |||
| 3f7b166767 | |||
| 6bc5145b9c | |||
| 597286fff0 | |||
| e141701567 | |||
| 7003e5cd80 | |||
| d8232fda7c | |||
| 16b3dc5d47 | |||
| 3b46f48655 | |||
| f10b15d91b | |||
| 49dfb3ef24 | |||
| b187aa1b46 | |||
| cd3d7e816f | |||
| 63925fe305 | |||
| c146a6c3c3 | |||
| ba779bde55 | |||
| ba94152074 |
178
README.legacy.md
Normal file
178
README.legacy.md
Normal 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
837
README.md
@@ -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.
|
||||
Binary file not shown.
48
backend/add_participant.py
Normal file
48
backend/add_participant.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import asyncio
|
||||
from app.core.database import AsyncSessionLocal, Base, engine
|
||||
from app.models.ticket import Ticket
|
||||
from sqlalchemy import String, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.core.database import GUID
|
||||
import uuid
|
||||
|
||||
# Agregar modelo al archivo ticket.py
|
||||
new_model = '''
|
||||
|
||||
class TicketParticipant(Base):
|
||||
"""Participantes asignados a un ticket"""
|
||||
__tablename__ = "ticket_participants"
|
||||
|
||||
ticket_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(),
|
||||
ForeignKey("tickets.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(50), nullable=False, default="participant")
|
||||
|
||||
ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="participants")
|
||||
user: Mapped["User"] = relationship("User")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("ticket_id", "user_id", name="uq_ticket_participant"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TicketParticipant(ticket={self.ticket_id}, user={self.user_id})>"
|
||||
'''
|
||||
|
||||
with open("/app/app/models/ticket.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
if "TicketParticipant" in content:
|
||||
print("Ya existe TicketParticipant")
|
||||
else:
|
||||
content += new_model
|
||||
with open("/app/app/models/ticket.py", "w") as f:
|
||||
f.write(content)
|
||||
print("OK: TicketParticipant agregado")
|
||||
21
backend/add_relation.py
Normal file
21
backend/add_relation.py
Normal file
@@ -0,0 +1,21 @@
|
||||
with open("/app/app/models/ticket.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
old = ' def __repr__(self) -> str:\n return f"<Ticket(id={self.id}, number=\'{self.ticket_number}\', status={self.status})>"'
|
||||
new = ''' participants: Mapped[list["TicketParticipant"]] = relationship(
|
||||
"TicketParticipant",
|
||||
back_populates="ticket",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Ticket(id={self.id}, number=\'{self.ticket_number}\', status={self.status})>"'''
|
||||
|
||||
if old in content:
|
||||
content = content.replace(old, new)
|
||||
print("OK: relacion participants agregada")
|
||||
else:
|
||||
print("ERROR: bloque no encontrado")
|
||||
|
||||
with open("/app/app/models/ticket.py", "w") as f:
|
||||
f.write(content)
|
||||
@@ -1,3 +1,4 @@
|
||||
from typing import Optional
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from starlette.requests import Request
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
@@ -15,7 +16,48 @@ from app.models.tenant import Tenant
|
||||
settings = get_settings()
|
||||
|
||||
# Esquema OAuth2 centralizado — auth.py importa desde aquí
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login")
|
||||
# Soporta: 1) Authorization: Bearer header (Swagger/API clients)
|
||||
# 2) Cookie access_token HttpOnly (apps web)
|
||||
_bearer_scheme = OAuth2PasswordBearer(
|
||||
tokenUrl=f"/{settings.API_VERSION}/auth/login",
|
||||
auto_error=False,
|
||||
)
|
||||
|
||||
|
||||
async def oauth2_scheme(
|
||||
request: Request,
|
||||
bearer_token: Optional[str] = Depends(_bearer_scheme),
|
||||
) -> str:
|
||||
"""Extrae JWT desde header Authorization (prioridad) o cookie del frontend correcto.
|
||||
|
||||
Usa el header X-App para seleccionar la cookie:
|
||||
- X-App: internal → solo 'internal_access_token'
|
||||
- X-App: client → solo 'client_access_token'
|
||||
- sin header → prueba ambas (compatibilidad con Swagger/CLI)
|
||||
"""
|
||||
if bearer_token:
|
||||
return bearer_token
|
||||
|
||||
app_hint = request.headers.get("X-App", "").lower()
|
||||
if app_hint == "internal":
|
||||
token = request.cookies.get("internal_access_token")
|
||||
elif app_hint == "client":
|
||||
token = request.cookies.get("client_access_token")
|
||||
else:
|
||||
# Fallback para Swagger, tests y clientes sin header
|
||||
token = (
|
||||
request.cookies.get("internal_access_token")
|
||||
or request.cookies.get("client_access_token")
|
||||
)
|
||||
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
@@ -47,9 +89,10 @@ async def get_current_user(
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
|
||||
# Enforce that tenant header (if present) matches the authenticated user's tenant.
|
||||
# Prevents cross-tenant header impersonation.
|
||||
# Roles globales (is_global) pueden operar en cualquier tenant → omitir chequeo.
|
||||
# Roles de cliente (is_client) deben coincidir con su propio tenant.
|
||||
request_tenant_id = getattr(getattr(request, "state", None), "tenant_id", None)
|
||||
if request_tenant_id and str(user.tenant_id) != str(request_tenant_id):
|
||||
if request_tenant_id and not user.role.is_global and str(user.tenant_id) != str(request_tenant_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Tenant header does not match authenticated user",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Auth Schemas - ServiceManagerWeb
|
||||
|
||||
Pydantic schemas para autenticación y autorización.
|
||||
Pydantic schemas para autenticación y autorización.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
@@ -12,7 +12,7 @@ class LoginRequest(BaseModel):
|
||||
"""Schema para solicitud de login."""
|
||||
email: EmailStr
|
||||
password: str
|
||||
tenant_slug: str
|
||||
tenant_slug: Optional[str] = None
|
||||
totp_code: Optional[str] = None
|
||||
|
||||
|
||||
@@ -53,28 +53,28 @@ class TwoFactorSetupResponse(BaseModel):
|
||||
|
||||
|
||||
class TwoFactorEnableRequest(BaseModel):
|
||||
"""Código TOTP para confirmar y activar 2FA."""
|
||||
"""Código TOTP para confirmar y activar 2FA."""
|
||||
totp_code: str
|
||||
|
||||
|
||||
class TwoFactorEnableResponse(BaseModel):
|
||||
"""Resultado al habilitar 2FA: incluye los códigos de respaldo."""
|
||||
"""Resultado al habilitar 2FA: incluye los códigos de respaldo."""
|
||||
enabled: bool
|
||||
backup_codes: List[str]
|
||||
|
||||
|
||||
class TwoFactorDisableRequest(BaseModel):
|
||||
"""Deshabilitar 2FA verificando con TOTP o código de respaldo."""
|
||||
"""Deshabilitar 2FA verificando con TOTP o código de respaldo."""
|
||||
totp_code: Optional[str] = None
|
||||
backup_code: Optional[str] = None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Cambio de contraseña
|
||||
# Cambio de contraseña
|
||||
# ============================================================
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
"""Schema para cambio de contraseña del usuario autenticado."""
|
||||
"""Schema para cambio de contraseña del usuario autenticado."""
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
@@ -82,15 +82,15 @@ class ChangePasswordRequest(BaseModel):
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Recuperación de contraseña
|
||||
# Recuperación de contraseña
|
||||
# ============================================================
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
"""Solicitar enlace de reseteo de contraseña por email."""
|
||||
"""Solicitar enlace de reseteo de contraseña por email."""
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
"""Aplicar nueva contraseña usando token de reseteo."""
|
||||
"""Aplicar nueva contraseña usando token de reseteo."""
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
@@ -11,13 +11,14 @@ import uuid
|
||||
|
||||
|
||||
class CategoryCreate(BaseModel):
|
||||
"""Schema para crear categoría. No incluye tenant_id (se asigna automáticamente)."""
|
||||
"""Schema para crear categoría. tenant_id opcional para ADMIN global."""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
sla_response_hours: int = 24
|
||||
sla_resolution_hours: int = 72
|
||||
auto_assign_to: Optional[uuid.UUID] = None
|
||||
tenant_id: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
|
||||
53
backend/app/api/schemas/issue.py
Normal file
53
backend/app/api/schemas/issue.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Schemas para TicketIssue"""
|
||||
from pydantic import BaseModel, field_validator
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
class TaggedUserBasic(BaseModel):
|
||||
id: uuid.UUID
|
||||
full_name: str
|
||||
email: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class IssueCreate(BaseModel):
|
||||
content: str
|
||||
priority: str = "MEDIUM"
|
||||
tagged_user_ids: List[uuid.UUID] = []
|
||||
|
||||
@field_validator("priority")
|
||||
@classmethod
|
||||
def validate_priority(cls, v: str) -> str:
|
||||
valid = {"LOW", "MEDIUM", "HIGH", "URGENT"}
|
||||
if v.upper() not in valid:
|
||||
raise ValueError(f"priority debe ser uno de: {valid}")
|
||||
return v.upper()
|
||||
|
||||
@field_validator("content")
|
||||
@classmethod
|
||||
def validate_content(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("content no puede estar vacío")
|
||||
return v.strip()
|
||||
|
||||
|
||||
class IssueResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
ticket_id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
content: str
|
||||
priority: str
|
||||
created_by: uuid.UUID
|
||||
created_by_name: str
|
||||
tagged_users: List[TaggedUserBasic] = []
|
||||
attachment_filename: Optional[str] = None
|
||||
attachment_mime_type: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
||||
"""
|
||||
Authentication Endpoints - ServiceManagerWeb
|
||||
|
||||
Endpoints para autenticación y autorización
|
||||
Endpoints para autenticación y autorización
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status, Depends, Request
|
||||
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
@@ -21,6 +21,15 @@ from app.services.audit_service import AuditService
|
||||
from app.services.token_service import TokenService
|
||||
from app.api.deps import oauth2_scheme, get_current_user
|
||||
from app.core.cache import cache, cache_key
|
||||
from app.core.limiter import limiter
|
||||
|
||||
# Nombres de cookie por tipo de usuario
|
||||
CLIENT_ROLES = {"CLIENT_ADMIN", "CLIENT_USER"}
|
||||
|
||||
|
||||
def _cookie_name_for_role(role: str) -> str:
|
||||
"""Devuelve el nombre de cookie según el rol del usuario."""
|
||||
return "client_access_token" if role in CLIENT_ROLES else "internal_access_token"
|
||||
from app.api.schemas.auth import (
|
||||
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
|
||||
TwoFactorStatusResponse, TwoFactorSetupResponse,
|
||||
@@ -38,9 +47,11 @@ settings = get_settings()
|
||||
# ===================================
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
@limiter.limit("10/minute")
|
||||
async def login(
|
||||
login_data: LoginRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
@@ -77,25 +88,23 @@ async def login(
|
||||
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
|
||||
)
|
||||
|
||||
# 1. Validar tenant
|
||||
tenant_result = await db.execute(
|
||||
select(Tenant).where(Tenant.slug == login_data.tenant_slug)
|
||||
)
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
if tenant is None:
|
||||
logger.warning(
|
||||
"Login failed - tenant not found",
|
||||
email=login_data.email,
|
||||
tenant_slug=login_data.tenant_slug,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tenant not found",
|
||||
# 1. Validar tenant - por slug si viene, sino buscar por email
|
||||
if login_data.tenant_slug:
|
||||
tenant_result = await db.execute(
|
||||
select(Tenant).where(Tenant.slug == login_data.tenant_slug)
|
||||
)
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
if tenant is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tenant not found",
|
||||
)
|
||||
else:
|
||||
tenant = None
|
||||
|
||||
# Rate limiting (best-effort): by (tenant,email) to slow brute force.
|
||||
ident_key = None
|
||||
if settings.RATE_LIMIT_ENABLED and not settings.TESTING:
|
||||
if settings.RATE_LIMIT_ENABLED and not settings.TESTING and tenant:
|
||||
email_norm = login_data.email.strip().lower()
|
||||
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)
|
||||
ident_count = await cache.incr(ident_key, 1)
|
||||
@@ -131,22 +140,25 @@ async def login(
|
||||
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
|
||||
)
|
||||
|
||||
# 2. Buscar usuario en base de datos (aislado por tenant)
|
||||
query = select(User).where(
|
||||
User.email == login_data.email,
|
||||
User.tenant_id == tenant.id,
|
||||
)
|
||||
# 2. Buscar usuario - filtrar por tenant si se detecto, sino buscar por email
|
||||
if tenant:
|
||||
query = select(User).where(
|
||||
User.email == login_data.email,
|
||||
User.tenant_id == tenant.id,
|
||||
)
|
||||
else:
|
||||
query = select(User).where(User.email == login_data.email)
|
||||
result = await db.execute(query)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
# 3. Verificar usuario y contraseña
|
||||
# 3. Verificar usuario y contraseña
|
||||
if not user or not security.verify_password(login_data.password, user.password_hash):
|
||||
logger.warning(
|
||||
"Login failed - invalid credentials",
|
||||
email=login_data.email
|
||||
)
|
||||
|
||||
# Registrar intento fallido en auditoría (si el usuario existe)
|
||||
# Registrar intento fallido en auditorÃa (si el usuario existe)
|
||||
if user:
|
||||
try:
|
||||
await AuditService.log(
|
||||
@@ -167,7 +179,7 @@ async def login(
|
||||
detail="Invalid credentials",
|
||||
)
|
||||
|
||||
# 4. Verificar si está activo
|
||||
# 4. Verificar si está activo
|
||||
if not user.is_active:
|
||||
logger.warning(
|
||||
"Login failed - user inactive",
|
||||
@@ -178,19 +190,19 @@ async def login(
|
||||
detail="User inactive",
|
||||
)
|
||||
|
||||
# 5. Verificar 2FA si está habilitado
|
||||
# 5. Verificar 2FA si está habilitado
|
||||
if user.totp_enabled:
|
||||
if not login_data.totp_code:
|
||||
# Indicar al frontend que debe pedir el código TOTP
|
||||
# Indicar al frontend que debe pedir el código TOTP
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Se requiere autenticación de dos factores (2FA). Ingresa tu código."
|
||||
detail="Se requiere autenticación de dos factores (2FA). Ingresa tu código."
|
||||
)
|
||||
if not security.verify_totp(user.totp_secret, login_data.totp_code):
|
||||
logger.warning("Login failed - invalid 2FA code", email=login_data.email)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Código 2FA inválido o expirado"
|
||||
detail="Código 2FA inválido o expirado"
|
||||
)
|
||||
|
||||
# Create tokens
|
||||
@@ -222,7 +234,7 @@ async def login(
|
||||
detail="Service temporarily unavailable",
|
||||
)
|
||||
|
||||
# Registrar login exitoso en auditoría
|
||||
# Registrar login exitoso en auditorÃa
|
||||
try:
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
@@ -247,7 +259,20 @@ async def login(
|
||||
# Best-effort: clear per-identity limiter on success.
|
||||
if ident_key:
|
||||
await cache.delete(ident_key)
|
||||
|
||||
|
||||
# Cookie diferenciada por rol para aislar sesiones entre frontends
|
||||
cookie_name = _cookie_name_for_role(
|
||||
user.role.value if hasattr(user.role, "value") else user.role
|
||||
)
|
||||
response.set_cookie(
|
||||
key=cookie_name,
|
||||
value=access_token,
|
||||
httponly=True,
|
||||
secure=settings.is_production(),
|
||||
samesite="strict" if settings.is_production() else "lax",
|
||||
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
@@ -259,6 +284,7 @@ async def login(
|
||||
"last_name": user.last_name,
|
||||
"role": user.role,
|
||||
"tenant_id": str(user.tenant_id),
|
||||
"tenant_slug": tenant.slug if tenant else str(user.tenant_id),
|
||||
"is_active": user.is_active,
|
||||
"is_two_factor_enabled": user.totp_enabled or False,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None
|
||||
@@ -330,6 +356,7 @@ async def refresh_token(
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
response: Response,
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
@@ -367,7 +394,7 @@ async def logout(
|
||||
except Exception as e:
|
||||
logger.warning("Failed to revoke refresh tokens on logout", error=str(e))
|
||||
|
||||
# Registrar logout en auditoría
|
||||
# Registrar logout en auditorÃa
|
||||
try:
|
||||
import uuid
|
||||
user_id = uuid.UUID(payload["sub"])
|
||||
@@ -387,7 +414,10 @@ async def logout(
|
||||
logger.warning("Failed to log audit entry", error=str(e))
|
||||
|
||||
logger.info("Logout successful", user_id=payload["sub"])
|
||||
|
||||
|
||||
# Borrar la cookie correcta según el rol del usuario
|
||||
cookie_name = _cookie_name_for_role(payload.get("role", ""))
|
||||
response.delete_cookie(key=cookie_name)
|
||||
return {"message": "Successfully logged out"}
|
||||
|
||||
|
||||
@@ -451,6 +481,7 @@ async def get_current_user(
|
||||
"role": user.role.value if hasattr(user.role, 'value') else user.role,
|
||||
"tenant_id": str(user.tenant_id),
|
||||
"tenant_name": user.tenant.name if user.tenant else None,
|
||||
"tenant_slug": user.tenant.slug if user.tenant else None,
|
||||
"is_active": user.is_active,
|
||||
"is_two_factor_enabled": user.totp_secret is not None,
|
||||
"last_login": user.last_login.isoformat() if user.last_login else None,
|
||||
@@ -474,7 +505,7 @@ async def get_2fa_status(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Consultar si el 2FA está habilitado para el usuario actual.
|
||||
Consultar si el 2FA está habilitado para el usuario actual.
|
||||
|
||||
Returns:
|
||||
Estado de 2FA del usuario autenticado.
|
||||
@@ -488,10 +519,10 @@ async def setup_2fa(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Iniciar configuración de 2FA: genera un nuevo TOTP secret y QR URI.
|
||||
Iniciar configuración de 2FA: genera un nuevo TOTP secret y QR URI.
|
||||
|
||||
El secret se guarda en BD pero 2FA NO se activa todavía.
|
||||
Se necesita llamar a /2fa/enable con un código válido para activarlo.
|
||||
El secret se guarda en BD pero 2FA NO se activa todavÃa.
|
||||
Se necesita llamar a /2fa/enable con un código válido para activarlo.
|
||||
|
||||
Returns:
|
||||
Secret y QR URI para escanear con la app autenticadora.
|
||||
@@ -499,7 +530,7 @@ async def setup_2fa(
|
||||
new_secret = security.generate_totp_secret()
|
||||
qr_uri = security.generate_totp_uri(new_secret, current_user.email)
|
||||
|
||||
# Guardar el secret (sin habilitar aún)
|
||||
# Guardar el secret (sin habilitar aún)
|
||||
current_user.totp_secret = new_secret
|
||||
await db.commit()
|
||||
|
||||
@@ -515,29 +546,29 @@ async def enable_2fa(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Activar 2FA verificando que el usuario escaneó correctamente el QR.
|
||||
Activar 2FA verificando que el usuario escaneó correctamente el QR.
|
||||
|
||||
Requiere que /2fa/setup haya sido llamado previamente.
|
||||
|
||||
Args:
|
||||
data: Código TOTP generado por la app autenticadora.
|
||||
data: Código TOTP generado por la app autenticadora.
|
||||
|
||||
Returns:
|
||||
Confirmación y lista de códigos de respaldo.
|
||||
Confirmación y lista de códigos de respaldo.
|
||||
"""
|
||||
if not current_user.totp_secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Primero inicia el proceso de configuración con /2fa/setup"
|
||||
detail="Primero inicia el proceso de configuración con /2fa/setup"
|
||||
)
|
||||
|
||||
if not security.verify_totp(current_user.totp_secret, data.totp_code):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Código TOTP inválido. Verifica la hora de tu dispositivo e intenta de nuevo."
|
||||
detail="Código TOTP inválido. Verifica la hora de tu dispositivo e intenta de nuevo."
|
||||
)
|
||||
|
||||
# Activar 2FA y generar códigos de respaldo
|
||||
# Activar 2FA y generar códigos de respaldo
|
||||
backup_codes = security.generate_backup_codes()
|
||||
current_user.totp_enabled = True
|
||||
current_user.backup_codes = backup_codes
|
||||
@@ -565,21 +596,21 @@ async def disable_2fa(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Deshabilitar 2FA verificando con código TOTP o código de respaldo.
|
||||
Deshabilitar 2FA verificando con código TOTP o código de respaldo.
|
||||
|
||||
Args:
|
||||
data: totp_code o backup_code para verificar identidad.
|
||||
|
||||
Returns:
|
||||
Mensaje de confirmación.
|
||||
Mensaje de confirmación.
|
||||
"""
|
||||
if not current_user.totp_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="El 2FA no está habilitado en esta cuenta"
|
||||
detail="El 2FA no está habilitado en esta cuenta"
|
||||
)
|
||||
|
||||
# Verificar con TOTP o código de respaldo
|
||||
# Verificar con TOTP o código de respaldo
|
||||
verified = False
|
||||
|
||||
if data.totp_code:
|
||||
@@ -587,7 +618,7 @@ async def disable_2fa(
|
||||
elif data.backup_code and current_user.backup_codes:
|
||||
if data.backup_code in current_user.backup_codes:
|
||||
verified = True
|
||||
# Invalidar el código de respaldo usado
|
||||
# Invalidar el código de respaldo usado
|
||||
current_user.backup_codes = [
|
||||
c for c in current_user.backup_codes if c != data.backup_code
|
||||
]
|
||||
@@ -595,7 +626,7 @@ async def disable_2fa(
|
||||
if not verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Verificación fallida. Proporciona un código TOTP o un código de respaldo válido."
|
||||
detail="Verificación fallida. Proporciona un código TOTP o un código de respaldo válido."
|
||||
)
|
||||
|
||||
# Deshabilitar 2FA
|
||||
@@ -616,7 +647,7 @@ async def disable_2fa(
|
||||
|
||||
logger.info("2FA disabled", user_id=str(current_user.id))
|
||||
|
||||
return {"message": "Autenticación de dos factores deshabilitada correctamente"}
|
||||
return {"message": "Autenticación de dos factores deshabilitada correctamente"}
|
||||
|
||||
|
||||
@router.post("/change-password", status_code=status.HTTP_200_OK)
|
||||
@@ -626,32 +657,32 @@ async def change_password(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Cambiar la contraseña del usuario autenticado.
|
||||
Cambiar la contraseña del usuario autenticado.
|
||||
|
||||
Verifica la contraseña actual antes de actualizar.
|
||||
Requiere autenticación activa.
|
||||
Verifica la contraseña actual antes de actualizar.
|
||||
Requiere autenticación activa.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
# Validar longitud mínima
|
||||
# Validar longitud mÃnima
|
||||
if len(data.new_password) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="La nueva contraseña debe tener al menos 8 caracteres"
|
||||
detail="La nueva contraseña debe tener al menos 8 caracteres"
|
||||
)
|
||||
|
||||
# Verificar que la contraseña actual sea correcta
|
||||
# Verificar que la contraseña actual sea correcta
|
||||
if not security.verify_password(data.current_password, current_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="La contraseña actual es incorrecta"
|
||||
detail="La contraseña actual es incorrecta"
|
||||
)
|
||||
|
||||
# No permitir que la nueva sea igual a la actual
|
||||
if security.verify_password(data.new_password, current_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="La nueva contraseña no puede ser igual a la actual"
|
||||
detail="La nueva contraseña no puede ser igual a la actual"
|
||||
)
|
||||
|
||||
current_user.password_hash = security.hash_password(data.new_password)
|
||||
@@ -669,11 +700,11 @@ async def change_password(
|
||||
await db.commit()
|
||||
|
||||
logger.info("Password changed", user_id=str(current_user.id))
|
||||
return {"message": "Contraseña actualizada correctamente"}
|
||||
return {"message": "Contraseña actualizada correctamente"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Recuperación de contraseña (forgot / reset)
|
||||
# Recuperación de contraseña (forgot / reset)
|
||||
# ============================================================
|
||||
|
||||
_RESET_TOKEN_TTL = 1800 # 30 minutos en segundos
|
||||
@@ -681,15 +712,17 @@ _RESET_KEY_PREFIX = "pwd_reset:"
|
||||
|
||||
|
||||
@router.post("/forgot-password", status_code=status.HTTP_200_OK)
|
||||
@limiter.limit("5/minute")
|
||||
async def forgot_password(
|
||||
request: Request,
|
||||
data: ForgotPasswordRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Solicitar reseteo de contraseña.
|
||||
Solicitar reseteo de contraseña.
|
||||
|
||||
Siempre retorna 200 aunque el email no exista, para no revelar
|
||||
si una dirección está registrada en el sistema.
|
||||
si una dirección está registrada en el sistema.
|
||||
"""
|
||||
import secrets
|
||||
from redis.asyncio import from_url as redis_from_url
|
||||
@@ -705,9 +738,9 @@ async def forgot_password(
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
# Respuesta idéntica — no revelar existencia
|
||||
# Respuesta idéntica — no revelar existencia
|
||||
logger.info("Forgot password: email not found", email=data.email)
|
||||
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
|
||||
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
|
||||
|
||||
# Generar token seguro
|
||||
token = secrets.token_urlsafe(32)
|
||||
@@ -727,7 +760,7 @@ async def forgot_password(
|
||||
|
||||
await send_email(
|
||||
to_email=user.email,
|
||||
subject="Restablece tu contraseña — ServiceManager",
|
||||
subject="Restablece tu contraseña — ServiceManager",
|
||||
html_content=html,
|
||||
text_content=text,
|
||||
)
|
||||
@@ -744,16 +777,18 @@ async def forgot_password(
|
||||
await db.commit()
|
||||
|
||||
logger.info("Password reset email sent", user_id=str(user.id))
|
||||
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
|
||||
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
|
||||
|
||||
|
||||
@router.post("/reset-password", status_code=status.HTTP_200_OK)
|
||||
@limiter.limit("5/minute")
|
||||
async def reset_password(
|
||||
request: Request,
|
||||
data: ResetPasswordRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Aplicar nueva contraseña usando el token recibido por email.
|
||||
Aplicar nueva contraseña usando el token recibido por email.
|
||||
|
||||
El token es de un solo uso: se elimina de Redis al usarse.
|
||||
"""
|
||||
@@ -764,7 +799,7 @@ async def reset_password(
|
||||
if len(data.new_password) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="La contraseña debe tener al menos 8 caracteres"
|
||||
detail="La contraseña debe tener al menos 8 caracteres"
|
||||
)
|
||||
|
||||
redis_key = f"{_RESET_KEY_PREFIX}{data.token}"
|
||||
@@ -775,7 +810,7 @@ async def reset_password(
|
||||
if not user_id_str:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="El enlace de reseteo es inválido o ya expiró. Solicita uno nuevo."
|
||||
detail="El enlace de reseteo es inválido o ya expiró. Solicita uno nuevo."
|
||||
)
|
||||
|
||||
# Eliminar token inmediatamente (un solo uso)
|
||||
@@ -806,4 +841,4 @@ async def reset_password(
|
||||
await db.commit()
|
||||
|
||||
logger.info("Password reset completed", user_id=str(user.id))
|
||||
return {"message": "Contraseña actualizada correctamente. Ya puedes iniciar sesión."}
|
||||
return {"message": "Contraseña actualizada correctamente. Ya puedes iniciar sesión."}
|
||||
@@ -79,11 +79,16 @@ async def create_category(
|
||||
|
||||
✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
|
||||
"""
|
||||
# ✅ CORREGIDO: Asignar tenant_id del usuario actual
|
||||
db_category = Category(
|
||||
**category.model_dump(),
|
||||
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
|
||||
)
|
||||
# ADMIN global puede especificar tenant_id; otros usan el suyo
|
||||
from app.models.user import UserRole as _R
|
||||
data = category.model_dump()
|
||||
if current_user.role == _R.ADMIN and data.get("tenant_id"):
|
||||
target_tenant_id = data["tenant_id"]
|
||||
else:
|
||||
target_tenant_id = current_user.tenant_id
|
||||
data["tenant_id"] = target_tenant_id
|
||||
|
||||
db_category = Category(**data)
|
||||
|
||||
db.add(db_category)
|
||||
await db.commit()
|
||||
|
||||
130
backend/app/api/v1/endpoints/participants.py
Normal file
130
backend/app/api/v1/endpoints/participants.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
import uuid
|
||||
|
||||
from app.api import deps
|
||||
from app.core.database import get_db
|
||||
from app.models.ticket import Ticket, TicketParticipant
|
||||
from app.models.user import User
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class ParticipantAdd(BaseModel):
|
||||
user_id: uuid.UUID
|
||||
|
||||
class ParticipantResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
ticket_id: uuid.UUID
|
||||
role: str
|
||||
user_email: str
|
||||
user_name: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@router.get("/{ticket_id}/participants", response_model=list[ParticipantResponse])
|
||||
async def list_participants(
|
||||
ticket_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_user)
|
||||
):
|
||||
result = await db.execute(
|
||||
select(TicketParticipant)
|
||||
.where(TicketParticipant.ticket_id == ticket_id)
|
||||
.options(selectinload(TicketParticipant.user))
|
||||
)
|
||||
participants = result.scalars().all()
|
||||
return [
|
||||
ParticipantResponse(
|
||||
id=p.id,
|
||||
user_id=p.user_id,
|
||||
ticket_id=p.ticket_id,
|
||||
role=p.role,
|
||||
user_email=p.user.email,
|
||||
user_name=f"{p.user.first_name or ''} {p.user.last_name or ''}".strip()
|
||||
)
|
||||
for p in participants
|
||||
]
|
||||
|
||||
@router.post("/{ticket_id}/participants", response_model=ParticipantResponse)
|
||||
async def add_participant(
|
||||
ticket_id: uuid.UUID,
|
||||
data: ParticipantAdd,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_user)
|
||||
):
|
||||
# Verificar que el ticket existe
|
||||
ticket_result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
|
||||
ticket = ticket_result.scalar_one_or_none()
|
||||
if not ticket:
|
||||
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||
|
||||
# Solo el creador puede agregar participantes
|
||||
if ticket.created_by != current_user.id and not current_user.role.is_global:
|
||||
raise HTTPException(status_code=403, detail="Only the ticket creator can add participants")
|
||||
|
||||
# Verificar que el usuario existe
|
||||
user_result = await db.execute(select(User).where(User.id == data.user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Verificar que no sea duplicado
|
||||
existing = await db.execute(
|
||||
select(TicketParticipant).where(
|
||||
TicketParticipant.ticket_id == ticket_id,
|
||||
TicketParticipant.user_id == data.user_id
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="User is already a participant")
|
||||
|
||||
participant = TicketParticipant(
|
||||
ticket_id=ticket_id,
|
||||
user_id=data.user_id,
|
||||
role="participant"
|
||||
)
|
||||
db.add(participant)
|
||||
await db.commit()
|
||||
await db.refresh(participant)
|
||||
|
||||
return ParticipantResponse(
|
||||
id=participant.id,
|
||||
user_id=participant.user_id,
|
||||
ticket_id=participant.ticket_id,
|
||||
role=participant.role,
|
||||
user_email=user.email,
|
||||
user_name=f"{user.first_name or ''} {user.last_name or ''}".strip()
|
||||
)
|
||||
|
||||
@router.delete("/{ticket_id}/participants/{user_id}", status_code=204)
|
||||
async def remove_participant(
|
||||
ticket_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_user)
|
||||
):
|
||||
ticket_result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
|
||||
ticket = ticket_result.scalar_one_or_none()
|
||||
if not ticket:
|
||||
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||
|
||||
if ticket.created_by != current_user.id and not current_user.role.is_global:
|
||||
raise HTTPException(status_code=403, detail="Only the ticket creator can remove participants")
|
||||
|
||||
result = await db.execute(
|
||||
select(TicketParticipant).where(
|
||||
TicketParticipant.ticket_id == ticket_id,
|
||||
TicketParticipant.user_id == user_id
|
||||
)
|
||||
)
|
||||
participant = result.scalar_one_or_none()
|
||||
if not participant:
|
||||
raise HTTPException(status_code=404, detail="Participant not found")
|
||||
|
||||
await db.delete(participant)
|
||||
await db.commit()
|
||||
@@ -7,7 +7,7 @@ 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 sqlalchemy import select, func, and_, case, text, literal_column
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import uuid
|
||||
@@ -505,20 +505,23 @@ async def get_report_trends(
|
||||
tenant_filter = Ticket.tenant_id == current_user.tenant_id
|
||||
|
||||
# Tickets creados por día
|
||||
# literal_column("'day'") evita que SQLAlchemy genere múltiples parámetros
|
||||
# ($1, $4, $5) para 'day', lo que confunde a PostgreSQL en el GROUP BY.
|
||||
_day_lit = literal_column("'day'")
|
||||
created_rows = (await db.execute(
|
||||
select(
|
||||
func.date_trunc("day", Ticket.created_at).label("day"),
|
||||
func.date_trunc(_day_lit, 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))
|
||||
.group_by(func.date_trunc(_day_lit, Ticket.created_at))
|
||||
.order_by(func.date_trunc(_day_lit, 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.date_trunc(_day_lit, Ticket.resolved_at).label("day"),
|
||||
func.count(Ticket.id).label("cnt"),
|
||||
)
|
||||
.where(and_(
|
||||
@@ -526,8 +529,8 @@ async def get_report_trends(
|
||||
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))
|
||||
.group_by(func.date_trunc(_day_lit, Ticket.resolved_at))
|
||||
.order_by(func.date_trunc(_day_lit, Ticket.resolved_at))
|
||||
)).all()
|
||||
|
||||
created_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in created_rows}
|
||||
|
||||
@@ -28,14 +28,36 @@ async def create_tenant(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(deps.get_current_active_superuser)
|
||||
):
|
||||
from app.models.permission import TenantPermission, DEFAULT_PERMISSIONS
|
||||
from datetime import datetime
|
||||
|
||||
# Check existing slug
|
||||
query = select(Tenant).where(Tenant.slug == tenant.slug)
|
||||
result = await db.execute(query)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Tenant slug already exists")
|
||||
|
||||
db_tenant = Tenant(**tenant.model_dump())
|
||||
data = tenant.model_dump()
|
||||
data['slug'] = data['slug'].lower().strip()
|
||||
db_tenant = Tenant(**data)
|
||||
db.add(db_tenant)
|
||||
await db.flush() # genera el id sin hacer commit
|
||||
|
||||
# Inicializar permisos por defecto para el nuevo tenant
|
||||
now = datetime.utcnow()
|
||||
for role, perms in DEFAULT_PERMISSIONS.items():
|
||||
for permission, granted in perms.items():
|
||||
db.add(TenantPermission(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=db_tenant.id,
|
||||
user_id=None,
|
||||
role=role,
|
||||
permission=permission,
|
||||
granted=granted,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
))
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(db_tenant)
|
||||
return db_tenant
|
||||
|
||||
@@ -7,11 +7,13 @@ from sqlalchemy.orm import selectinload
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
import uuid
|
||||
from app.models.issue import TicketIssue, ticket_issue_tagged_users
|
||||
from app.api.schemas.issue import IssueCreate, IssueResponse
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.api.deps import get_current_user, get_current_tenant
|
||||
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.category import Category
|
||||
from app.models.system import System
|
||||
@@ -19,7 +21,7 @@ from app.models.comment import TicketComment
|
||||
from app.models.attachment import TicketAttachment
|
||||
from app.api.schemas.attachment import AttachmentResponse
|
||||
from app.api.schemas.ticket import (
|
||||
TicketCreate, TicketUpdate, TicketResponse,
|
||||
TicketCreate, TicketUpdate, TicketResponse,
|
||||
TicketCloseRequest, CommentCreate, CommentResponse
|
||||
)
|
||||
from app.core.file_handler import file_handler
|
||||
@@ -28,94 +30,33 @@ from app.api.v1.helpers import (
|
||||
safe_audit_log, generate_next_ticket_number, calculate_sla_deadlines, ticket_to_dict
|
||||
)
|
||||
from app.services.audit_service import AuditService
|
||||
from app.services.ticket_service import TicketService, get_ticket_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/", response_model=TicketResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_ticket(ticket: TicketCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
async def create_ticket(
|
||||
ticket: TicketCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
ticket_service: TicketService = Depends(get_ticket_service),
|
||||
):
|
||||
"""Crear un nuevo ticket"""
|
||||
max_retries = 3
|
||||
last_error = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
ticket_number = await generate_next_ticket_number(db, current_user.tenant_id)
|
||||
category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None
|
||||
system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None
|
||||
|
||||
category = None
|
||||
if category_uuid:
|
||||
category = await db.get(Category, category_uuid)
|
||||
if not category:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"La categoría con ID {ticket.category_id} no existe.")
|
||||
|
||||
if system_uuid:
|
||||
system = await db.get(System, system_uuid)
|
||||
if not system:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"El sistema con ID {ticket.affected_system_id} no existe.")
|
||||
|
||||
sla_response_due, sla_resolution_due = calculate_sla_deadlines(category)
|
||||
assigned_to_user = category.auto_assign_to if category and category.auto_assign_to else None
|
||||
|
||||
db_ticket = Ticket(
|
||||
id=uuid.uuid4(), tenant_id=current_user.tenant_id, ticket_number=ticket_number,
|
||||
subject=ticket.subject, description=ticket.description, category_id=category_uuid,
|
||||
affected_system_id=system_uuid, priority=TicketPriority[ticket.priority.upper()],
|
||||
created_by=current_user.id, assigned_to=assigned_to_user, status=TicketStatus.NEW,
|
||||
sla_response_due=sla_response_due, sla_resolution_due=sla_resolution_due,
|
||||
created_at=datetime.utcnow(), updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
db.add(db_ticket)
|
||||
await db.commit()
|
||||
await db.refresh(db_ticket)
|
||||
|
||||
await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
|
||||
action="ticket.create", resource_type="ticket", resource_id=db_ticket.id,
|
||||
new_values={"ticket_number": db_ticket.ticket_number, "subject": db_ticket.subject,
|
||||
"priority": db_ticket.priority.value, "status": db_ticket.status.value})
|
||||
|
||||
return {
|
||||
"id": str(db_ticket.id), "ticket_number": db_ticket.ticket_number, "subject": db_ticket.subject,
|
||||
"title": db_ticket.subject, "description": db_ticket.description, "status": db_ticket.status.value,
|
||||
"priority": db_ticket.priority.value, "category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
|
||||
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
|
||||
"system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
|
||||
"contact_email": ticket.contact_email,
|
||||
"contact_phone": ticket.contact_phone,
|
||||
"created_by": str(db_ticket.created_by), "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
|
||||
"created_at": db_ticket.created_at, "updated_at": db_ticket.updated_at,
|
||||
"sla_response_due": db_ticket.sla_response_due,
|
||||
"sla_resolution_due": db_ticket.sla_resolution_due,
|
||||
"first_response_at": db_ticket.first_response_at,
|
||||
"resolved_at": db_ticket.resolved_at,
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid UUID format: {str(e)}")
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
last_error = e
|
||||
if "duplicate key" in str(e).lower() and "ticket_number" in str(e).lower():
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Error creating ticket: {str(e)}")
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"No se pudo crear el ticket después de {max_retries} intentos: {str(last_error)}")
|
||||
return await ticket_service.create_ticket(ticket, current_user.tenant_id, current_user.id)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[TicketResponse])
|
||||
async def get_tickets(skip: int = 0, limit: int = 100, status: Optional[str] = None, priority: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
async def get_tickets(
|
||||
skip: int = 0, limit: int = 100,
|
||||
status: Optional[str] = None, priority: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Obtener tickets con filtros opcionales"""
|
||||
query = select(Ticket).where(Ticket.tenant_id == current_user.tenant_id)
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
||||
if current_user.role == UserRole.CLIENT_USER:
|
||||
query = query.where(Ticket.created_by == current_user.id)
|
||||
|
||||
|
||||
query = apply_enum_filter(query, Ticket.status, status, TicketStatus, "status")
|
||||
query = apply_enum_filter(query, Ticket.priority, priority, TicketPriority, "priority")
|
||||
query = query.options(
|
||||
@@ -124,26 +65,29 @@ async def get_tickets(skip: int = 0, limit: int = 100, status: Optional[str] = N
|
||||
selectinload(Ticket.assigned_to_user)
|
||||
)
|
||||
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
tickets = result.scalars().all()
|
||||
|
||||
return [ticket_to_dict(t) for t in tickets]
|
||||
|
||||
|
||||
@router.get("/admin/all", response_model=List[dict])
|
||||
async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter: Optional[str] = None,
|
||||
priority_filter: Optional[str] = None, tenant_id_filter: Optional[str] = None, category_filter: Optional[str] = None,
|
||||
assigned_to_filter: Optional[str] = None, search: Optional[str] = None, date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
async def get_all_tickets_admin(
|
||||
skip: int = 0, limit: int = 100,
|
||||
status_filter: Optional[str] = None, priority_filter: Optional[str] = None,
|
||||
tenant_id_filter: Optional[str] = None, category_filter: Optional[str] = None,
|
||||
assigned_to_filter: Optional[str] = None, search: Optional[str] = None,
|
||||
date_from: Optional[str] = None, date_to: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Obtener todos los tickets del tenant del administrador (ADMIN/SUPPORT_MANAGER)."""
|
||||
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
|
||||
if current_user.role not in (UserRole.ADMIN, UserRole.SUPPORT_MANAGER):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No tienes permisos para acceder a esta función")
|
||||
|
||||
query = select(Ticket, Tenant, User).join(Tenant, Ticket.tenant_id == Tenant.id).join(User, Ticket.created_by == User.id)
|
||||
|
||||
# SUPPORT_MANAGER solo ve su propio tenant.
|
||||
# ADMIN ve todos los tenants (es el administrador de la plataforma).
|
||||
if current_user.role == "SUPPORT_MANAGER":
|
||||
if current_user.role == UserRole.SUPPORT_MANAGER:
|
||||
query = query.where(Ticket.tenant_id == current_user.tenant_id)
|
||||
|
||||
query = apply_enum_filter(query, Ticket.status, status_filter, TicketStatus, "status")
|
||||
@@ -169,12 +113,11 @@ async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter:
|
||||
query = query.where(Ticket.created_at < date_to_parsed)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid date_to format. Use YYYY-MM-DD")
|
||||
|
||||
|
||||
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
# Cargar categorías en un solo query para evitar N+1
|
||||
|
||||
category_ids = list({ticket.category_id for ticket, _, _ in rows if ticket.category_id})
|
||||
categories_map = {}
|
||||
if category_ids:
|
||||
@@ -190,44 +133,66 @@ async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter:
|
||||
"category_name": categories_map.get(ticket.category_id) if ticket.category_id else None,
|
||||
"created_by": str(ticket.created_by), "creator_name": f"{creator.first_name} {creator.last_name}",
|
||||
"creator_email": creator.email, "assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
|
||||
"created_at": ticket.created_at, "updated_at": ticket.updated_at, "sla_response_due": ticket.sla_response_due,
|
||||
"sla_resolution_due": ticket.sla_resolution_due, "first_response_at": ticket.first_response_at,
|
||||
"resolved_at": ticket.resolved_at}
|
||||
"created_at": ticket.created_at, "updated_at": ticket.updated_at,
|
||||
"sla_response_due": ticket.sla_response_due, "sla_resolution_due": ticket.sla_resolution_due,
|
||||
"first_response_at": ticket.first_response_at, "resolved_at": ticket.resolved_at}
|
||||
for ticket, tenant, creator in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{ticket_id}", response_model=TicketResponse)
|
||||
async def get_ticket(ticket_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
async def get_ticket(
|
||||
ticket_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Obtener un ticket por ID"""
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid)
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
query = query.where(Ticket.tenant_id == current_user.tenant_id)
|
||||
if current_user.role == UserRole.CLIENT_USER:
|
||||
query = query.where(Ticket.created_by == current_user.id)
|
||||
|
||||
query = query.options(selectinload(Ticket.category), selectinload(Ticket.affected_system), selectinload(Ticket.assigned_to_user))
|
||||
|
||||
query = query.options(
|
||||
selectinload(Ticket.category),
|
||||
selectinload(Ticket.affected_system),
|
||||
selectinload(Ticket.assigned_to_user)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
db_ticket = result.scalars().first()
|
||||
|
||||
|
||||
if not db_ticket:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
|
||||
|
||||
|
||||
return ticket_to_dict(db_ticket)
|
||||
|
||||
|
||||
@router.patch("/{ticket_id}", response_model=TicketResponse)
|
||||
async def update_ticket(ticket_id: str, ticket: TicketUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
async def update_ticket(
|
||||
ticket_id: str, ticket: TicketUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Actualizar un ticket"""
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid)
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
query = query.where(Ticket.tenant_id == current_user.tenant_id)
|
||||
if current_user.role == UserRole.CLIENT_USER:
|
||||
query = query.where(Ticket.created_by == current_user.id)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
db_ticket = result.scalars().first()
|
||||
if not db_ticket:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
|
||||
|
||||
old_values = {"status": db_ticket.status.value, "priority": db_ticket.priority.value, "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None}
|
||||
|
||||
|
||||
old_values = {
|
||||
"status": db_ticket.status.value,
|
||||
"priority": db_ticket.priority.value,
|
||||
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None
|
||||
}
|
||||
|
||||
update_data = ticket.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if field == "status" and value:
|
||||
@@ -238,37 +203,48 @@ async def update_ticket(ticket_id: str, ticket: TicketUpdate, db: AsyncSession =
|
||||
setattr(db_ticket, field, uuid.UUID(value))
|
||||
elif value is not None:
|
||||
setattr(db_ticket, field, value)
|
||||
|
||||
|
||||
db_ticket.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(db_ticket, ["category", "affected_system", "assigned_to_user"])
|
||||
|
||||
new_values = {"status": db_ticket.status.value, "priority": db_ticket.priority.value, "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None}
|
||||
await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
|
||||
|
||||
new_values = {
|
||||
"status": db_ticket.status.value,
|
||||
"priority": db_ticket.priority.value,
|
||||
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None
|
||||
}
|
||||
await safe_audit_log(
|
||||
db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
|
||||
action="ticket.update", resource_type="ticket", resource_id=db_ticket.id,
|
||||
old_values=old_values, new_values=new_values)
|
||||
|
||||
old_values=old_values, new_values=new_values
|
||||
)
|
||||
|
||||
return ticket_to_dict(db_ticket)
|
||||
|
||||
|
||||
@router.patch("/{ticket_id}/close", response_model=TicketResponse)
|
||||
async def close_ticket(ticket_id: str, close_request: TicketCloseRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
async def close_ticket(
|
||||
ticket_id: str, close_request: TicketCloseRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Cerrar un ticket"""
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
|
||||
result = await db.execute(query)
|
||||
db_ticket = result.scalars().first()
|
||||
|
||||
|
||||
if not db_ticket:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
|
||||
|
||||
|
||||
if db_ticket.status in [TicketStatus.CLOSED, TicketStatus.RESOLVED]:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ticket ya está cerrado o resuelto")
|
||||
|
||||
|
||||
old_status = db_ticket.status.value
|
||||
db_ticket.status = TicketStatus.CLOSED
|
||||
db_ticket.resolved_at = datetime.utcnow()
|
||||
db_ticket.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
if close_request.resolution_notes:
|
||||
comment = TicketComment(
|
||||
id=uuid.uuid4(), ticket_id=ticket_uuid, author_id=current_user.id,
|
||||
@@ -276,203 +252,461 @@ async def close_ticket(ticket_id: str, close_request: TicketCloseRequest, db: As
|
||||
is_internal=False, created_at=datetime.utcnow(), updated_at=datetime.utcnow()
|
||||
)
|
||||
db.add(comment)
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(db_ticket, ["category", "affected_system", "assigned_to_user"])
|
||||
|
||||
await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
|
||||
|
||||
await safe_audit_log(
|
||||
db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
|
||||
action="ticket.close", resource_type="ticket", resource_id=db_ticket.id,
|
||||
old_values={"status": old_status}, new_values={"status": db_ticket.status.value, "resolution_notes": close_request.resolution_notes})
|
||||
|
||||
old_values={"status": old_status},
|
||||
new_values={"status": db_ticket.status.value, "resolution_notes": close_request.resolution_notes}
|
||||
)
|
||||
|
||||
return ticket_to_dict(db_ticket)
|
||||
|
||||
|
||||
@router.get("/{ticket_id}/comments", response_model=List[CommentResponse])
|
||||
async def get_ticket_comments(ticket_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
async def get_ticket_comments(
|
||||
ticket_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Obtener comentarios de un ticket"""
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid)
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
query = query.where(Ticket.tenant_id == current_user.tenant_id)
|
||||
if current_user.role == UserRole.CLIENT_USER:
|
||||
query = query.where(Ticket.created_by == current_user.id)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
ticket_obj = result.scalars().first()
|
||||
if not ticket_obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
|
||||
|
||||
comments_query = select(TicketComment).where(TicketComment.ticket_id == ticket_uuid).options(selectinload(TicketComment.author)).order_by(TicketComment.created_at.desc())
|
||||
|
||||
comments_query = (
|
||||
select(TicketComment)
|
||||
.where(TicketComment.ticket_id == ticket_uuid)
|
||||
.options(selectinload(TicketComment.author))
|
||||
.order_by(TicketComment.created_at.desc())
|
||||
)
|
||||
result = await db.execute(comments_query)
|
||||
comments = result.scalars().all()
|
||||
|
||||
|
||||
return [
|
||||
{"id": str(c.id), "ticket_id": str(c.ticket_id), "author_id": str(c.author_id),
|
||||
"author_name": f"{c.author.first_name} {c.author.last_name}" if c.author else "Unknown",
|
||||
"content": c.content, "is_internal": c.is_internal, "created_at": c.created_at, "updated_at": c.updated_at}
|
||||
"content": c.content, "is_internal": c.is_internal,
|
||||
"created_at": c.created_at, "updated_at": c.updated_at}
|
||||
for c in comments
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_comment(ticket_id: str, comment: CommentCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
async def create_comment(
|
||||
ticket_id: str, comment: CommentCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Crear un comentario en un ticket"""
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid)
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
query = query.where(Ticket.tenant_id == current_user.tenant_id)
|
||||
if current_user.role == UserRole.CLIENT_USER:
|
||||
query = query.where(Ticket.created_by == current_user.id)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
ticket_obj = result.scalars().first()
|
||||
if not ticket_obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
|
||||
|
||||
|
||||
if comment.is_internal and current_user.role.is_client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Clientes no pueden crear comentarios internos"
|
||||
)
|
||||
|
||||
new_comment = TicketComment(
|
||||
id=uuid.uuid4(), ticket_id=ticket_uuid, author_id=current_user.id,
|
||||
content=comment.content, is_internal=comment.is_internal,
|
||||
created_at=datetime.utcnow(), updated_at=datetime.utcnow()
|
||||
)
|
||||
db.add(new_comment)
|
||||
|
||||
|
||||
staff_roles = ["ADMIN", "SUPPORT_MANAGER", "AGENT"]
|
||||
if current_user.role in staff_roles and not comment.is_internal and ticket_obj.first_response_at is None:
|
||||
ticket_obj.first_response_at = datetime.utcnow()
|
||||
|
||||
|
||||
ticket_obj.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(new_comment)
|
||||
|
||||
|
||||
return {
|
||||
"id": str(new_comment.id), "ticket_id": str(new_comment.ticket_id), "author_id": str(new_comment.author_id),
|
||||
"id": str(new_comment.id), "ticket_id": str(new_comment.ticket_id),
|
||||
"author_id": str(new_comment.author_id),
|
||||
"author_name": f"{current_user.first_name} {current_user.last_name}",
|
||||
"content": new_comment.content, "is_internal": new_comment.is_internal,
|
||||
"created_at": new_comment.created_at, "updated_at": new_comment.updated_at
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{ticket_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_ticket(ticket_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
async def delete_ticket(
|
||||
ticket_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Eliminar un ticket (solo admin/manager)"""
|
||||
if current_user.role not in (UserRole.ADMIN, UserRole.SUPPORT_MANAGER):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="No tienes permisos para eliminar tickets"
|
||||
)
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
|
||||
result = await db.execute(query)
|
||||
db_ticket = result.scalars().first()
|
||||
|
||||
|
||||
if not db_ticket:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
|
||||
|
||||
old_values = {"ticket_number": db_ticket.ticket_number, "subject": db_ticket.subject,
|
||||
"status": db_ticket.status.value, "priority": db_ticket.priority.value}
|
||||
|
||||
|
||||
old_values = {
|
||||
"ticket_number": db_ticket.ticket_number, "subject": db_ticket.subject,
|
||||
"status": db_ticket.status.value, "priority": db_ticket.priority.value
|
||||
}
|
||||
|
||||
await db.delete(db_ticket)
|
||||
await db.commit()
|
||||
|
||||
await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
|
||||
action="ticket.delete", resource_type="ticket", resource_id=ticket_uuid, old_values=old_values)
|
||||
|
||||
|
||||
await safe_audit_log(
|
||||
db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
|
||||
action="ticket.delete", resource_type="ticket", resource_id=ticket_uuid,
|
||||
old_values=old_values
|
||||
)
|
||||
|
||||
return {"message": "Ticket deleted successfully"}
|
||||
|
||||
|
||||
@router.get("/{ticket_id}/attachments", response_model=List[AttachmentResponse])
|
||||
async def get_ticket_attachments(ticket_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), current_tenant: Tenant = Depends(get_current_tenant)):
|
||||
async def get_ticket_attachments(
|
||||
ticket_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_tenant: Tenant = Depends(get_current_tenant)
|
||||
):
|
||||
"""Obtener adjuntos de un ticket"""
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
result = await db.execute(select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id))
|
||||
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid)
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
query = query.where(Ticket.tenant_id == current_tenant.id)
|
||||
result = await db.execute(query)
|
||||
ticket = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if not ticket:
|
||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id:
|
||||
if current_user.role == UserRole.CLIENT_USER and ticket.created_by != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||
|
||||
result = await db.execute(select(TicketAttachment).where(TicketAttachment.ticket_id == ticket_uuid).options(selectinload(TicketAttachment.uploaded_by_user)).order_by(TicketAttachment.created_at.desc()))
|
||||
|
||||
result = await db.execute(
|
||||
select(TicketAttachment)
|
||||
.where(TicketAttachment.ticket_id == ticket_uuid)
|
||||
.options(selectinload(TicketAttachment.uploaded_by_user))
|
||||
.order_by(TicketAttachment.created_at.desc())
|
||||
)
|
||||
attachments = result.scalars().all()
|
||||
|
||||
|
||||
return [
|
||||
AttachmentResponse(
|
||||
id=att.id, ticket_id=att.ticket_id, comment_id=att.comment_id, uploaded_by=att.uploaded_by,
|
||||
filename=att.filename, original_filename=att.original_filename, mime_type=att.mime_type,
|
||||
id=att.id, ticket_id=att.ticket_id, comment_id=att.comment_id,
|
||||
uploaded_by=att.uploaded_by, filename=att.filename,
|
||||
original_filename=att.original_filename, mime_type=att.mime_type,
|
||||
file_size=att.file_size, file_path=att.file_path,
|
||||
uploaded_by_name=f"{att.uploaded_by_user.first_name} {att.uploaded_by_user.last_name}" if att.uploaded_by_user else "Unknown",
|
||||
created_at=att.created_at, download_url=f"/api/v1/tickets/{ticket_id}/attachments/{att.id}/download"
|
||||
created_at=att.created_at,
|
||||
download_url=f"/api/v1/tickets/{ticket_id}/attachments/{att.id}/download"
|
||||
) for att in attachments
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/attachments", status_code=status.HTTP_201_CREATED)
|
||||
async def upload_attachment(ticket_id: str, file: UploadFile = File(...), db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user), current_tenant: Tenant = Depends(get_current_tenant)):
|
||||
async def upload_attachment(
|
||||
ticket_id: str, file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_tenant: Tenant = Depends(get_current_tenant)
|
||||
):
|
||||
"""Subir un archivo adjunto a un ticket"""
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
result = await db.execute(select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id))
|
||||
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid)
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
query = query.where(Ticket.tenant_id == current_tenant.id)
|
||||
result = await db.execute(query)
|
||||
ticket = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if not ticket:
|
||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id:
|
||||
if current_user.role == UserRole.CLIENT_USER and ticket.created_by != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||
|
||||
file_metadata = await file_handler.save_upload(file, current_tenant.id, ticket_uuid)
|
||||
|
||||
|
||||
upload_tenant_id = ticket.tenant_id
|
||||
file_metadata = await file_handler.save_upload(file, upload_tenant_id, ticket_uuid)
|
||||
|
||||
attachment = TicketAttachment(
|
||||
id=uuid.uuid4(), ticket_id=ticket_uuid, uploaded_by=current_user.id, filename=file_metadata["filename"],
|
||||
original_filename=file_metadata["original_filename"], mime_type=file_metadata["mime_type"],
|
||||
file_size=file_metadata["file_size"], file_path=file_metadata["file_path"],
|
||||
md5_hash=file_metadata["md5_hash"], sha256_hash=file_metadata["sha256_hash"], created_at=datetime.utcnow()
|
||||
id=uuid.uuid4(), ticket_id=ticket_uuid, uploaded_by=current_user.id,
|
||||
filename=file_metadata["filename"], original_filename=file_metadata["original_filename"],
|
||||
mime_type=file_metadata["mime_type"], file_size=file_metadata["file_size"],
|
||||
file_path=file_metadata["file_path"], md5_hash=file_metadata["md5_hash"],
|
||||
sha256_hash=file_metadata["sha256_hash"], created_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
|
||||
db.add(attachment)
|
||||
await db.commit()
|
||||
await db.refresh(attachment, ["uploaded_by_user"])
|
||||
|
||||
|
||||
return {
|
||||
"success": True, "message": "Archivo subido exitosamente",
|
||||
"data": AttachmentResponse(
|
||||
id=attachment.id, ticket_id=attachment.ticket_id, comment_id=attachment.comment_id,
|
||||
uploaded_by=attachment.uploaded_by, filename=attachment.filename, original_filename=attachment.original_filename,
|
||||
mime_type=attachment.mime_type, file_size=attachment.file_size, file_path=attachment.file_path,
|
||||
uploaded_by=attachment.uploaded_by, filename=attachment.filename,
|
||||
original_filename=attachment.original_filename, mime_type=attachment.mime_type,
|
||||
file_size=attachment.file_size, file_path=attachment.file_path,
|
||||
uploaded_by_name=f"{attachment.uploaded_by_user.first_name} {attachment.uploaded_by_user.last_name}",
|
||||
created_at=attachment.created_at, download_url=f"/api/v1/tickets/{ticket_id}/attachments/{attachment.id}/download"
|
||||
created_at=attachment.created_at,
|
||||
download_url=f"/api/v1/tickets/{ticket_id}/attachments/{attachment.id}/download"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{ticket_id}/attachments/{attachment_id}/download")
|
||||
async def download_attachment(ticket_id: str, attachment_id: str, db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user), current_tenant: Tenant = Depends(get_current_tenant)):
|
||||
async def download_attachment(
|
||||
ticket_id: str, attachment_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_tenant: Tenant = Depends(get_current_tenant)
|
||||
):
|
||||
"""Descargar un archivo adjunto"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.info(f"Download request - ticket_id: {ticket_id}, attachment_id: {attachment_id}")
|
||||
|
||||
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
attachment_uuid = validate_uuid_param(attachment_id, "attachment ID")
|
||||
|
||||
result = await db.execute(select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id))
|
||||
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid)
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
query = query.where(Ticket.tenant_id == current_tenant.id)
|
||||
result = await db.execute(query)
|
||||
ticket = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if not ticket:
|
||||
logger.error(f"Ticket not found - ticket_id: {ticket_id}")
|
||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id:
|
||||
if current_user.role == UserRole.CLIENT_USER and ticket.created_by != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||
|
||||
result = await db.execute(select(TicketAttachment).where(TicketAttachment.id == attachment_uuid, TicketAttachment.ticket_id == ticket_uuid))
|
||||
|
||||
result = await db.execute(
|
||||
select(TicketAttachment).where(
|
||||
TicketAttachment.id == attachment_uuid,
|
||||
TicketAttachment.ticket_id == ticket_uuid
|
||||
)
|
||||
)
|
||||
attachment = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if not attachment:
|
||||
logger.error(f"Attachment not found - attachment_id: {attachment_id}")
|
||||
raise HTTPException(status_code=404, detail="Adjunto no encontrado")
|
||||
|
||||
logger.info(f"Attachment found - file_path: {attachment.file_path}, original_filename: {attachment.original_filename}")
|
||||
|
||||
|
||||
try:
|
||||
file_path = file_handler.get_file_path(attachment.file_path)
|
||||
logger.info(f"Absolute file path: {file_path}")
|
||||
|
||||
if not file_path.exists():
|
||||
logger.error(f"File does not exist at path: {file_path}")
|
||||
raise HTTPException(status_code=404, detail="Archivo no encontrado en el sistema")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting file path: {str(e)}")
|
||||
raise
|
||||
|
||||
logger.info(f"Returning file: {attachment.original_filename}")
|
||||
return FileResponse(path=file_path, filename=attachment.original_filename, media_type=attachment.mime_type)
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=attachment.original_filename,
|
||||
media_type=attachment.mime_type
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{ticket_id}/issues", response_model=List[IssueResponse])
|
||||
async def get_ticket_issues(
|
||||
ticket_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Obtener asuntos de un ticket."""
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid)
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
query = query.where(Ticket.tenant_id == current_user.tenant_id)
|
||||
if current_user.role == UserRole.CLIENT_USER:
|
||||
query = query.where(Ticket.created_by == current_user.id)
|
||||
|
||||
result = await db.execute(query)
|
||||
ticket_obj = result.scalars().first()
|
||||
if not ticket_obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ticket no encontrado")
|
||||
|
||||
issues_query = (
|
||||
select(TicketIssue)
|
||||
.where(TicketIssue.ticket_id == ticket_uuid)
|
||||
.options(
|
||||
selectinload(TicketIssue.created_by_user),
|
||||
selectinload(TicketIssue.tagged_users),
|
||||
)
|
||||
.order_by(TicketIssue.created_at.desc())
|
||||
)
|
||||
result = await db.execute(issues_query)
|
||||
issues = result.scalars().all()
|
||||
|
||||
return [
|
||||
IssueResponse(
|
||||
id=issue.id, ticket_id=issue.ticket_id, tenant_id=issue.tenant_id,
|
||||
content=issue.content, priority=issue.priority.value,
|
||||
created_by=issue.created_by,
|
||||
created_by_name=f"{issue.created_by_user.first_name} {issue.created_by_user.last_name}",
|
||||
tagged_users=[
|
||||
{"id": u.id, "full_name": f"{u.first_name} {u.last_name}", "email": u.email}
|
||||
for u in issue.tagged_users
|
||||
],
|
||||
attachment_filename=issue.attachment_filename,
|
||||
attachment_mime_type=issue.attachment_mime_type,
|
||||
created_at=issue.created_at, updated_at=issue.updated_at,
|
||||
)
|
||||
for issue in issues
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/issues", response_model=IssueResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_ticket_issue(
|
||||
ticket_id: str,
|
||||
issue: IssueCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
):
|
||||
"""Crear un asunto de escalación en un ticket."""
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid)
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
query = query.where(Ticket.tenant_id == current_user.tenant_id)
|
||||
result = await db.execute(query)
|
||||
ticket_obj = result.scalars().first()
|
||||
if not ticket_obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ticket no encontrado")
|
||||
|
||||
is_ticket_owner = ticket_obj.created_by == current_user.id
|
||||
is_client_admin = current_user.role == UserRole.CLIENT_ADMIN
|
||||
is_staff = current_user.role.is_global
|
||||
|
||||
if not is_ticket_owner and not is_client_admin and not is_staff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo el creador del ticket o el administrador pueden crear asuntos",
|
||||
)
|
||||
|
||||
tagged_users = []
|
||||
if issue.tagged_user_ids:
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
User.id.in_(issue.tagged_user_ids),
|
||||
User.is_active == True,
|
||||
)
|
||||
)
|
||||
tagged_users = result.scalars().all()
|
||||
|
||||
found_ids = {u.id for u in tagged_users}
|
||||
missing = [str(uid) for uid in issue.tagged_user_ids if uid not in found_ids]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Usuarios no encontrados: {missing}",
|
||||
)
|
||||
|
||||
new_issue = TicketIssue(
|
||||
id=uuid.uuid4(), ticket_id=ticket_uuid,
|
||||
tenant_id=ticket_obj.tenant_id, created_by=current_user.id,
|
||||
content=issue.content, priority=TicketPriority[issue.priority],
|
||||
attachment_path=None, attachment_filename=None, attachment_mime_type=None,
|
||||
created_at=datetime.utcnow(), updated_at=datetime.utcnow(),
|
||||
)
|
||||
new_issue.tagged_users = tagged_users
|
||||
db.add(new_issue)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(new_issue, ["created_by_user", "tagged_users"])
|
||||
|
||||
await safe_audit_log(
|
||||
db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
|
||||
action="ticket.issue.create", resource_type="ticket_issue", resource_id=new_issue.id,
|
||||
new_values={
|
||||
"ticket_id": str(ticket_uuid), "priority": issue.priority,
|
||||
"tagged_users": [str(uid) for uid in issue.tagged_user_ids],
|
||||
},
|
||||
)
|
||||
|
||||
return IssueResponse(
|
||||
id=new_issue.id, ticket_id=new_issue.ticket_id, tenant_id=new_issue.tenant_id,
|
||||
content=new_issue.content, priority=new_issue.priority.value,
|
||||
created_by=new_issue.created_by,
|
||||
created_by_name=f"{new_issue.created_by_user.first_name} {new_issue.created_by_user.last_name}",
|
||||
tagged_users=[
|
||||
{"id": u.id, "full_name": f"{u.first_name} {u.last_name}", "email": u.email}
|
||||
for u in new_issue.tagged_users
|
||||
],
|
||||
attachment_filename=new_issue.attachment_filename,
|
||||
attachment_mime_type=new_issue.attachment_mime_type,
|
||||
created_at=new_issue.created_at, updated_at=new_issue.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/issues/{issue_id}/attachment", status_code=status.HTTP_200_OK)
|
||||
async def upload_issue_attachment(
|
||||
ticket_id: str,
|
||||
issue_id: str,
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
):
|
||||
"""Subir adjunto a un asunto existente."""
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
issue_uuid = validate_uuid_param(issue_id, "issue ID")
|
||||
|
||||
result = await db.execute(
|
||||
select(TicketIssue).where(
|
||||
TicketIssue.id == issue_uuid,
|
||||
TicketIssue.ticket_id == ticket_uuid,
|
||||
)
|
||||
)
|
||||
db_issue = result.scalars().first()
|
||||
if not db_issue:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asunto no encontrado")
|
||||
|
||||
if db_issue.created_by != current_user.id and not current_user.role.is_global:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Sin permisos")
|
||||
|
||||
upload_tenant_id = db_issue.tenant_id
|
||||
file_metadata = await file_handler.save_upload(file, upload_tenant_id, ticket_uuid)
|
||||
|
||||
db_issue.attachment_path = file_metadata["file_path"]
|
||||
db_issue.attachment_filename = file_metadata["original_filename"]
|
||||
db_issue.attachment_mime_type = file_metadata["mime_type"]
|
||||
db_issue.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"message": "Adjunto subido correctamente",
|
||||
"attachment_filename": db_issue.attachment_filename,
|
||||
"attachment_mime_type": db_issue.attachment_mime_type,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,8 +45,12 @@ async def read_users(
|
||||
- role: filtrar por rol
|
||||
- is_active: filtrar por estado activo
|
||||
"""
|
||||
# ✅ CORREGIDO: Filtrar por tenant_id
|
||||
query = select(User).where(User.tenant_id == current_user.tenant_id)
|
||||
# ADMIN global ve todos los tenants; el resto solo ve su propio tenant
|
||||
from app.models.user import UserRole as _UserRole
|
||||
if current_user.role != _UserRole.ADMIN:
|
||||
query = select(User).where(User.tenant_id == current_user.tenant_id)
|
||||
else:
|
||||
query = select(User)
|
||||
|
||||
# Aplicar filtros opcionales
|
||||
if role:
|
||||
@@ -144,10 +148,13 @@ async def read_user(
|
||||
|
||||
✅ Implementa multi-tenancy: solo permite acceso a usuarios del propio tenant.
|
||||
"""
|
||||
query = select(User).where(
|
||||
User.id == user_id,
|
||||
User.tenant_id == current_user.tenant_id # ✅ Seguridad multi-tenant
|
||||
)
|
||||
if current_user.role.value == 'ADMIN':
|
||||
query = select(User).where(User.id == user_id)
|
||||
else:
|
||||
query = select(User).where(
|
||||
User.id == user_id,
|
||||
User.tenant_id == current_user.tenant_id
|
||||
)
|
||||
result = await db.execute(query)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
@@ -183,11 +190,14 @@ async def update_user(
|
||||
detail="You don't have permission to update users"
|
||||
)
|
||||
|
||||
# Buscar usuario
|
||||
query = select(User).where(
|
||||
User.id == user_id,
|
||||
User.tenant_id == current_user.tenant_id
|
||||
)
|
||||
# Buscar usuario - ADMIN global puede editar cualquier tenant
|
||||
if current_user.role.value == "ADMIN":
|
||||
query = select(User).where(User.id == user_id)
|
||||
else:
|
||||
query = select(User).where(
|
||||
User.id == user_id,
|
||||
User.tenant_id == current_user.tenant_id
|
||||
)
|
||||
result = await db.execute(query)
|
||||
db_user = result.scalar_one_or_none()
|
||||
|
||||
@@ -276,8 +286,9 @@ async def delete_user(
|
||||
- No se puede eliminar a sí mismo
|
||||
- No se puede eliminar el último ADMIN del tenant
|
||||
"""
|
||||
# Verificar permisos - solo ADMIN puede eliminar
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
# Verificar permisos - ADMIN global o CLIENT_ADMIN pueden eliminar
|
||||
allowed = [UserRole.ADMIN, UserRole.CLIENT_ADMIN]
|
||||
if current_user.role not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only admins can delete users"
|
||||
@@ -290,11 +301,14 @@ async def delete_user(
|
||||
detail="You cannot delete yourself"
|
||||
)
|
||||
|
||||
# Buscar usuario
|
||||
query = select(User).where(
|
||||
User.id == user_id,
|
||||
User.tenant_id == current_user.tenant_id
|
||||
)
|
||||
# Buscar usuario - ADMIN global puede editar cualquier tenant
|
||||
if current_user.role.value == "ADMIN":
|
||||
query = select(User).where(User.id == user_id)
|
||||
else:
|
||||
query = select(User).where(
|
||||
User.id == user_id,
|
||||
User.tenant_id == current_user.tenant_id
|
||||
)
|
||||
result = await db.execute(query)
|
||||
db_user = result.scalar_one_or_none()
|
||||
|
||||
@@ -321,8 +335,8 @@ async def delete_user(
|
||||
detail="Cannot delete the last active admin of the tenant"
|
||||
)
|
||||
|
||||
# Soft delete
|
||||
db_user.is_active = False
|
||||
# Hard delete
|
||||
await db.delete(db_user)
|
||||
await db.commit()
|
||||
|
||||
# Registrar eliminación en auditoría
|
||||
@@ -367,11 +381,14 @@ async def activate_user(
|
||||
detail="You don't have permission to activate users"
|
||||
)
|
||||
|
||||
# Buscar usuario
|
||||
query = select(User).where(
|
||||
User.id == user_id,
|
||||
User.tenant_id == current_user.tenant_id
|
||||
)
|
||||
# Buscar usuario - ADMIN global puede editar cualquier tenant
|
||||
if current_user.role.value == "ADMIN":
|
||||
query = select(User).where(User.id == user_id)
|
||||
else:
|
||||
query = select(User).where(
|
||||
User.id == user_id,
|
||||
User.tenant_id == current_user.tenant_id
|
||||
)
|
||||
result = await db.execute(query)
|
||||
db_user = result.scalar_one_or_none()
|
||||
|
||||
|
||||
@@ -68,11 +68,11 @@ async def generate_next_ticket_number(db: AsyncSession, tenant_id: uuid.UUID) ->
|
||||
last_ticket_number = result.scalar_one_or_none()
|
||||
|
||||
if last_ticket_number:
|
||||
last_number = int(last_ticket_number.split('-')[1])
|
||||
last_number = int(last_ticket_number.split('-')[-1])
|
||||
next_number = last_number + 1
|
||||
else:
|
||||
next_number = 1
|
||||
|
||||
|
||||
return f"TK-{next_number:06d}"
|
||||
|
||||
|
||||
|
||||
@@ -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, reports
|
||||
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports, participants
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -53,6 +53,12 @@ api_router.include_router(
|
||||
prefix="/tickets",
|
||||
tags=["tickets"]
|
||||
)
|
||||
# Participants routes (nested under tickets)
|
||||
api_router.include_router(
|
||||
participants.router,
|
||||
prefix="/tickets",
|
||||
tags=["participants"]
|
||||
)
|
||||
|
||||
# Client Profile routes
|
||||
api_router.include_router(
|
||||
|
||||
@@ -5,7 +5,6 @@ Configuración centralizada usando Pydantic Settings v2
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import List, Optional
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator, Field
|
||||
import os
|
||||
@@ -60,8 +59,8 @@ class Settings(BaseSettings):
|
||||
# ===================================
|
||||
SMTP_HOST: str = Field(default="localhost")
|
||||
SMTP_PORT: int = Field(default=587)
|
||||
SMTP_USER: Optional[str] = Field(default=None)
|
||||
SMTP_PASSWORD: Optional[str] = Field(default=None)
|
||||
SMTP_USER: str | None = Field(default=None)
|
||||
SMTP_PASSWORD: str | None = Field(default=None)
|
||||
SMTP_USE_TLS: bool = Field(default=True)
|
||||
SMTP_USE_SSL: bool = Field(default=False)
|
||||
|
||||
@@ -79,7 +78,7 @@ class Settings(BaseSettings):
|
||||
UPLOAD_PATH: str = Field(default="/app/uploads")
|
||||
|
||||
@property
|
||||
def ALLOWED_FILE_EXTENSIONS(self) -> List[str]:
|
||||
def ALLOWED_FILE_EXTENSIONS(self) -> list[str]:
|
||||
"""Parse the comma-separated file extensions."""
|
||||
return [ext.strip().lower() for ext in self.ALLOWED_FILE_EXTENSIONS_STR.split(",")]
|
||||
|
||||
@@ -102,7 +101,7 @@ class Settings(BaseSettings):
|
||||
# ===================================
|
||||
LOG_LEVEL: str = Field(default="INFO")
|
||||
LOG_FORMAT: str = Field(default="json")
|
||||
LOG_FILE: Optional[str] = Field(default=None)
|
||||
LOG_FILE: str | None = Field(default=None)
|
||||
|
||||
# ===================================
|
||||
# FRONTEND URLS
|
||||
|
||||
20
backend/app/core/limiter.py
Normal file
20
backend/app/core/limiter.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
Rate Limiter - ServiceManagerWeb
|
||||
|
||||
Configura slowapi con Redis como storage backend.
|
||||
Respeta settings.RATE_LIMIT_ENABLED: si está desactivado usa memoria
|
||||
y el limiter queda en modo noop (enabled=False).
|
||||
"""
|
||||
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
storage_uri=settings.REDIS_URL if settings.RATE_LIMIT_ENABLED else "memory://",
|
||||
enabled=settings.RATE_LIMIT_ENABLED,
|
||||
)
|
||||
@@ -9,6 +9,9 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from contextlib import asynccontextmanager
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
import structlog
|
||||
import time
|
||||
import uuid
|
||||
@@ -31,6 +34,8 @@ from app.api.v1.router import api_router
|
||||
from app.middleware.tenant import TenantMiddleware
|
||||
from app.middleware.correlation_id import CorrelationIDMiddleware
|
||||
from app.core.cache import cache
|
||||
from app.core.limiter import limiter
|
||||
from app.models.issue import TicketIssue, ticket_issue_tagged_users
|
||||
|
||||
settings = get_settings()
|
||||
setup_logging()
|
||||
@@ -70,6 +75,11 @@ app = FastAPI(
|
||||
openapi_url=f"/{settings.API_VERSION}/openapi.json"
|
||||
)
|
||||
|
||||
# SlowAPI rate limiting
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
app.add_middleware(SlowAPIMiddleware)
|
||||
|
||||
# ===================================
|
||||
# MIDDLEWARE
|
||||
# ===================================
|
||||
@@ -87,8 +97,14 @@ if settings.is_production():
|
||||
"X-Correlation-ID",
|
||||
]
|
||||
else:
|
||||
cors_allow_methods = ["*"]
|
||||
cors_allow_headers = ["*"]
|
||||
cors_allow_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
||||
cors_allow_headers = [
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Tenant-ID",
|
||||
"X-Tenant-Slug",
|
||||
"X-Correlation-ID",
|
||||
]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
|
||||
@@ -42,6 +42,8 @@ class TenantMiddleware(BaseHTTPMiddleware):
|
||||
"/v1/auth/refresh",
|
||||
"/api/v1/auth/logout",
|
||||
"/v1/auth/logout",
|
||||
"/api/v1/auth/me",
|
||||
"/v1/auth/me",
|
||||
"/api/v1/auth/forgot-password",
|
||||
"/v1/auth/forgot-password",
|
||||
"/api/v1/auth/reset-password",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from .user import User
|
||||
from .tenant import Tenant
|
||||
from .ticket import Ticket
|
||||
from .issue import TicketIssue, ticket_issue_tagged_users
|
||||
from .comment import TicketComment
|
||||
from .system import System
|
||||
from .category import Category
|
||||
@@ -10,12 +11,15 @@ from .client_profile import ClientProfile
|
||||
from .attachment import TicketAttachment
|
||||
from .audit import AuditLog
|
||||
from .refresh_token import RefreshToken
|
||||
from .permission import TenantPermission
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Tenant",
|
||||
"Tenant",
|
||||
"Ticket",
|
||||
"TicketIssue",
|
||||
"TicketComment",
|
||||
"TenantPermission",
|
||||
"System",
|
||||
"Category",
|
||||
"ClientProfile",
|
||||
|
||||
102
backend/app/models/issue.py
Normal file
102
backend/app/models/issue.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
TicketIssue Model - ServiceManagerWeb
|
||||
|
||||
Asuntos de escalación asociados a tickets.
|
||||
Creados por el dueño del ticket o el CLIENT_ADMIN del tenant.
|
||||
"""
|
||||
from sqlalchemy import String, ForeignKey, Text, Table, Column
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base, GUID
|
||||
from app.models.ticket import TicketPriority
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM
|
||||
|
||||
|
||||
# Tabla de relación N:M entre TicketIssue y User (usuarios etiquetados)
|
||||
ticket_issue_tagged_users = Table(
|
||||
"ticket_issue_tagged_users",
|
||||
Base.metadata,
|
||||
Column(
|
||||
"issue_id",
|
||||
GUID(),
|
||||
ForeignKey("ticket_issues.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
),
|
||||
Column(
|
||||
"user_id",
|
||||
GUID(),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TicketIssue(Base):
|
||||
"""
|
||||
Asunto de escalación de un ticket.
|
||||
|
||||
Solo puede crearlo:
|
||||
- El usuario que creó el ticket (created_by del Ticket)
|
||||
- El CLIENT_ADMIN del mismo tenant
|
||||
"""
|
||||
__tablename__ = "ticket_issues"
|
||||
|
||||
# Multi-tenancy — siempre filtrar por esto
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relación con el ticket padre
|
||||
ticket_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(),
|
||||
ForeignKey("tickets.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Quién lo creó
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(),
|
||||
ForeignKey("users.id"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Contenido
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
priority: Mapped[TicketPriority] = mapped_column(
|
||||
SAEnum(TicketPriority, name="ticket_priority_enum", native_enum=False).with_variant(
|
||||
PG_ENUM(TicketPriority, name="ticket_priority_enum", create_type=True),
|
||||
"postgresql",
|
||||
),
|
||||
default=TicketPriority.MEDIUM,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Adjunto opcional (reutiliza file_handler igual que TicketAttachment)
|
||||
attachment_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
attachment_filename: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
attachment_mime_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
|
||||
# Relationships
|
||||
ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="issues")
|
||||
|
||||
created_by_user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
foreign_keys=[created_by],
|
||||
)
|
||||
|
||||
tagged_users: Mapped[List["User"]] = relationship(
|
||||
"User",
|
||||
secondary=ticket_issue_tagged_users,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<TicketIssue(id={self.id}, ticket={self.ticket_id}, priority={self.priority})>"
|
||||
67
backend/app/models/permission.py
Normal file
67
backend/app/models/permission.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
TenantPermission Model - ServiceManagerWeb
|
||||
|
||||
Permisos dinámicos por tenant.
|
||||
- Si user_id es None → aplica al rol completo (default del tenant)
|
||||
- Si user_id tiene valor → override individual para ese usuario
|
||||
"""
|
||||
from sqlalchemy import String, Boolean, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from typing import Optional
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
|
||||
CLIENT_PERMISSIONS = [
|
||||
"view_tickets",
|
||||
"create_tickets",
|
||||
"close_tickets",
|
||||
"view_reports",
|
||||
"manage_tenant_users",
|
||||
"create_issues",
|
||||
]
|
||||
|
||||
DEFAULT_PERMISSIONS = {
|
||||
"CLIENT_ADMIN": {p: True for p in CLIENT_PERMISSIONS},
|
||||
"CLIENT_USER": {
|
||||
"view_tickets": True,
|
||||
"create_tickets": True,
|
||||
"close_tickets": False,
|
||||
"view_reports": False,
|
||||
"manage_tenant_users": False,
|
||||
"create_issues": False,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TenantPermission(Base):
|
||||
__tablename__ = "tenant_permissions"
|
||||
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
GUID(),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
permission: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
granted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant")
|
||||
user: Mapped[Optional["User"]] = relationship("User")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "role", "user_id", "permission",
|
||||
name="uq_tenant_permission"
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
scope = f"user:{self.user_id}" if self.user_id else f"role:{self.role}"
|
||||
return f"<TenantPermission({scope} {self.permission}={'✓' if self.granted else '✗'})>"
|
||||
63
backend/app/models/roles.py
Normal file
63
backend/app/models/roles.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Definición y helpers de roles para el sistema multi-tenant.
|
||||
|
||||
Fuente única: UserRole en app.models.user.
|
||||
Este módulo expone conjuntos de roles y helpers de verificación
|
||||
para usarse en deps.py y en los endpoints.
|
||||
|
||||
Roles globales (staff interno — alcance multi-tenant):
|
||||
ADMIN → control total sobre todos los tenants
|
||||
SUPPORT_MANAGER → gestiona equipos y SLAs de todos los tenants
|
||||
AGENT → atiende tickets de cualquier tenant
|
||||
AUDITOR → auditoría de solo lectura en todos los tenants
|
||||
|
||||
Roles de cliente (alcance limitado al propio tenant):
|
||||
CLIENT_ADMIN → administra organización: usuarios, configuración, tickets
|
||||
CLIENT_USER → crea y sigue sus propios tickets
|
||||
"""
|
||||
|
||||
from app.models.user import UserRole
|
||||
|
||||
# ── Conjuntos de roles ──────────────────────────────────────────────────────
|
||||
|
||||
GLOBAL_ROLES: frozenset[UserRole] = frozenset({
|
||||
UserRole.ADMIN,
|
||||
UserRole.SUPPORT_MANAGER,
|
||||
UserRole.AGENT,
|
||||
UserRole.AUDITOR,
|
||||
})
|
||||
|
||||
CLIENT_ROLES: frozenset[UserRole] = frozenset({
|
||||
UserRole.CLIENT_ADMIN,
|
||||
UserRole.CLIENT_USER,
|
||||
})
|
||||
|
||||
# ── Permisos por rol ────────────────────────────────────────────────────────
|
||||
|
||||
ROLE_PERMISSIONS: dict[UserRole, list[str]] = {
|
||||
# Staff global
|
||||
UserRole.ADMIN: ["manage_all", "view_all", "audit_all"],
|
||||
UserRole.SUPPORT_MANAGER: ["manage_teams", "view_all_tickets", "manage_sla"],
|
||||
UserRole.AGENT: ["view_all_tickets", "update_any_ticket"],
|
||||
UserRole.AUDITOR: ["view_all", "audit_all"],
|
||||
# Clientes (acotados al tenant)
|
||||
UserRole.CLIENT_ADMIN: ["manage_tenant", "manage_tenant_users", "view_tenant_tickets"],
|
||||
UserRole.CLIENT_USER: ["create_ticket", "view_own_tickets"],
|
||||
}
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def is_global_staff(role: UserRole) -> bool:
|
||||
"""Retorna True si el rol tiene alcance global (staff interno)."""
|
||||
return role.is_global
|
||||
|
||||
|
||||
def is_client_role(role: UserRole) -> bool:
|
||||
"""Retorna True si el rol está acotado al tenant del usuario."""
|
||||
return role.is_client
|
||||
|
||||
|
||||
def has_permission(role: UserRole, permission: str) -> bool:
|
||||
"""Verifica si un rol tiene un permiso específico."""
|
||||
return permission in ROLE_PERMISSIONS.get(role, [])
|
||||
|
||||
@@ -157,5 +157,43 @@ class Ticket(Base):
|
||||
CheckConstraint('rating >= 1 AND rating <= 5', name='check_rating_range'),
|
||||
)
|
||||
|
||||
participants: Mapped[list["TicketParticipant"]] = relationship(
|
||||
"TicketParticipant",
|
||||
back_populates="ticket",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
issues: Mapped[list["TicketIssue"]] = relationship(
|
||||
"TicketIssue",
|
||||
back_populates="ticket",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Ticket(id={self.id}, number='{self.ticket_number}', status={self.status})>"
|
||||
return f"<Ticket(id={self.id}, number='{self.ticket_number}', status={self.status})>"
|
||||
|
||||
class TicketParticipant(Base):
|
||||
"""Participantes asignados a un ticket"""
|
||||
__tablename__ = "ticket_participants"
|
||||
|
||||
ticket_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(),
|
||||
ForeignKey("tickets.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(50), nullable=False, default="participant")
|
||||
|
||||
ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="participants")
|
||||
user: Mapped["User"] = relationship("User")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("ticket_id", "user_id", name="uq_ticket_participant"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TicketParticipant(ticket={self.ticket_id}, user={self.user_id})>"
|
||||
|
||||
@@ -27,6 +27,24 @@ class UserRole(str, enum.Enum):
|
||||
CLIENT_ADMIN = "CLIENT_ADMIN" # Admin de organización cliente
|
||||
CLIENT_USER = "CLIENT_USER" # Usuario final cliente
|
||||
|
||||
@property
|
||||
def is_global(self) -> bool:
|
||||
"""True si el rol tiene alcance global (staff interno cross-tenant)."""
|
||||
return self in (
|
||||
UserRole.ADMIN,
|
||||
UserRole.SUPPORT_MANAGER,
|
||||
UserRole.AGENT,
|
||||
UserRole.AUDITOR,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_client(self) -> bool:
|
||||
"""True si el rol está acotado al tenant del usuario."""
|
||||
return self in (
|
||||
UserRole.CLIENT_ADMIN,
|
||||
UserRole.CLIENT_USER,
|
||||
)
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Modelo de Usuario."""
|
||||
@@ -113,11 +131,8 @@ class User(Base):
|
||||
|
||||
@property
|
||||
def is_client(self) -> bool:
|
||||
"""Check if user is a client."""
|
||||
return self.role in [
|
||||
UserRole.CLIENT_ADMIN,
|
||||
UserRole.CLIENT_USER
|
||||
]
|
||||
"""Check if user is a client (rol acotado al propio tenant)."""
|
||||
return self.role.is_client
|
||||
|
||||
@property
|
||||
def can_manage_users(self) -> bool:
|
||||
@@ -125,7 +140,7 @@ class User(Base):
|
||||
return self.role in [
|
||||
UserRole.ADMIN,
|
||||
UserRole.SUPPORT_MANAGER,
|
||||
UserRole.CLIENT_ADMIN
|
||||
UserRole.CLIENT_ADMIN,
|
||||
]
|
||||
|
||||
@property
|
||||
@@ -134,7 +149,7 @@ class User(Base):
|
||||
return self.role in [
|
||||
UserRole.ADMIN,
|
||||
UserRole.SUPPORT_MANAGER,
|
||||
UserRole.AGENT
|
||||
UserRole.AGENT,
|
||||
]
|
||||
|
||||
@property
|
||||
|
||||
170
backend/app/services/ticket_service.py
Normal file
170
backend/app/services/ticket_service.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Ticket Service - ServiceManagerWeb
|
||||
|
||||
Lógica de negocio para creación y gestión de tickets.
|
||||
Inyectable vía Depends() en los endpoints de FastAPI.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||
from app.models.category import Category
|
||||
from app.models.system import System
|
||||
from app.api.schemas.ticket import TicketCreate
|
||||
from app.api.v1.helpers import (
|
||||
generate_next_ticket_number,
|
||||
calculate_sla_deadlines,
|
||||
safe_audit_log,
|
||||
)
|
||||
|
||||
|
||||
class TicketService:
|
||||
"""Servicio de tickets: encapsula lógica de negocio fuera del router."""
|
||||
|
||||
def __init__(self, db: AsyncSession = Depends(get_db)):
|
||||
self.db = db
|
||||
|
||||
async def create_ticket(
|
||||
self,
|
||||
ticket: TicketCreate,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> dict:
|
||||
"""
|
||||
Crea un ticket con validación multi-tenant, cálculo de SLA y auto-asignación.
|
||||
|
||||
Args:
|
||||
ticket: Datos del ticket a crear.
|
||||
tenant_id: Tenant del usuario autenticado.
|
||||
user_id: ID del usuario que crea el ticket.
|
||||
|
||||
Returns:
|
||||
dict compatible con TicketResponse.
|
||||
|
||||
Raises:
|
||||
HTTPException 400: UUID inválido, categoría/sistema no encontrado o de otro tenant.
|
||||
HTTPException 500: Fallo persistente tras max_retries.
|
||||
"""
|
||||
max_retries = 3
|
||||
last_error = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
ticket_number = await generate_next_ticket_number(self.db, tenant_id)
|
||||
category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None
|
||||
system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None
|
||||
|
||||
category = None
|
||||
if category_uuid:
|
||||
category = await self.db.get(Category, category_uuid)
|
||||
if not category or category.tenant_id != tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"La categoría con ID {ticket.category_id} no existe.",
|
||||
)
|
||||
|
||||
if system_uuid:
|
||||
system = await self.db.get(System, system_uuid)
|
||||
if not system or system.tenant_id != tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"El sistema con ID {ticket.affected_system_id} no existe.",
|
||||
)
|
||||
|
||||
sla_response_due, sla_resolution_due = calculate_sla_deadlines(category)
|
||||
assigned_to_user = category.auto_assign_to if category and category.auto_assign_to else None
|
||||
|
||||
db_ticket = Ticket(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant_id,
|
||||
ticket_number=ticket_number,
|
||||
subject=ticket.subject,
|
||||
description=ticket.description,
|
||||
category_id=category_uuid,
|
||||
affected_system_id=system_uuid,
|
||||
priority=TicketPriority[ticket.priority.upper()],
|
||||
created_by=user_id,
|
||||
assigned_to=assigned_to_user,
|
||||
status=TicketStatus.NEW,
|
||||
sla_response_due=sla_response_due,
|
||||
sla_resolution_due=sla_resolution_due,
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
self.db.add(db_ticket)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(db_ticket)
|
||||
|
||||
await safe_audit_log(
|
||||
db=self.db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
action="ticket.create",
|
||||
resource_type="ticket",
|
||||
resource_id=db_ticket.id,
|
||||
new_values={
|
||||
"ticket_number": db_ticket.ticket_number,
|
||||
"subject": db_ticket.subject,
|
||||
"priority": db_ticket.priority.value,
|
||||
"status": db_ticket.status.value,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"id": str(db_ticket.id),
|
||||
"ticket_number": db_ticket.ticket_number,
|
||||
"subject": db_ticket.subject,
|
||||
"title": db_ticket.subject,
|
||||
"description": db_ticket.description,
|
||||
"status": db_ticket.status.value,
|
||||
"priority": db_ticket.priority.value,
|
||||
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
|
||||
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
|
||||
"system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
|
||||
"contact_email": ticket.contact_email,
|
||||
"contact_phone": ticket.contact_phone,
|
||||
"created_by": str(db_ticket.created_by),
|
||||
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
|
||||
"created_at": db_ticket.created_at,
|
||||
"updated_at": db_ticket.updated_at,
|
||||
"sla_response_due": db_ticket.sla_response_due,
|
||||
"sla_resolution_due": db_ticket.sla_resolution_due,
|
||||
"first_response_at": db_ticket.first_response_at,
|
||||
"resolved_at": db_ticket.resolved_at,
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
await self.db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid UUID format: {str(e)}",
|
||||
)
|
||||
except HTTPException:
|
||||
await self.db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
await self.db.rollback()
|
||||
last_error = e
|
||||
if "duplicate key" in str(e).lower() and "ticket_number" in str(e).lower():
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Error creating ticket: {str(e)}",
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"No se pudo crear el ticket después de {max_retries} intentos: {str(last_error)}",
|
||||
)
|
||||
|
||||
|
||||
def get_ticket_service(db: AsyncSession = Depends(get_db)) -> TicketService:
|
||||
"""Factory function para inyectar TicketService vía Depends()."""
|
||||
return TicketService(db)
|
||||
373
backend/app/tests/conftest.py
Normal file
373
backend/app/tests/conftest.py
Normal file
@@ -0,0 +1,373 @@
|
||||
"""
|
||||
Integration Test Fixtures - ServiceManagerWeb (Docker / PostgreSQL)
|
||||
|
||||
backend/app/tests/conftest.py
|
||||
|
||||
Usa la BD Docker existente (servicemanager).
|
||||
Los fixtures leen datos reales ya seedeados — no crean ni eliminan nada.
|
||||
Los tests que inserten datos propios quedan aislados por rollback.
|
||||
|
||||
Tenant de referencia : aduanasoft
|
||||
Usuarios de referencia:
|
||||
admin@aduanasoft.com → ADMIN
|
||||
manager@aduanasoft.com → SUPPORT_MANAGER
|
||||
agente@aduanasoft.com → AGENT
|
||||
auditor1@test.com → AUDITOR (tenant aduanasoft)
|
||||
admin-cliente@empresa-demo → CLIENT_ADMIN
|
||||
test_user@aduanasoft.com → CLIENT_USER
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import pytest
|
||||
from typing import AsyncGenerator, Generator
|
||||
|
||||
# ============================================================
|
||||
# ENV VARS — antes de importar la app
|
||||
# ============================================================
|
||||
os.environ.setdefault("ENVIRONMENT", "testing")
|
||||
os.environ.setdefault("TESTING", "true")
|
||||
os.environ.setdefault("DEBUG", "false")
|
||||
os.environ.setdefault("SECRET_KEY", "integration-secret-key-32chars!!!!")
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "integration-jwt-secret-32chars!!!!")
|
||||
os.environ.setdefault(
|
||||
"DATABASE_URL",
|
||||
"postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager",
|
||||
)
|
||||
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/14")
|
||||
os.environ.setdefault("CELERY_BROKER_URL", "redis://localhost:6379/14")
|
||||
os.environ.setdefault("CELERY_RESULT_BACKEND", "redis://localhost:6379/14")
|
||||
os.environ.setdefault("CORS_ORIGINS", "http://localhost:3000")
|
||||
os.environ.setdefault("ALLOWED_FILE_EXTENSIONS", "pdf,jpg,jpeg,png,doc,docx,txt")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# EVENT LOOP (session-scoped)
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop() -> Generator:
|
||||
"""Event loop compartido para toda la sesión de tests."""
|
||||
policy = asyncio.get_event_loop_policy()
|
||||
loop = policy.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ENGINE (session-scoped — reutiliza el pool toda la sesión)
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def engine():
|
||||
"""
|
||||
Conecta al PostgreSQL Docker existente (servicemanager).
|
||||
NO crea ni destruye el schema — la BD ya está lista.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
import app.models # noqa: F401 — registra todos los modelos
|
||||
|
||||
_engine = create_async_engine(os.environ["DATABASE_URL"], echo=False)
|
||||
yield _engine
|
||||
await _engine.dispose()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# DB (function-scoped — rollback para datos creados en el test)
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture
|
||||
async def db(engine) -> AsyncGenerator:
|
||||
"""
|
||||
Sesión con transacción por test.
|
||||
Los datos seedeados son visibles (ya están committed).
|
||||
Cualquier INSERT hecho en el test se revierte al finalizar.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with factory() as session:
|
||||
await session.begin()
|
||||
yield session
|
||||
await session.rollback()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# TENANT (function-scoped — lee el registro existente)
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture
|
||||
async def tenant_a(db):
|
||||
"""Tenant 'aduanasoft' ya existente en la BD."""
|
||||
from sqlalchemy import select
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
result = await db.execute(select(Tenant).where(Tenant.slug == "aduanasoft"))
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# USUARIOS (function-scoped — leen registros existentes)
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_user(db, tenant_a):
|
||||
"""ADMIN: admin@aduanasoft.com (tenant aduanasoft)."""
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.email == "admin@aduanasoft.com")
|
||||
.where(User.tenant_id == tenant_a.id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def manager_user(db, tenant_a):
|
||||
"""SUPPORT_MANAGER: manager@aduanasoft.com (tenant aduanasoft)."""
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.email == "manager@aduanasoft.com")
|
||||
.where(User.tenant_id == tenant_a.id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def agent_user(db, tenant_a):
|
||||
"""AGENT: agente@aduanasoft.com (tenant aduanasoft)."""
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.email == "agente@aduanasoft.com")
|
||||
.where(User.tenant_id == tenant_a.id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def user_tenant_a(db, tenant_a):
|
||||
"""CLIENT_USER: test_user@aduanasoft.com (tenant aduanasoft)."""
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.email == "test_user@aduanasoft.com")
|
||||
.where(User.tenant_id == tenant_a.id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# HTTP CLIENT (function-scoped)
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture
|
||||
async def client(db) -> AsyncGenerator:
|
||||
"""
|
||||
httpx.AsyncClient contra la app FastAPI en memoria (sin red).
|
||||
get_db queda sobreescrito para inyectar la sesión de test.
|
||||
Los cambios del test se revierten al terminar (rollback en db).
|
||||
"""
|
||||
import httpx
|
||||
from httpx import ASGITransport
|
||||
from app.main import app
|
||||
from app.core.database import get_db
|
||||
|
||||
async def _override_get_db():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_get_db
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# FIXTURES DE AISLAMIENTO MULTI-TENANT
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture
|
||||
def make_token():
|
||||
"""Factory de JWT tokens para autenticar clientes HTTP en tests."""
|
||||
from app.core.security import security
|
||||
|
||||
def _make(user):
|
||||
return security.create_access_token(data={"sub": str(user.id)})
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def app_with_db(db):
|
||||
"""
|
||||
Override de get_db compartido para todos los HTTP clients de un mismo test.
|
||||
Garantiza que todos los clients usen la misma sesión (y el mismo rollback).
|
||||
"""
|
||||
from app.main import app as _app
|
||||
from app.core.database import get_db
|
||||
|
||||
async def _override():
|
||||
yield db
|
||||
|
||||
_app.dependency_overrides[get_db] = _override
|
||||
yield _app
|
||||
_app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def tenant_b(db):
|
||||
"""Tenant 'empresa-test' creado en la transacción del test (se revierte al final)."""
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
t = Tenant(name="Empresa Test", slug="empresa-test")
|
||||
db.add(t)
|
||||
await db.flush()
|
||||
return t
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def user_b(db, tenant_b):
|
||||
"""CLIENT_ADMIN en tenant_b — puede gestionar recursos de su tenant."""
|
||||
from app.models.user import User, UserRole
|
||||
from app.core.security import security
|
||||
|
||||
u = User(
|
||||
tenant_id=tenant_b.id,
|
||||
email="admin@empresa-test.com",
|
||||
first_name="Admin",
|
||||
last_name="Test",
|
||||
password_hash=security.hash_password("Test1234!"),
|
||||
role=UserRole.CLIENT_ADMIN,
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
)
|
||||
db.add(u)
|
||||
await db.flush()
|
||||
return u
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client_tenant_a(app_with_db, manager_user, make_token):
|
||||
"""HTTP client autenticado como SUPPORT_MANAGER de tenant_a (aduanasoft).
|
||||
Usa manager_user en lugar de admin_user para mantener el aislamiento de
|
||||
tenant en GET /users/ (el ADMIN global bypasa el filtro de tenant).
|
||||
"""
|
||||
import httpx
|
||||
from httpx import ASGITransport
|
||||
|
||||
token = make_token(manager_user)
|
||||
async with httpx.AsyncClient(
|
||||
transport=ASGITransport(app=app_with_db),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client_tenant_b(app_with_db, user_b, make_token):
|
||||
"""HTTP client autenticado como CLIENT_ADMIN de tenant_b (empresa-test)."""
|
||||
import httpx
|
||||
from httpx import ASGITransport
|
||||
|
||||
token = make_token(user_b)
|
||||
async with httpx.AsyncClient(
|
||||
transport=ASGITransport(app=app_with_db),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client_admin(app_with_db, admin_user, make_token):
|
||||
"""HTTP client autenticado como ADMIN global."""
|
||||
import httpx
|
||||
from httpx import ASGITransport
|
||||
|
||||
token = make_token(admin_user)
|
||||
async with httpx.AsyncClient(
|
||||
transport=ASGITransport(app=app_with_db),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_ticket_tenant_a(client_tenant_a):
|
||||
"""Factory: crea un ticket en tenant_a vía HTTP y retorna el JSON de respuesta."""
|
||||
async def _create(subject="Ticket Tenant A", priority="MEDIUM"):
|
||||
resp = await client_tenant_a.post("/v1/tickets/", json={
|
||||
"subject": subject,
|
||||
"description": "Test de aislamiento tenant A",
|
||||
"priority": priority,
|
||||
})
|
||||
assert resp.status_code in (200, 201), f"Error creando ticket A: {resp.text}"
|
||||
return resp.json()
|
||||
|
||||
return _create
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_ticket_tenant_b(client_tenant_b):
|
||||
"""Factory: crea un ticket en tenant_b vía HTTP y retorna el JSON de respuesta."""
|
||||
async def _create(subject="Ticket Tenant B", priority="MEDIUM"):
|
||||
resp = await client_tenant_b.post("/v1/tickets/", json={
|
||||
"subject": subject,
|
||||
"description": "Test de aislamiento tenant B",
|
||||
"priority": priority,
|
||||
})
|
||||
assert resp.status_code in (200, 201), f"Error creando ticket B: {resp.text}"
|
||||
return resp.json()
|
||||
|
||||
return _create
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_user_tenant_a(client_tenant_a):
|
||||
"""Factory: crea un usuario en tenant_a vía HTTP y retorna el JSON de respuesta."""
|
||||
async def _create(email="nuevo_user_a@test.com"):
|
||||
resp = await client_tenant_a.post("/v1/users/", json={
|
||||
"email": email,
|
||||
"first_name": "Usuario",
|
||||
"last_name": "TenantA",
|
||||
"password": "Test1234!",
|
||||
"role": "CLIENT_USER",
|
||||
})
|
||||
assert resp.status_code in (200, 201), f"Error creando user A: {resp.text}"
|
||||
return resp.json()
|
||||
|
||||
return _create
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_user_tenant_b(client_tenant_b):
|
||||
"""Factory: crea un usuario en tenant_b vía HTTP y retorna el JSON de respuesta."""
|
||||
async def _create(email="nuevo_user_b@test.com"):
|
||||
resp = await client_tenant_b.post("/v1/users/", json={
|
||||
"email": email,
|
||||
"first_name": "Usuario",
|
||||
"last_name": "TenantB",
|
||||
"password": "Test1234!",
|
||||
"role": "CLIENT_USER",
|
||||
})
|
||||
assert resp.status_code in (200, 201), f"Error creando user B: {resp.text}"
|
||||
return resp.json()
|
||||
|
||||
return _create
|
||||
137
backend/app/tests/test_smoke.py
Normal file
137
backend/app/tests/test_smoke.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Smoke Tests - ServiceManagerWeb
|
||||
|
||||
Verifican que el stack completo funciona:
|
||||
- Conexión a BD Docker
|
||||
- Fixtures de tenant y usuarios
|
||||
- Login vía HTTP (httpx + FastAPI en memoria)
|
||||
- Endpoint protegido con token
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ============================================================
|
||||
# BD + FIXTURES
|
||||
# ============================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_connected(db):
|
||||
"""La sesión de BD está activa y responde."""
|
||||
from sqlalchemy import text
|
||||
result = await db.execute(text("SELECT 1"))
|
||||
assert result.scalar() == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_a_existe(tenant_a):
|
||||
"""El tenant 'aduanasoft' existe y tiene datos válidos."""
|
||||
assert tenant_a.slug == "aduanasoft"
|
||||
assert tenant_a.name is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_user_existe(admin_user):
|
||||
"""El usuario ADMIN existe y pertenece al tenant correcto."""
|
||||
from app.models.user import UserRole
|
||||
assert admin_user.email == "admin@aduanasoft.com"
|
||||
assert admin_user.role == UserRole.ADMIN
|
||||
assert admin_user.is_active is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_user_existe(manager_user):
|
||||
"""El usuario SUPPORT_MANAGER existe."""
|
||||
from app.models.user import UserRole
|
||||
assert manager_user.email == "manager@aduanasoft.com"
|
||||
assert manager_user.role == UserRole.SUPPORT_MANAGER
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_user_existe(agent_user):
|
||||
"""El usuario AGENT existe."""
|
||||
from app.models.user import UserRole
|
||||
assert agent_user.email == "agente@aduanasoft.com"
|
||||
assert agent_user.role == UserRole.AGENT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_user_existe(user_tenant_a):
|
||||
"""El CLIENT_USER existe."""
|
||||
from app.models.user import UserRole
|
||||
assert user_tenant_a.email == "test_user@aduanasoft.com"
|
||||
assert user_tenant_a.role == UserRole.CLIENT_USER
|
||||
|
||||
|
||||
# ============================================================
|
||||
# HTTP — LOGIN
|
||||
# ============================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_admin_ok(client):
|
||||
"""Login con credenciales de admin devuelve access_token."""
|
||||
response = await client.post(
|
||||
"/v1/auth/login",
|
||||
json={
|
||||
"email": "admin@aduanasoft.com",
|
||||
"password": "admin123",
|
||||
"tenant_slug": "aduanasoft",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_credenciales_invalidas(client):
|
||||
"""Login con contraseña incorrecta devuelve 401."""
|
||||
response = await client.post(
|
||||
"/v1/auth/login",
|
||||
json={
|
||||
"email": "admin@aduanasoft.com",
|
||||
"password": "wrongpassword",
|
||||
"tenant_slug": "aduanasoft",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_endpoint_sin_token_devuelve_401(client):
|
||||
"""Acceder a un endpoint protegido sin token devuelve 401."""
|
||||
response = await client.get(
|
||||
"/v1/users/me",
|
||||
headers={"X-Tenant-Slug": "aduanasoft"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_y_me(client):
|
||||
"""Login exitoso → /users/me devuelve el usuario correcto."""
|
||||
# Login
|
||||
login = await client.post(
|
||||
"/v1/auth/login",
|
||||
json={
|
||||
"email": "admin@aduanasoft.com",
|
||||
"password": "admin123",
|
||||
"tenant_slug": "aduanasoft",
|
||||
},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
token = login.json()["access_token"]
|
||||
|
||||
# Endpoint protegido
|
||||
me = await client.get(
|
||||
"/v1/users/me",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-Tenant-Slug": "aduanasoft",
|
||||
},
|
||||
)
|
||||
assert me.status_code == 200
|
||||
data = me.json()
|
||||
assert data["email"] == "admin@aduanasoft.com"
|
||||
assert data["role"] == "ADMIN"
|
||||
123
backend/app/tests/test_tenant_isolation.py
Normal file
123
backend/app/tests/test_tenant_isolation.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Pruebas de aislamiento multi-tenant para tickets y usuarios.
|
||||
Usa solo los fixtures definidos en conftest.py.
|
||||
|
||||
Roles en juego:
|
||||
client_tenant_a → SUPPORT_MANAGER (aduanasoft) — restringido a su tenant
|
||||
client_tenant_b → CLIENT_ADMIN (empresa-test) — restringido a su tenant
|
||||
client_admin → ADMIN global (aduanasoft) — acceso a todos los tenants
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_a_cannot_see_tenant_b_tickets(
|
||||
client_tenant_a, create_ticket_tenant_b
|
||||
):
|
||||
"""
|
||||
El usuario del tenant B crea un ticket.
|
||||
El usuario del tenant A (SUPPORT_MANAGER) lista sus tickets.
|
||||
El ticket de tenant B NO debe aparecer en la respuesta.
|
||||
"""
|
||||
# El usuario del tenant B crea un ticket
|
||||
ticket_b = await create_ticket_tenant_b()
|
||||
ticket_b_id = ticket_b["id"]
|
||||
|
||||
# El usuario del tenant A lista sus tickets
|
||||
response = await client_tenant_a.get("/v1/tickets/")
|
||||
assert response.status_code == 200
|
||||
|
||||
ids_visibles = {t["id"] for t in response.json()}
|
||||
|
||||
# El ticket de tenant B no debe ser visible para tenant A
|
||||
assert ticket_b_id not in ids_visibles, (
|
||||
f"Fallo de aislamiento: ticket de tenant B ({ticket_b_id}) "
|
||||
f"visible para usuario de tenant A"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_b_cannot_edit_tenant_a_ticket(
|
||||
create_ticket_tenant_a, client_tenant_b
|
||||
):
|
||||
"""
|
||||
El usuario del tenant A crea un ticket.
|
||||
El usuario del tenant B intenta editar ese ticket vía PATCH.
|
||||
Debe recibir 403 (prohibido) o 404 (no encontrado).
|
||||
"""
|
||||
# El usuario del tenant A crea un ticket
|
||||
ticket_a = await create_ticket_tenant_a()
|
||||
ticket_a_id = ticket_a["id"]
|
||||
|
||||
# El usuario del tenant B intenta editar el ticket de tenant A
|
||||
response = await client_tenant_b.patch(
|
||||
f"/v1/tickets/{ticket_a_id}",
|
||||
json={"status": "CLOSED"},
|
||||
)
|
||||
|
||||
# Debe recibir 403 o 404 — nunca 200
|
||||
assert response.status_code in (403, 404), (
|
||||
f"Fallo de aislamiento: tenant B pudo editar ticket de tenant A "
|
||||
f"(HTTP {response.status_code})"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_a_cannot_see_tenant_b_users(
|
||||
client_tenant_a, create_user_tenant_b
|
||||
):
|
||||
"""
|
||||
El usuario del tenant B crea un usuario nuevo.
|
||||
El usuario del tenant A (SUPPORT_MANAGER) lista los usuarios.
|
||||
El usuario de tenant B NO debe aparecer en la respuesta.
|
||||
"""
|
||||
# El usuario del tenant B crea un usuario
|
||||
user_b = await create_user_tenant_b()
|
||||
user_b_id = user_b["id"]
|
||||
|
||||
# El usuario del tenant A lista los usuarios de su tenant
|
||||
response = await client_tenant_a.get("/v1/users/")
|
||||
assert response.status_code == 200
|
||||
|
||||
ids_visibles = {u["id"] for u in response.json()}
|
||||
|
||||
# El usuario de tenant B no debe ser visible para tenant A
|
||||
assert user_b_id not in ids_visibles, (
|
||||
f"Fallo de aislamiento: usuario de tenant B ({user_b_id}) "
|
||||
f"visible para usuario de tenant A"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_sees_all_tenant_data(
|
||||
client_admin,
|
||||
create_ticket_tenant_a,
|
||||
create_ticket_tenant_b,
|
||||
create_user_tenant_a,
|
||||
create_user_tenant_b,
|
||||
):
|
||||
"""
|
||||
El ADMIN global debe poder ver tickets y usuarios de TODOS los tenants.
|
||||
- Tickets: vía /v1/tickets/admin/all (endpoint multi-tenant).
|
||||
- Usuarios: vía /v1/users/ (ADMIN bypasa el filtro de tenant).
|
||||
"""
|
||||
# Crear datos en ambos tenants
|
||||
ticket_a = await create_ticket_tenant_a()
|
||||
ticket_b = await create_ticket_tenant_b()
|
||||
user_a = await create_user_tenant_a()
|
||||
user_b = await create_user_tenant_b()
|
||||
|
||||
# El admin lista todos los tickets (endpoint multi-tenant)
|
||||
resp_tickets = await client_admin.get("/v1/tickets/admin/all")
|
||||
assert resp_tickets.status_code == 200
|
||||
ids_tickets = {t["id"] for t in resp_tickets.json()}
|
||||
assert ticket_a["id"] in ids_tickets, "El ADMIN no ve el ticket de tenant A"
|
||||
assert ticket_b["id"] in ids_tickets, "El ADMIN no ve el ticket de tenant B"
|
||||
|
||||
# El admin lista todos los usuarios (ADMIN bypasa filtro de tenant)
|
||||
resp_users = await client_admin.get("/v1/users/")
|
||||
assert resp_users.status_code == 200
|
||||
ids_users = {u["id"] for u in resp_users.json()}
|
||||
assert user_a["id"] in ids_users, "El ADMIN no ve el usuario de tenant A"
|
||||
assert user_b["id"] in ids_users, "El ADMIN no ve el usuario de tenant B"
|
||||
|
||||
3
backend/check_schema.py
Normal file
3
backend/check_schema.py
Normal file
@@ -0,0 +1,3 @@
|
||||
with open("/app/app/api/schemas/category.py", "r") as f:
|
||||
content = f.read()
|
||||
print(content)
|
||||
19
backend/debug_tenant.py
Normal file
19
backend/debug_tenant.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import asyncio
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.user import User
|
||||
from app.models.tenant import Tenant
|
||||
from sqlalchemy import select
|
||||
|
||||
async def check():
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(
|
||||
select(User, Tenant).join(Tenant).where(User.email == 'javier@ventas.com')
|
||||
)
|
||||
user, tenant = result.one()
|
||||
print(f"user.tenant_id: {user.tenant_id}")
|
||||
print(f"tenant.id: {tenant.id}")
|
||||
print(f"tenant.slug: {tenant.slug}")
|
||||
print(f"user.role: {user.role}")
|
||||
print(f"role.is_client: {user.role.is_client}")
|
||||
|
||||
asyncio.run(check())
|
||||
28
backend/fix_categories.py
Normal file
28
backend/fix_categories.py
Normal file
@@ -0,0 +1,28 @@
|
||||
with open("/app/app/api/v1/endpoints/categories.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
old = ''' # ✅ CORREGIDO: Asignar tenant_id del usuario actual
|
||||
db_category = Category(
|
||||
**category.model_dump(),
|
||||
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
|
||||
)'''
|
||||
|
||||
new = ''' # ADMIN global puede especificar tenant_id; otros usan el suyo
|
||||
from app.models.user import UserRole as _R
|
||||
data = category.model_dump()
|
||||
if current_user.role == _R.ADMIN and data.get("tenant_id"):
|
||||
target_tenant_id = data["tenant_id"]
|
||||
else:
|
||||
target_tenant_id = current_user.tenant_id
|
||||
data["tenant_id"] = target_tenant_id
|
||||
|
||||
db_category = Category(**data)'''
|
||||
|
||||
if old in content:
|
||||
content = content.replace(old, new)
|
||||
print("OK: fix aplicado")
|
||||
else:
|
||||
print("ERROR: bloque no encontrado")
|
||||
|
||||
with open("/app/app/api/v1/endpoints/categories.py", "w") as f:
|
||||
f.write(content)
|
||||
15
backend/fix_delete.py
Normal file
15
backend/fix_delete.py
Normal file
@@ -0,0 +1,15 @@
|
||||
with open("/app/app/api/v1/endpoints/users.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
old = ' # Verificar permisos - solo ADMIN puede eliminar\n if current_user.role != UserRole.ADMIN:\n raise HTTPException(\n status_code=status.HTTP_403_FORBIDDEN,\n detail="Only admins can delete users"\n )'
|
||||
|
||||
new = ' # Verificar permisos - ADMIN global o CLIENT_ADMIN pueden eliminar\n allowed = [UserRole.ADMIN, UserRole.CLIENT_ADMIN]\n if current_user.role not in allowed:\n raise HTTPException(\n status_code=status.HTTP_403_FORBIDDEN,\n detail="Only admins can delete users"\n )'
|
||||
|
||||
if old in content:
|
||||
content = content.replace(old, new)
|
||||
print("OK: permisos delete actualizados")
|
||||
else:
|
||||
print("ERROR: bloque no encontrado")
|
||||
|
||||
with open("/app/app/api/v1/endpoints/users.py", "w") as f:
|
||||
f.write(content)
|
||||
14
backend/fix_delete2.py
Normal file
14
backend/fix_delete2.py
Normal file
@@ -0,0 +1,14 @@
|
||||
with open("/app/app/api/v1/endpoints/users.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
old = " # Soft delete\n db_user.is_active = False\n await db.commit()"
|
||||
new = " # Hard delete\n await db.delete(db_user)\n await db.commit()"
|
||||
|
||||
if old in content:
|
||||
content = content.replace(old, new)
|
||||
print("OK: hard delete aplicado")
|
||||
else:
|
||||
print("ERROR: bloque no encontrado")
|
||||
|
||||
with open("/app/app/api/v1/endpoints/users.py", "w") as f:
|
||||
f.write(content)
|
||||
34
backend/fix_router.py
Normal file
34
backend/fix_router.py
Normal file
@@ -0,0 +1,34 @@
|
||||
with open("/app/app/api/v1/router.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
old = "from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports"
|
||||
new = "from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports, participants"
|
||||
|
||||
old2 = "# Tickets routes\napi_router.include_router(\n tickets.router,\n prefix=\"/tickets\",\n tags=[\"tickets\"]\n)"
|
||||
new2 = """# Tickets routes
|
||||
api_router.include_router(
|
||||
tickets.router,
|
||||
prefix="/tickets",
|
||||
tags=["tickets"]
|
||||
)
|
||||
# Participants routes (nested under tickets)
|
||||
api_router.include_router(
|
||||
participants.router,
|
||||
prefix="/tickets",
|
||||
tags=["participants"]
|
||||
)"""
|
||||
|
||||
if old in content:
|
||||
content = content.replace(old, new)
|
||||
print("OK: import agregado")
|
||||
else:
|
||||
print("ERROR: import no encontrado")
|
||||
|
||||
if old2 in content:
|
||||
content = content.replace(old2, new2)
|
||||
print("OK: router agregado")
|
||||
else:
|
||||
print("ERROR: router no encontrado")
|
||||
|
||||
with open("/app/app/api/v1/router.py", "w") as f:
|
||||
f.write(content)
|
||||
30
backend/fix_schema.py
Normal file
30
backend/fix_schema.py
Normal file
@@ -0,0 +1,30 @@
|
||||
with open("/app/app/api/schemas/category.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
old = '''class CategoryCreate(BaseModel):
|
||||
"""Schema para crear categoría. No incluye tenant_id (se asigna automáticamente)."""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
sla_response_hours: int = 24
|
||||
sla_resolution_hours: int = 72
|
||||
auto_assign_to: Optional[uuid.UUID] = None'''
|
||||
|
||||
new = '''class CategoryCreate(BaseModel):
|
||||
"""Schema para crear categoría. tenant_id opcional para ADMIN global."""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
sla_response_hours: int = 24
|
||||
sla_resolution_hours: int = 72
|
||||
auto_assign_to: Optional[uuid.UUID] = None
|
||||
tenant_id: Optional[uuid.UUID] = None'''
|
||||
|
||||
if old in content:
|
||||
content = content.replace(old, new)
|
||||
print("OK")
|
||||
else:
|
||||
print("ERROR: bloque no encontrado")
|
||||
|
||||
with open("/app/app/api/schemas/category.py", "w") as f:
|
||||
f.write(content)
|
||||
@@ -0,0 +1,282 @@
|
||||
"""add_tenant_permissions
|
||||
|
||||
Revision ID: 0aec0a6e294a
|
||||
Revises: b2dcb926e091
|
||||
Create Date: 2026-03-17 19:33:30.229993
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0aec0a6e294a'
|
||||
down_revision = 'b2dcb926e091'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index('idx_email_templates_tenant_id', table_name='email_templates')
|
||||
op.drop_table('email_templates')
|
||||
op.drop_index('idx_ticket_status_history_changed_by', table_name='ticket_status_history')
|
||||
op.drop_index('idx_ticket_status_history_created_at', table_name='ticket_status_history')
|
||||
op.drop_index('idx_ticket_status_history_ticket_id', table_name='ticket_status_history')
|
||||
op.drop_table('ticket_status_history')
|
||||
op.drop_index('idx_notification_logs_created_at', table_name='notification_logs')
|
||||
op.drop_index('idx_notification_logs_recipient', table_name='notification_logs')
|
||||
op.drop_index('idx_notification_logs_status', table_name='notification_logs')
|
||||
op.drop_index('idx_notification_logs_tenant_id', table_name='notification_logs')
|
||||
op.drop_index('idx_notification_logs_ticket_id', table_name='notification_logs')
|
||||
op.drop_table('notification_logs')
|
||||
op.add_column('audit_logs', sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False))
|
||||
op.alter_column('audit_logs', 'created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('now()'))
|
||||
op.drop_index('idx_audit_logs_action', table_name='audit_logs')
|
||||
op.drop_index('idx_audit_logs_correlation_id', table_name='audit_logs')
|
||||
op.drop_index('idx_audit_logs_created_at', table_name='audit_logs')
|
||||
op.drop_index('idx_audit_logs_tenant_id', table_name='audit_logs')
|
||||
op.drop_index('idx_audit_logs_user_id', table_name='audit_logs')
|
||||
op.create_index('idx_audit_logs_tenant_action', 'audit_logs', ['tenant_id', 'action'], unique=False)
|
||||
op.create_index('idx_audit_logs_user_created', 'audit_logs', ['user_id', 'created_at'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_action'), 'audit_logs', ['action'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_correlation_id'), 'audit_logs', ['correlation_id'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_created_at'), 'audit_logs', ['created_at'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_resource_type'), 'audit_logs', ['resource_type'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_tenant_id'), 'audit_logs', ['tenant_id'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_user_id'), 'audit_logs', ['user_id'], unique=False)
|
||||
op.drop_constraint('audit_logs_user_id_fkey', 'audit_logs', type_='foreignkey')
|
||||
op.drop_constraint('audit_logs_tenant_id_fkey', 'audit_logs', type_='foreignkey')
|
||||
op.create_foreign_key(None, 'audit_logs', 'users', ['user_id'], ['id'], ondelete='SET NULL')
|
||||
op.create_foreign_key(None, 'audit_logs', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE')
|
||||
op.drop_table_comment(
|
||||
'audit_logs',
|
||||
existing_comment='Bit??cora de acciones para auditor??a y compliance',
|
||||
schema=None
|
||||
)
|
||||
op.drop_index('idx_refresh_tokens_expires', table_name='refresh_tokens')
|
||||
op.drop_index('idx_refresh_tokens_user_id', table_name='refresh_tokens')
|
||||
op.create_index('idx_refresh_tokens_user_expires', 'refresh_tokens', ['user_id', 'expires_at'], unique=False)
|
||||
op.create_index(op.f('ix_refresh_tokens_expires_at'), 'refresh_tokens', ['expires_at'], unique=False)
|
||||
op.create_index(op.f('ix_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False)
|
||||
op.drop_index('idx_tenants_domain', table_name='tenants')
|
||||
op.drop_index('idx_tenants_slug', table_name='tenants')
|
||||
op.drop_index('idx_tenants_status', table_name='tenants')
|
||||
op.drop_table_comment(
|
||||
'tenants',
|
||||
existing_comment='Organizaciones cliente en el sistema multi-tenant',
|
||||
schema=None
|
||||
)
|
||||
op.drop_index('idx_ticket_attachments_comment_id', table_name='ticket_attachments')
|
||||
op.drop_index('idx_ticket_attachments_ticket_id', table_name='ticket_attachments')
|
||||
op.drop_index('idx_ticket_attachments_uploaded_by', table_name='ticket_attachments')
|
||||
op.drop_table_comment(
|
||||
'ticket_attachments',
|
||||
existing_comment='Archivos adjuntos en tickets',
|
||||
schema=None
|
||||
)
|
||||
op.drop_index('idx_ticket_comments_author_id', table_name='ticket_comments')
|
||||
op.drop_index('idx_ticket_comments_created_at', table_name='ticket_comments')
|
||||
op.drop_index('idx_ticket_comments_ticket_id', table_name='ticket_comments')
|
||||
op.drop_table_comment(
|
||||
'ticket_comments',
|
||||
existing_comment='Comentarios en tickets',
|
||||
schema=None
|
||||
)
|
||||
op.drop_index('idx_tickets_assigned_to', table_name='tickets', postgresql_where='(assigned_to IS NOT NULL)')
|
||||
op.drop_index('idx_tickets_category', table_name='tickets')
|
||||
op.drop_index('idx_tickets_created_at', table_name='tickets')
|
||||
op.drop_index('idx_tickets_created_by', table_name='tickets')
|
||||
op.drop_index('idx_tickets_number', table_name='tickets')
|
||||
op.drop_index('idx_tickets_priority', table_name='tickets')
|
||||
op.drop_index('idx_tickets_sla_resolution', table_name='tickets')
|
||||
op.drop_index('idx_tickets_sla_response', table_name='tickets')
|
||||
op.drop_index('idx_tickets_status', table_name='tickets')
|
||||
op.drop_index('idx_tickets_tenant_id', table_name='tickets')
|
||||
op.drop_index('idx_tickets_tenant_status', table_name='tickets')
|
||||
op.drop_table_comment(
|
||||
'tickets',
|
||||
existing_comment='Tickets de soporte - core del negocio',
|
||||
schema=None
|
||||
)
|
||||
op.drop_index('idx_users_active', table_name='users')
|
||||
op.drop_index('idx_users_email', table_name='users')
|
||||
op.drop_index('idx_users_role', table_name='users')
|
||||
op.drop_index('idx_users_tenant_email', table_name='users')
|
||||
op.drop_index('idx_users_tenant_id', table_name='users')
|
||||
op.drop_table_comment(
|
||||
'users',
|
||||
existing_comment='Usuarios del sistema (internos y clientes)',
|
||||
schema=None
|
||||
)
|
||||
op.drop_column('users', 'backup_codes_temp')
|
||||
op.drop_column('users', 'password_changed_at')
|
||||
op.drop_column('users', 'totp_secret_temp')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('users', sa.Column('totp_secret_temp', sa.VARCHAR(length=32), autoincrement=False, nullable=True))
|
||||
op.add_column('users', sa.Column('password_changed_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True))
|
||||
op.add_column('users', sa.Column('backup_codes_temp', postgresql.ARRAY(sa.VARCHAR()), autoincrement=False, nullable=True))
|
||||
op.create_table_comment(
|
||||
'users',
|
||||
'Usuarios del sistema (internos y clientes)',
|
||||
existing_comment=None,
|
||||
schema=None
|
||||
)
|
||||
op.create_index('idx_users_tenant_id', 'users', ['tenant_id'], unique=False)
|
||||
op.create_index('idx_users_tenant_email', 'users', ['tenant_id', 'email'], unique=False)
|
||||
op.create_index('idx_users_role', 'users', ['role'], unique=False)
|
||||
op.create_index('idx_users_email', 'users', ['email'], unique=False)
|
||||
op.create_index('idx_users_active', 'users', ['is_active'], unique=False)
|
||||
op.create_table_comment(
|
||||
'tickets',
|
||||
'Tickets de soporte - core del negocio',
|
||||
existing_comment=None,
|
||||
schema=None
|
||||
)
|
||||
op.create_index('idx_tickets_tenant_status', 'tickets', ['tenant_id', 'status'], unique=False)
|
||||
op.create_index('idx_tickets_tenant_id', 'tickets', ['tenant_id'], unique=False)
|
||||
op.create_index('idx_tickets_status', 'tickets', ['status'], unique=False)
|
||||
op.create_index('idx_tickets_sla_response', 'tickets', ['sla_response_due'], unique=False)
|
||||
op.create_index('idx_tickets_sla_resolution', 'tickets', ['sla_resolution_due'], unique=False)
|
||||
op.create_index('idx_tickets_priority', 'tickets', ['priority'], unique=False)
|
||||
op.create_index('idx_tickets_number', 'tickets', ['ticket_number'], unique=False)
|
||||
op.create_index('idx_tickets_created_by', 'tickets', ['created_by'], unique=False)
|
||||
op.create_index('idx_tickets_created_at', 'tickets', ['created_at'], unique=False)
|
||||
op.create_index('idx_tickets_category', 'tickets', ['category_id'], unique=False)
|
||||
op.create_index('idx_tickets_assigned_to', 'tickets', ['assigned_to'], unique=False, postgresql_where='(assigned_to IS NOT NULL)')
|
||||
op.create_table_comment(
|
||||
'ticket_comments',
|
||||
'Comentarios en tickets',
|
||||
existing_comment=None,
|
||||
schema=None
|
||||
)
|
||||
op.create_index('idx_ticket_comments_ticket_id', 'ticket_comments', ['ticket_id'], unique=False)
|
||||
op.create_index('idx_ticket_comments_created_at', 'ticket_comments', ['created_at'], unique=False)
|
||||
op.create_index('idx_ticket_comments_author_id', 'ticket_comments', ['author_id'], unique=False)
|
||||
op.create_table_comment(
|
||||
'ticket_attachments',
|
||||
'Archivos adjuntos en tickets',
|
||||
existing_comment=None,
|
||||
schema=None
|
||||
)
|
||||
op.create_index('idx_ticket_attachments_uploaded_by', 'ticket_attachments', ['uploaded_by'], unique=False)
|
||||
op.create_index('idx_ticket_attachments_ticket_id', 'ticket_attachments', ['ticket_id'], unique=False)
|
||||
op.create_index('idx_ticket_attachments_comment_id', 'ticket_attachments', ['comment_id'], unique=False)
|
||||
op.create_table_comment(
|
||||
'tenants',
|
||||
'Organizaciones cliente en el sistema multi-tenant',
|
||||
existing_comment=None,
|
||||
schema=None
|
||||
)
|
||||
op.create_index('idx_tenants_status', 'tenants', ['status'], unique=False)
|
||||
op.create_index('idx_tenants_slug', 'tenants', ['slug'], unique=False)
|
||||
op.create_index('idx_tenants_domain', 'tenants', ['domain'], unique=False)
|
||||
op.drop_index(op.f('ix_refresh_tokens_user_id'), table_name='refresh_tokens')
|
||||
op.drop_index(op.f('ix_refresh_tokens_expires_at'), table_name='refresh_tokens')
|
||||
op.drop_index('idx_refresh_tokens_user_expires', table_name='refresh_tokens')
|
||||
op.create_index('idx_refresh_tokens_user_id', 'refresh_tokens', ['user_id'], unique=False)
|
||||
op.create_index('idx_refresh_tokens_expires', 'refresh_tokens', ['expires_at'], unique=False)
|
||||
op.create_table_comment(
|
||||
'audit_logs',
|
||||
'Bit??cora de acciones para auditor??a y compliance',
|
||||
existing_comment=None,
|
||||
schema=None
|
||||
)
|
||||
op.drop_constraint(None, 'audit_logs', type_='foreignkey')
|
||||
op.drop_constraint(None, 'audit_logs', type_='foreignkey')
|
||||
op.create_foreign_key('audit_logs_tenant_id_fkey', 'audit_logs', 'tenants', ['tenant_id'], ['id'])
|
||||
op.create_foreign_key('audit_logs_user_id_fkey', 'audit_logs', 'users', ['user_id'], ['id'])
|
||||
op.drop_index(op.f('ix_audit_logs_user_id'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_tenant_id'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_resource_type'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_created_at'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_correlation_id'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_action'), table_name='audit_logs')
|
||||
op.drop_index('idx_audit_logs_user_created', table_name='audit_logs')
|
||||
op.drop_index('idx_audit_logs_tenant_action', table_name='audit_logs')
|
||||
op.create_index('idx_audit_logs_user_id', 'audit_logs', ['user_id'], unique=False)
|
||||
op.create_index('idx_audit_logs_tenant_id', 'audit_logs', ['tenant_id'], unique=False)
|
||||
op.create_index('idx_audit_logs_created_at', 'audit_logs', ['created_at'], unique=False)
|
||||
op.create_index('idx_audit_logs_correlation_id', 'audit_logs', ['correlation_id'], unique=False)
|
||||
op.create_index('idx_audit_logs_action', 'audit_logs', ['action'], unique=False)
|
||||
op.alter_column('audit_logs', 'created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('now()'))
|
||||
op.drop_column('audit_logs', 'updated_at')
|
||||
op.create_table('notification_logs',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
|
||||
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||
sa.Column('recipient_email', sa.VARCHAR(length=320), autoincrement=False, nullable=False),
|
||||
sa.Column('subject', sa.VARCHAR(length=500), autoincrement=False, nullable=False),
|
||||
sa.Column('template_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('ticket_id', sa.UUID(), autoincrement=False, nullable=True),
|
||||
sa.Column('user_id', sa.UUID(), autoincrement=False, nullable=True),
|
||||
sa.Column('status', sa.VARCHAR(length=20), server_default=sa.text("'pending'::character varying"), autoincrement=False, nullable=True),
|
||||
sa.Column('error_message', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('provider', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('external_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
|
||||
sa.Column('sent_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||
sa.CheckConstraint("status::text = ANY (ARRAY['pending'::character varying, 'sent'::character varying, 'failed'::character varying, 'bounced'::character varying]::text[])", name='notification_logs_status_check'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='notification_logs_tenant_id_fkey'),
|
||||
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], name='notification_logs_ticket_id_fkey'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='notification_logs_user_id_fkey'),
|
||||
sa.PrimaryKeyConstraint('id', name='notification_logs_pkey')
|
||||
)
|
||||
op.create_index('idx_notification_logs_ticket_id', 'notification_logs', ['ticket_id'], unique=False)
|
||||
op.create_index('idx_notification_logs_tenant_id', 'notification_logs', ['tenant_id'], unique=False)
|
||||
op.create_index('idx_notification_logs_status', 'notification_logs', ['status'], unique=False)
|
||||
op.create_index('idx_notification_logs_recipient', 'notification_logs', ['recipient_email'], unique=False)
|
||||
op.create_index('idx_notification_logs_created_at', 'notification_logs', ['created_at'], unique=False)
|
||||
op.create_table('ticket_status_history',
|
||||
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
|
||||
sa.Column('ticket_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||
sa.Column('changed_by', sa.UUID(), autoincrement=False, nullable=False),
|
||||
sa.Column('old_status', postgresql.ENUM('NEW', 'TRIAGE', 'IN_PROGRESS', 'WAITING_CUSTOMER', 'RESOLVED', 'CLOSED', 'REOPENED', name='ticket_status_enum'), autoincrement=False, nullable=True),
|
||||
sa.Column('new_status', postgresql.ENUM('NEW', 'TRIAGE', 'IN_PROGRESS', 'WAITING_CUSTOMER', 'RESOLVED', 'CLOSED', 'REOPENED', name='ticket_status_enum'), autoincrement=False, nullable=False),
|
||||
sa.Column('old_assigned_to', sa.UUID(), autoincrement=False, nullable=True),
|
||||
sa.Column('new_assigned_to', sa.UUID(), autoincrement=False, nullable=True),
|
||||
sa.Column('comment', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['changed_by'], ['users.id'], name='ticket_status_history_changed_by_fkey'),
|
||||
sa.ForeignKeyConstraint(['new_assigned_to'], ['users.id'], name='ticket_status_history_new_assigned_to_fkey'),
|
||||
sa.ForeignKeyConstraint(['old_assigned_to'], ['users.id'], name='ticket_status_history_old_assigned_to_fkey'),
|
||||
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], name='ticket_status_history_ticket_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name='ticket_status_history_pkey')
|
||||
)
|
||||
op.create_index('idx_ticket_status_history_ticket_id', 'ticket_status_history', ['ticket_id'], unique=False)
|
||||
op.create_index('idx_ticket_status_history_created_at', 'ticket_status_history', ['created_at'], unique=False)
|
||||
op.create_index('idx_ticket_status_history_changed_by', 'ticket_status_history', ['changed_by'], unique=False)
|
||||
op.create_table('email_templates',
|
||||
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||
sa.Column('template_key', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||
sa.Column('name', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('subject_template', sa.TEXT(), autoincrement=False, nullable=False),
|
||||
sa.Column('html_template', sa.TEXT(), autoincrement=False, nullable=False),
|
||||
sa.Column('text_template', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=False),
|
||||
sa.Column('is_system', sa.BOOLEAN(), autoincrement=False, nullable=False),
|
||||
sa.Column('required_variables', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True),
|
||||
sa.Column('default_variables', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True),
|
||||
sa.Column('from_name', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
|
||||
sa.Column('from_email', sa.VARCHAR(length=320), autoincrement=False, nullable=True),
|
||||
sa.Column('version', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('last_used_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
|
||||
sa.Column('usage_count', sa.INTEGER(), autoincrement=False, nullable=False),
|
||||
sa.Column('id', sa.UUID(), autoincrement=False, nullable=False),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='email_templates_tenant_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name='email_templates_pkey')
|
||||
)
|
||||
op.create_index('idx_email_templates_tenant_id', 'email_templates', ['tenant_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,28 @@
|
||||
"""add_ticket_participants
|
||||
|
||||
Revision ID: 46bccd948688
|
||||
Revises: d2e3f4a5b6c7
|
||||
Create Date: 2026-03-11 17:59:55.409878
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '46bccd948688'
|
||||
down_revision = 'd2e3f4a5b6c7'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,63 @@
|
||||
"""add_ticket_issues
|
||||
|
||||
Revision ID: b2dcb926e091
|
||||
Revises: 46bccd948688
|
||||
Create Date: 2026-03-17 16:25:34.452826
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = 'b2dcb926e091'
|
||||
down_revision = '46bccd948688'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = inspector.get_table_names()
|
||||
|
||||
if 'ticket_issues' not in existing_tables:
|
||||
op.create_table(
|
||||
'ticket_issues',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('tenant_id', sa.UUID(), nullable=False),
|
||||
sa.Column('ticket_id', sa.UUID(), nullable=False),
|
||||
sa.Column('created_by', sa.UUID(), nullable=False),
|
||||
sa.Column('content', sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
'priority',
|
||||
postgresql.ENUM('LOW', 'MEDIUM', 'HIGH', 'URGENT',
|
||||
name='ticket_priority_enum', create_type=False),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('attachment_path', sa.String(500), nullable=True),
|
||||
sa.Column('attachment_filename', sa.String(255), nullable=True),
|
||||
sa.Column('attachment_mime_type', sa.String(100), nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True),
|
||||
server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True),
|
||||
server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['created_by'], ['users.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
if 'ticket_issue_tagged_users' not in existing_tables:
|
||||
op.create_table(
|
||||
'ticket_issue_tagged_users',
|
||||
sa.Column('issue_id', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['issue_id'], ['ticket_issues.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('issue_id', 'user_id'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('ticket_issue_tagged_users')
|
||||
op.drop_table('ticket_issues')
|
||||
@@ -0,0 +1,43 @@
|
||||
"""add_ticket_indexes
|
||||
|
||||
Revision ID: b7c8d9e0f1a2
|
||||
Revises: fix_client_timestamps
|
||||
Create Date: 2026-03-03 00:00:00.000000
|
||||
|
||||
Agrega índices a la tabla tickets para optimizar queries frecuentes:
|
||||
- idx_tickets_status → filtros por estado
|
||||
- idx_tickets_priority → filtros por prioridad
|
||||
- idx_tickets_assigned_to → tickets por agente asignado
|
||||
- idx_tickets_tenant_status → compuesto multi-tenant (tenant_id, status)
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'b7c8d9e0f1a2'
|
||||
down_revision = 'fix_client_timestamps'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets (status)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_tickets_priority ON tickets (priority)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_tickets_assigned_to "
|
||||
"ON tickets (assigned_to) WHERE assigned_to IS NOT NULL"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_tickets_tenant_status "
|
||||
"ON tickets (tenant_id, status)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS idx_tickets_tenant_status")
|
||||
op.execute("DROP INDEX IF EXISTS idx_tickets_assigned_to")
|
||||
op.execute("DROP INDEX IF EXISTS idx_tickets_priority")
|
||||
op.execute("DROP INDEX IF EXISTS idx_tickets_status")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Add CLIENT_MANAGER, CLIENT_AGENT, CLIENT_AUDITOR to user_role_enum
|
||||
|
||||
Revision ID: c1d2e3f4a5b6
|
||||
Revises: b7c8d9e0f1a2
|
||||
Create Date: 2026-03-03 10:00:00.000000
|
||||
|
||||
Agrega tres nuevos roles de cliente al enum PostgreSQL:
|
||||
- CLIENT_MANAGER → gestiona tickets y usuarios del tenant
|
||||
- CLIENT_AGENT → atiende tickets del tenant
|
||||
- CLIENT_AUDITOR → auditoría de solo lectura del tenant
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c1d2e3f4a5b6'
|
||||
down_revision = 'b7c8d9e0f1a2'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# PostgreSQL permite agregar valores a un enum con ADD VALUE.
|
||||
# IF NOT EXISTS evita error si la migración se aplica dos veces.
|
||||
op.execute("ALTER TYPE user_role_enum ADD VALUE IF NOT EXISTS 'CLIENT_MANAGER'")
|
||||
op.execute("ALTER TYPE user_role_enum ADD VALUE IF NOT EXISTS 'CLIENT_AGENT'")
|
||||
op.execute("ALTER TYPE user_role_enum ADD VALUE IF NOT EXISTS 'CLIENT_AUDITOR'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# PostgreSQL no permite eliminar valores de un enum con ALTER TYPE DROP VALUE.
|
||||
# Para revertir habría que recrear el tipo completo desde cero, lo que requiere
|
||||
# actualizar todas las columnas que lo usan. Se documenta como no reversible
|
||||
# automáticamente — usar con precaución.
|
||||
pass
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Remove CLIENT_MANAGER, CLIENT_AGENT, CLIENT_AUDITOR from user_role_enum
|
||||
|
||||
Revision ID: d2e3f4a5b6c7
|
||||
Revises: c1d2e3f4a5b6
|
||||
Create Date: 2026-03-03 14:00:00.000000
|
||||
|
||||
Consolida 9 roles → 6 roles migrando datos primero y luego recreando
|
||||
el tipo enum de PostgreSQL (única forma de eliminar valores en PG).
|
||||
|
||||
Mapeo de datos:
|
||||
CLIENT_MANAGER → CLIENT_ADMIN (conserva nivel de gestión)
|
||||
CLIENT_AGENT → CLIENT_USER (acceso básico de cliente)
|
||||
CLIENT_AUDITOR → CLIENT_USER (acceso básico de cliente)
|
||||
|
||||
ADVERTENCIA DOWNGRADE: La migración inversa restaura los valores del
|
||||
enum pero NO puede recuperar la distinción original entre CLIENT_AGENT
|
||||
y CLIENT_AUDITOR (ambos quedaron como CLIENT_USER). El downgrade es
|
||||
seguro a nivel de integridad de datos, pero irreversible en semántica.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'd2e3f4a5b6c7'
|
||||
down_revision = 'c1d2e3f4a5b6'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── Paso 1: Migrar datos ANTES de modificar el tipo ────────────────────
|
||||
# CLIENT_MANAGER → CLIENT_ADMIN (conserva acceso de gestión)
|
||||
op.execute("UPDATE users SET role = 'CLIENT_ADMIN' WHERE role = 'CLIENT_MANAGER'")
|
||||
# CLIENT_AGENT → CLIENT_USER (acceso básico de cliente)
|
||||
op.execute("UPDATE users SET role = 'CLIENT_USER' WHERE role = 'CLIENT_AGENT'")
|
||||
# CLIENT_AUDITOR → CLIENT_USER (acceso básico de cliente)
|
||||
op.execute("UPDATE users SET role = 'CLIENT_USER' WHERE role = 'CLIENT_AUDITOR'")
|
||||
|
||||
# ── Paso 2: Soltar la restricción de tipo para poder recrear el enum ───
|
||||
# PostgreSQL no permite DROP VALUE en un enum; hay que recrear el tipo.
|
||||
op.execute("ALTER TABLE users ALTER COLUMN role TYPE TEXT")
|
||||
|
||||
# ── Paso 3: Eliminar tipo actual y recrearlo solo con los 6 roles ──────
|
||||
op.execute("DROP TYPE user_role_enum")
|
||||
op.execute("""
|
||||
CREATE TYPE user_role_enum AS ENUM (
|
||||
'ADMIN',
|
||||
'SUPPORT_MANAGER',
|
||||
'AGENT',
|
||||
'AUDITOR',
|
||||
'CLIENT_ADMIN',
|
||||
'CLIENT_USER'
|
||||
)
|
||||
""")
|
||||
|
||||
# ── Paso 4: Restaurar columna al tipo enum ──────────────────────────────
|
||||
op.execute(
|
||||
"ALTER TABLE users ALTER COLUMN role TYPE user_role_enum "
|
||||
"USING role::user_role_enum"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ── Paso 1: Soltar la restricción de tipo para recrear el enum ─────────
|
||||
op.execute("ALTER TABLE users ALTER COLUMN role TYPE TEXT")
|
||||
|
||||
# ── Paso 2: Recrear enum con los 9 valores originales ──────────────────
|
||||
op.execute("DROP TYPE user_role_enum")
|
||||
op.execute("""
|
||||
CREATE TYPE user_role_enum AS ENUM (
|
||||
'ADMIN',
|
||||
'SUPPORT_MANAGER',
|
||||
'AGENT',
|
||||
'AUDITOR',
|
||||
'CLIENT_ADMIN',
|
||||
'CLIENT_MANAGER',
|
||||
'CLIENT_AGENT',
|
||||
'CLIENT_AUDITOR',
|
||||
'CLIENT_USER'
|
||||
)
|
||||
""")
|
||||
|
||||
# ── Paso 3: Restaurar columna al tipo enum ──────────────────────────────
|
||||
op.execute(
|
||||
"ALTER TABLE users ALTER COLUMN role TYPE user_role_enum "
|
||||
"USING role::user_role_enum"
|
||||
)
|
||||
|
||||
# ── Nota sobre pérdida de datos ─────────────────────────────────────────
|
||||
# Los usuarios que eran CLIENT_MANAGER ahora son CLIENT_ADMIN.
|
||||
# Los usuarios que eran CLIENT_AGENT o CLIENT_AUDITOR ahora son CLIENT_USER.
|
||||
# No es posible restaurar la distinción original automáticamente.
|
||||
@@ -31,6 +31,7 @@ pyotp==2.9.0 # TOTP/2FA support
|
||||
# ===================================
|
||||
celery==5.3.4
|
||||
redis==5.0.1
|
||||
slowapi==0.1.9 # Rate limiting middleware
|
||||
|
||||
# ===================================
|
||||
# EMAIL
|
||||
|
||||
49
backend/scripts/reset_passwords.py
Normal file
49
backend/scripts/reset_passwords.py
Normal 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())
|
||||
@@ -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)
|
||||
|
||||
@@ -13,9 +13,9 @@ services:
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
|
||||
#- ./db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "5433:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-servicemanager}"]
|
||||
interval: 10s
|
||||
@@ -32,7 +32,7 @@ services:
|
||||
container_name: servicemanager-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
ports:
|
||||
- "6379:6379"
|
||||
@@ -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
|
||||
@@ -211,7 +215,7 @@ services:
|
||||
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- uploads_data:/var/www/uploads:ro
|
||||
ports:
|
||||
- "80:80"
|
||||
- "8088:80"
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend-client
|
||||
|
||||
11
frontend-client/fix_usuarios.py
Normal file
11
frontend-client/fix_usuarios.py
Normal file
@@ -0,0 +1,11 @@
|
||||
with open("/app/src/routes/usuarios/+page.svelte", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
content = content.replace(
|
||||
"{#if (user as any).can_manage_users && user.role !== 'CLIENT_ADMIN'}",
|
||||
"{#if user.can_manage_users && user.role !== 'CLIENT_ADMIN'}"
|
||||
)
|
||||
|
||||
with open("/app/src/routes/usuarios/+page.svelte", "w") as f:
|
||||
f.write(content)
|
||||
print("Listo")
|
||||
11
frontend-client/src/app.d.ts
vendored
Normal file
11
frontend-client/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { User } from '$lib/stores/auth';
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
user: User | null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" type="image/png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#3b82f6" />
|
||||
|
||||
|
||||
37
frontend-client/src/hooks.server.ts
Normal file
37
frontend-client/src/hooks.server.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// No restaurar sesión en la página de login
|
||||
if (event.url.pathname === '/login') {
|
||||
event.locals.user = null;
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
const cookieHeader = event.request.headers.get('cookie') ?? '';
|
||||
const cookieMatch = cookieHeader.match(/(?:client_access_token|internal_access_token)=([^;]+)/);
|
||||
const token = cookieMatch?.[1];
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const apiUrl = process.env.PUBLIC_API_URL ?? 'http://backend:8000';
|
||||
const response = await fetch(`${apiUrl}/v1/auth/me`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'X-App': 'client',
|
||||
'X-Tenant-Slug': ''
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
event.locals.user = await response.json();
|
||||
} else {
|
||||
event.locals.user = null;
|
||||
event.cookies.delete('client_access_token', { path: '/' });
|
||||
event.cookies.delete('internal_access_token', { path: '/' });
|
||||
}
|
||||
} catch {
|
||||
event.locals.user = null;
|
||||
}
|
||||
} else {
|
||||
event.locals.user = null;
|
||||
}
|
||||
return resolve(event);
|
||||
};
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
function handleLogout() {
|
||||
isMenuOpen = false;
|
||||
auth.logout(); // El store maneja la redirección automática
|
||||
auth.logout(); // El store maneja la redirección automática
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -49,6 +49,13 @@
|
||||
>
|
||||
Mis Tickets
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/usuarios"
|
||||
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
Usuarios
|
||||
</a>
|
||||
<a
|
||||
href="/tickets/new"
|
||||
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
|
||||
@@ -133,6 +140,13 @@
|
||||
>
|
||||
Mis Tickets
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/usuarios"
|
||||
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
Usuarios
|
||||
</a>
|
||||
<a
|
||||
href="/tickets/new"
|
||||
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
|
||||
|
||||
146
frontend-client/src/lib/components/IssueModal.svelte
Normal file
146
frontend-client/src/lib/components/IssueModal.svelte
Normal file
@@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { tickets } from '$lib/stores/tickets';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
|
||||
export let ticketId: string;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const PRIORITIES = [
|
||||
{ value: 'LOW', label: 'Baja' },
|
||||
{ value: 'MEDIUM', label: 'Media' },
|
||||
{ value: 'HIGH', label: 'Alta' },
|
||||
{ value: 'URGENT', label: 'Urgente' }
|
||||
];
|
||||
|
||||
let content = '';
|
||||
let priority = 'MEDIUM';
|
||||
let file: File | null = null;
|
||||
let isSubmitting = false;
|
||||
|
||||
function handleFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
file = input.files?.[0] ?? null;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!content.trim()) {
|
||||
toast.error('El contenido del asunto es obligatorio');
|
||||
return;
|
||||
}
|
||||
isSubmitting = true;
|
||||
try {
|
||||
await tickets.createIssue(ticketId, {
|
||||
content: content.trim(),
|
||||
priority,
|
||||
tagged_user_ids: [],
|
||||
file
|
||||
});
|
||||
toast.success('Asunto creado correctamente');
|
||||
dispatch('created');
|
||||
dispatch('close');
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Error al crear el asunto');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
dispatch('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div
|
||||
class="fixed inset-0 bg-gray-500 bg-opacity-75"
|
||||
on:click={handleClose}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
on:keydown={(e) => e.key === 'Escape' && handleClose()}
|
||||
/>
|
||||
|
||||
<div class="relative bg-white rounded-lg shadow-xl w-full max-w-lg z-10">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Crear Asunto</h3>
|
||||
<button type="button" on:click={handleClose} class="text-gray-400 hover:text-gray-500">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-5 space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Descripción <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
rows="4"
|
||||
bind:value={content}
|
||||
disabled={isSubmitting}
|
||||
placeholder="Describe el asunto de escalación..."
|
||||
class="form-input w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Prioridad</label>
|
||||
<select bind:value={priority} disabled={isSubmitting} class="form-input w-full">
|
||||
{#each PRIORITIES as p}
|
||||
<option value={p.value}>{p.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Adjunto (opcional)
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
on:change={handleFileChange}
|
||||
disabled={isSubmitting}
|
||||
class="block w-full text-sm text-gray-500
|
||||
file:mr-4 file:py-2 file:px-4 file:rounded-md
|
||||
file:border-0 file:text-sm file:font-medium
|
||||
file:bg-blue-50 file:text-blue-700
|
||||
hover:file:bg-blue-100 disabled:opacity-50"
|
||||
/>
|
||||
{#if file}
|
||||
<p class="text-xs text-gray-500 mt-1">{file.name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
on:click={handleClose}
|
||||
disabled={isSubmitting}
|
||||
class="btn-secondary px-4 py-2"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={handleSubmit}
|
||||
disabled={isSubmitting || !content.trim()}
|
||||
class="btn-primary px-4 py-2 disabled:opacity-50"
|
||||
>
|
||||
{#if isSubmitting}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="spinner w-4 h-4" />
|
||||
<span>Creando...</span>
|
||||
</div>
|
||||
{:else}
|
||||
Crear Asunto
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
242
frontend-client/src/lib/components/ParticipantsModal.svelte
Normal file
242
frontend-client/src/lib/components/ParticipantsModal.svelte
Normal file
@@ -0,0 +1,242 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { api } from '$lib/utils/api';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import { auth } from '$lib/stores/auth';
|
||||
|
||||
export let ticketId: string;
|
||||
export let createdBy: string;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
interface Participant {
|
||||
id: string;
|
||||
user_id: string;
|
||||
ticket_id: string;
|
||||
role: string;
|
||||
user_email: string;
|
||||
user_name: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
let participants: Participant[] = [];
|
||||
let allUsers: User[] = [];
|
||||
let isLoading = true;
|
||||
let isAdding = false;
|
||||
let searchQuery = '';
|
||||
let selectedUserId = '';
|
||||
|
||||
$: isCreator = $auth.user?.id === createdBy;
|
||||
$: isGlobalAdmin = $auth.user?.role === 'ADMIN';
|
||||
$: canManage = isCreator || isGlobalAdmin;
|
||||
|
||||
$: participantIds = new Set(participants.map(p => p.user_id));
|
||||
|
||||
$: filteredUsers = allUsers.filter(u => {
|
||||
if (participantIds.has(u.id)) return false;
|
||||
if (u.id === createdBy) return false;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return !q ||
|
||||
u.email.toLowerCase().includes(q) ||
|
||||
`${u.first_name} ${u.last_name}`.toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
await Promise.all([loadParticipants(), loadUsers()]);
|
||||
isLoading = false;
|
||||
});
|
||||
|
||||
async function loadParticipants() {
|
||||
try {
|
||||
participants = await api.get(`/tickets/${ticketId}/participants`);
|
||||
} catch (e: any) {
|
||||
toast.error('Error cargando participantes');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
allUsers = await api.get('/users/');
|
||||
} catch (e: any) {
|
||||
toast.error('Error cargando usuarios');
|
||||
}
|
||||
}
|
||||
|
||||
async function addParticipant(userId: string) {
|
||||
if (!userId) return;
|
||||
isAdding = true;
|
||||
try {
|
||||
const p = await api.post(`/tickets/${ticketId}/participants`, { user_id: userId });
|
||||
participants = [...participants, p];
|
||||
searchQuery = '';
|
||||
toast.success('Participante agregado');
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Error al agregar participante');
|
||||
} finally {
|
||||
isAdding = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeParticipant(userId: string) {
|
||||
try {
|
||||
await api.delete(`/tickets/${ticketId}/participants/${userId}`);
|
||||
participants = participants.filter(p => p.user_id !== userId);
|
||||
toast.success('Participante eliminado');
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Error al eliminar participante');
|
||||
}
|
||||
}
|
||||
|
||||
function avatarInitials(name: string, email: string): string {
|
||||
if (name.trim()) {
|
||||
const parts = name.trim().split(' ');
|
||||
return (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase();
|
||||
}
|
||||
return email[0].toUpperCase();
|
||||
}
|
||||
|
||||
function avatarColor(email: string): string {
|
||||
const colors = ['bg-blue-400', 'bg-violet-400', 'bg-emerald-400', 'bg-rose-400', 'bg-amber-400', 'bg-cyan-400'];
|
||||
let hash = 0;
|
||||
for (const c of email) hash = (hash << 5) - hash + c.charCodeAt(0);
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Overlay -->
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" on:click={() => dispatch('close')}></div>
|
||||
|
||||
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-lg z-10 overflow-hidden">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">Participantes del asunto</h2>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{participants.length} persona{participants.length !== 1 ? 's' : ''} involucrada{participants.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
<button type="button" on:click={() => dispatch('close')}
|
||||
class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-5 max-h-[70vh] overflow-y-auto">
|
||||
|
||||
<!-- Buscador para agregar -->
|
||||
{#if canManage}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Agregar persona</label>
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por nombre o email..."
|
||||
bind:value={searchQuery}
|
||||
class="w-full pl-9 pr-4 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Resultados de búsqueda -->
|
||||
{#if searchQuery && filteredUsers.length > 0}
|
||||
<div class="mt-2 border border-gray-200 rounded-lg overflow-hidden shadow-sm">
|
||||
{#each filteredUsers.slice(0, 6) as user (user.id)}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => addParticipant(user.id)}
|
||||
disabled={isAdding}
|
||||
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-blue-50 transition-colors text-left border-b border-gray-100 last:border-0"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full {avatarColor(user.email)} flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
||||
{avatarInitials(`${user.first_name} ${user.last_name}`, user.email)}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{user.first_name} {user.last_name}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{user.email}</p>
|
||||
</div>
|
||||
<span class="ml-auto text-xs text-blue-600 font-medium flex-shrink-0">+ Agregar</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if searchQuery && filteredUsers.length === 0}
|
||||
<p class="mt-2 text-sm text-gray-400 text-center py-2">No se encontraron usuarios disponibles</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Lista de participantes -->
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-3">
|
||||
{canManage ? 'Participantes actuales' : 'Personas involucradas'}
|
||||
</h3>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="flex justify-center py-6">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
|
||||
{:else if participants.length === 0}
|
||||
<div class="text-center py-8 bg-gray-50 rounded-lg border border-dashed border-gray-200">
|
||||
<svg class="w-10 h-10 mx-auto text-gray-300 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
<p class="text-sm text-gray-400">Aún no hay participantes</p>
|
||||
{#if canManage}
|
||||
<p class="text-xs text-gray-300 mt-1">Usa el buscador para agregar personas</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each participants as p (p.id)}
|
||||
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg group">
|
||||
<div class="w-9 h-9 rounded-full {avatarColor(p.user_email)} flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
||||
{avatarInitials(p.user_name, p.user_email)}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{p.user_name || p.user_email}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{p.user_email}</p>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 bg-white px-2 py-0.5 rounded-full border border-gray-200">
|
||||
Participante
|
||||
</span>
|
||||
{#if canManage}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => removeParticipant(p.user_id)}
|
||||
class="p-1 rounded text-gray-300 hover:text-rose-500 hover:bg-rose-50 transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="Quitar participante"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-6 py-4 bg-gray-50 border-t border-gray-100 flex justify-end">
|
||||
<button type="button" on:click={() => dispatch('close')}
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,15 +29,17 @@ const initialState: AppState = {
|
||||
// API helper function
|
||||
async function apiCall(endpoint: string, options: RequestInit = {}) {
|
||||
const authState = get(auth);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-App': 'client',
|
||||
...(authState.user?.tenant_id ? { 'X-Tenant-ID': authState.user.tenant_id } : {}),
|
||||
...(options.headers as Record<string, string> ?? {})
|
||||
};
|
||||
if (authState.token) headers['Authorization'] = `Bearer ${authState.token}`;
|
||||
const response = await fetch(`/api/v1${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authState.token}`,
|
||||
...(authState.user?.tenant_id ? { 'X-Tenant-ID': authState.user.tenant_id } : {}),
|
||||
...options.headers
|
||||
}
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
// Types
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
@@ -35,7 +34,6 @@ export interface LoginResponse {
|
||||
user: User;
|
||||
}
|
||||
|
||||
// Initial state
|
||||
const initialState: AuthState = {
|
||||
user: null,
|
||||
token: null,
|
||||
@@ -43,98 +41,86 @@ const initialState: AuthState = {
|
||||
isLoading: false
|
||||
};
|
||||
|
||||
// Create auth store
|
||||
function createAuthStore() {
|
||||
const { subscribe, set, update }: Writable<AuthState> = writable(initialState);
|
||||
let _state = initialState;
|
||||
subscribe(s => { _state = s; });
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
// Initialize auth from localStorage
|
||||
init: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const user = localStorage.getItem('auth_user');
|
||||
|
||||
if (token && user) {
|
||||
try {
|
||||
const parsedUser = JSON.parse(user);
|
||||
set({
|
||||
user: parsedUser,
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error parsing stored auth data:', error);
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
// Verifica sesión activa al cargar la app (cookie HttpOnly)
|
||||
init: async () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/me', {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'X-App': 'client',
|
||||
// Sin X-Tenant-ID aquí — /auth/me lee el tenant del JWT directamente
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
// Token es null — la autenticación viaja por cookie HttpOnly
|
||||
// El store solo necesita el user para la UI
|
||||
set({ user, token: null, isAuthenticated: true, isLoading: false });
|
||||
} else {
|
||||
// Cookie expirada o inválida — limpiar estado
|
||||
set(initialState);
|
||||
}
|
||||
} catch {
|
||||
set(initialState);
|
||||
}
|
||||
},
|
||||
|
||||
// Login
|
||||
login: async (credentials: LoginRequest): Promise<void> => {
|
||||
update(state => ({ ...state, isLoading: true }));
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-Slug': credentials.tenant_slug,
|
||||
},
|
||||
body: JSON.stringify(credentials)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Login failed');
|
||||
}
|
||||
|
||||
const data: LoginResponse = await response.json();
|
||||
|
||||
// Store auth data
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('auth_token', data.access_token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(data.user));
|
||||
}
|
||||
|
||||
set({
|
||||
user: data.user,
|
||||
token: data.access_token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false
|
||||
});
|
||||
// Guardamos el token en memoria para requests inmediatos
|
||||
// Si la página se recarga, init() recupera la sesión desde la cookie
|
||||
set({ user: data.user, token: data.access_token, isAuthenticated: true, isLoading: false });
|
||||
} catch (error) {
|
||||
update(state => ({ ...state, isLoading: false }));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Logout
|
||||
logout: () => {
|
||||
logout: async () => {
|
||||
try {
|
||||
const token = _state.token;
|
||||
await fetch('/api/v1/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'X-App': 'client',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
}
|
||||
});
|
||||
} catch {}
|
||||
set(initialState);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
// Immediate redirect after cleanup
|
||||
window.location.href = '/login';
|
||||
}
|
||||
set(initialState);
|
||||
},
|
||||
|
||||
// Update user data
|
||||
updateUser: (user: User) => {
|
||||
update(state => ({ ...state, user }));
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('auth_user', JSON.stringify(user));
|
||||
}
|
||||
},
|
||||
|
||||
// Set loading state
|
||||
setLoading: (isLoading: boolean) => {
|
||||
update(state => ({ ...state, isLoading }));
|
||||
}
|
||||
updateUser: (user: User) => { update(state => ({ ...state, user })); },
|
||||
setUser: (user: User) => { set({ user, token: null, isAuthenticated: true, isLoading: false }); },
|
||||
setLoading: (isLoading: boolean) => { update(state => ({ ...state, isLoading })); }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
104
frontend-client/src/lib/stores/auth.ts.bak
Normal file
104
frontend-client/src/lib/stores/auth.ts.bak
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { writable } from 'svelte/store';
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
tenant_id: string;
|
||||
role: 'CLIENT_ADMIN' | 'CLIENT_USER';
|
||||
is_active: boolean;
|
||||
is_two_factor_enabled: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
export interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
tenant_slug: string;
|
||||
totp_code?: string;
|
||||
}
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
user: User;
|
||||
}
|
||||
const initialState: AuthState = {
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false
|
||||
};
|
||||
function createAuthStore() {
|
||||
const { subscribe, set, update }: Writable<AuthState> = writable(initialState);
|
||||
let _state = initialState;
|
||||
subscribe(s => { _state = s; });
|
||||
return {
|
||||
subscribe,
|
||||
init: async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/me', {
|
||||
credentials: 'include',
|
||||
headers: { 'X-App': 'client', 'X-Tenant-Slug': 'aduanasoft' }
|
||||
});
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
set({ user, token: null, isAuthenticated: true, isLoading: false });
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
},
|
||||
login: async (credentials: LoginRequest): Promise<void> => {
|
||||
update(state => ({ ...state, isLoading: true }));
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-Slug': credentials.tenant_slug,
|
||||
},
|
||||
body: JSON.stringify(credentials)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Login failed');
|
||||
}
|
||||
const data: LoginResponse = await response.json();
|
||||
set({ user: data.user, token: data.access_token, isAuthenticated: true, isLoading: false });
|
||||
} catch (error) {
|
||||
update(state => ({ ...state, isLoading: false }));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
logout: async () => {
|
||||
try {
|
||||
const token = _state.token;
|
||||
await fetch('/api/v1/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'X-App': 'client',
|
||||
'X-Tenant-Slug': 'aduanasoft',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
}
|
||||
});
|
||||
} catch {}
|
||||
set(initialState);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
},
|
||||
updateUser: (user: User) => { update(state => ({ ...state, user })); },
|
||||
setUser: (user: User) => { set({ user, token: null, isAuthenticated: true, isLoading: false }); },
|
||||
setLoading: (isLoading: boolean) => { update(state => ({ ...state, isLoading })); }
|
||||
};
|
||||
}
|
||||
export const auth = createAuthStore();
|
||||
@@ -2,7 +2,6 @@ import type { Writable } from 'svelte/store';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { auth } from './auth';
|
||||
|
||||
// Types
|
||||
interface FastAPIValidationError {
|
||||
loc: (string | number)[];
|
||||
msg: string;
|
||||
@@ -24,6 +23,11 @@ export interface Ticket {
|
||||
updated_at: string;
|
||||
due_date: string | null;
|
||||
resolution: string | null;
|
||||
first_response_at?: string | null;
|
||||
resolved_at?: string | null;
|
||||
sla_response_due?: string | null;
|
||||
sla_resolution_due?: string | null;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
export interface TicketComment {
|
||||
@@ -50,6 +54,19 @@ export interface TicketAttachment {
|
||||
uploaded_at: string;
|
||||
}
|
||||
|
||||
export interface TicketIssue {
|
||||
id: string;
|
||||
ticket_id: string;
|
||||
content: string;
|
||||
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
|
||||
created_by: string;
|
||||
created_by_name: string;
|
||||
tagged_users: { id: string; full_name: string; email: string }[];
|
||||
attachment_filename: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateTicketRequest {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -62,49 +79,53 @@ export interface TicketsState {
|
||||
currentTicket: Ticket | null;
|
||||
comments: TicketComment[];
|
||||
attachments: TicketAttachment[];
|
||||
issues: TicketIssue[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Initial state
|
||||
const initialState: TicketsState = {
|
||||
tickets: [],
|
||||
currentTicket: null,
|
||||
comments: [],
|
||||
attachments: [],
|
||||
issues: [],
|
||||
isLoading: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
// API helper function
|
||||
async function apiCall(endpoint: string, options: RequestInit = {}) {
|
||||
const authState = get(auth);
|
||||
if (!authState.user) throw new Error('Not authenticated');
|
||||
|
||||
if (!authState.token || !authState.user) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
// Verificar que el usuario sigue autenticado
|
||||
if (!authState.isAuthenticated) throw new Error('Session expired');
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-App': 'client',
|
||||
...(options.headers as Record<string, string>)
|
||||
};
|
||||
// Solo agregar Authorization si el token existe en memoria
|
||||
// Si no está (recarga de página), la cookie HttpOnly lo maneja
|
||||
if (authState.token) headers['Authorization'] = `Bearer ${authState.token}`;
|
||||
if (authState.user.tenant_id) headers['X-Tenant-ID'] = authState.user.tenant_id;
|
||||
|
||||
const response = await fetch(`/api/v1${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authState.token}`,
|
||||
'X-Tenant-ID': authState.user.tenant_id,
|
||||
...options.headers
|
||||
}
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Request failed';
|
||||
try {
|
||||
const error = await response.json();
|
||||
console.error('❌ API Error Response:', error);
|
||||
|
||||
// Manejar diferentes formatos de error de FastAPI
|
||||
if (error.detail) {
|
||||
if (Array.isArray(error.detail)) {
|
||||
// Errores de validación de FastAPI
|
||||
errorMessage = error.detail.map((e: FastAPIValidationError) => `${e.loc.join('.')}: ${e.msg}`).join(', ');
|
||||
errorMessage = error.detail
|
||||
.map((e: FastAPIValidationError) => `${e.loc.join('.')}: ${e.msg}`)
|
||||
.join(', ');
|
||||
} else if (typeof error.detail === 'string') {
|
||||
errorMessage = error.detail;
|
||||
} else {
|
||||
@@ -113,34 +134,29 @@ async function apiCall(endpoint: string, options: RequestInit = {}) {
|
||||
} else {
|
||||
errorMessage = JSON.stringify(error);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Create tickets store
|
||||
function createTicketsStore() {
|
||||
const { subscribe, set, update }: Writable<TicketsState> = writable(initialState);
|
||||
const { subscribe, update }: Writable<TicketsState> = writable(initialState);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
// Load user's tickets
|
||||
loadTickets: async () => {
|
||||
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
|
||||
|
||||
update(state => ({ ...state, isLoading: true, error: null }));
|
||||
try {
|
||||
const raw = await apiCall('/tickets/');
|
||||
// El backend devuelve 'subject', el tipo Ticket usa 'title'
|
||||
const tickets = raw.map((t: any) => ({ ...t, title: t.subject ?? t.title }));
|
||||
update((state: TicketsState) => ({ ...state, tickets, isLoading: false }));
|
||||
update(state => ({ ...state, tickets, isLoading: false }));
|
||||
} catch (error) {
|
||||
update((state: TicketsState) => ({
|
||||
update(state => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load tickets'
|
||||
@@ -148,78 +164,117 @@ function createTicketsStore() {
|
||||
}
|
||||
},
|
||||
|
||||
// Load specific ticket with details
|
||||
loadTicket: async (ticketId: string) => {
|
||||
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
|
||||
|
||||
update(state => ({ ...state, isLoading: true, error: null }));
|
||||
try {
|
||||
const [ticketRaw, comments, attachments] = await Promise.all([
|
||||
const [ticketRaw, comments, attachments, issues] = await Promise.all([
|
||||
apiCall(`/tickets/${ticketId}`),
|
||||
apiCall(`/tickets/${ticketId}/comments`),
|
||||
apiCall(`/tickets/${ticketId}/attachments`)
|
||||
apiCall(`/tickets/${ticketId}/attachments`),
|
||||
apiCall(`/tickets/${ticketId}/issues`)
|
||||
]);
|
||||
// El backend devuelve 'subject', el tipo Ticket usa 'title'
|
||||
const ticket = { ...ticketRaw, title: ticketRaw.subject ?? ticketRaw.title };
|
||||
|
||||
update((state: TicketsState) => ({
|
||||
update(state => ({
|
||||
...state,
|
||||
currentTicket: ticket,
|
||||
comments,
|
||||
attachments,
|
||||
issues,
|
||||
isLoading: false
|
||||
}));
|
||||
} catch (error) {
|
||||
update((state: TicketsState) => ({
|
||||
update(state => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load ticket'
|
||||
}));
|
||||
}
|
||||
},
|
||||
// Reload only comments silently (for polling)
|
||||
|
||||
reloadComments: async (ticketId: string) => {
|
||||
try {
|
||||
const comments = await apiCall(`/tickets/${ticketId}/comments`);
|
||||
update((state: TicketsState) => ({ ...state, comments }));
|
||||
} catch (error) {
|
||||
// Silent fail - no mostrar error en polling
|
||||
console.error('Error reloading comments:', error);
|
||||
update(state => ({ ...state, comments }));
|
||||
} catch {
|
||||
// Silent fail en polling
|
||||
}
|
||||
},
|
||||
|
||||
createIssue: async (ticketId: string, data: {
|
||||
content: string;
|
||||
priority: string;
|
||||
tagged_user_ids: string[];
|
||||
file?: File | null;
|
||||
}) => {
|
||||
const authState = get(auth);
|
||||
if (!authState.user) throw new Error('Not authenticated');
|
||||
if (!authState.isAuthenticated) throw new Error('Session expired');
|
||||
|
||||
const headers: Record<string, string> = { 'X-App': 'client' };
|
||||
// Solo agregar token si existe — sino la cookie HttpOnly lo maneja
|
||||
if (authState.token) headers['Authorization'] = `Bearer ${authState.token}`;
|
||||
if (authState.user.tenant_id) headers['X-Tenant-ID'] = authState.user.tenant_id;
|
||||
|
||||
let body: FormData | string;
|
||||
|
||||
if (data.file) {
|
||||
const formData = new FormData();
|
||||
formData.append('content', data.content);
|
||||
formData.append('priority', data.priority);
|
||||
data.tagged_user_ids.forEach(id => formData.append('tagged_user_ids', id));
|
||||
formData.append('file', data.file);
|
||||
body = formData;
|
||||
// NO poner Content-Type — el browser lo agrega con boundary automáticamente
|
||||
} else {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
body = JSON.stringify({
|
||||
content: data.content,
|
||||
priority: data.priority,
|
||||
tagged_user_ids: data.tagged_user_ids
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/tickets/${ticketId}/issues`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers,
|
||||
body
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Error creating issue' }));
|
||||
throw new Error(
|
||||
typeof error.detail === 'string' ? error.detail : JSON.stringify(error.detail)
|
||||
);
|
||||
}
|
||||
|
||||
const newIssue = await response.json();
|
||||
update(state => ({ ...state, issues: [newIssue, ...state.issues] }));
|
||||
return newIssue;
|
||||
},
|
||||
|
||||
// Create new ticket
|
||||
createTicket: async (ticket: CreateTicketRequest) => {
|
||||
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
|
||||
|
||||
update(state => ({ ...state, isLoading: true, error: null }));
|
||||
try {
|
||||
// Mapear campos del frontend al formato del backend
|
||||
const ticketData = {
|
||||
subject: ticket.title, // ← Backend espera "subject" no "title"
|
||||
subject: ticket.title,
|
||||
description: ticket.description,
|
||||
category_id: ticket.category_id,
|
||||
priority: ticket.priority,
|
||||
system_id: null // ← Opcional
|
||||
system_id: null
|
||||
};
|
||||
|
||||
|
||||
console.log('Sending ticket data:', ticketData);
|
||||
|
||||
const newTicket = await apiCall('/tickets/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(ticketData)
|
||||
});
|
||||
|
||||
update((state: TicketsState) => ({
|
||||
update(state => ({
|
||||
...state,
|
||||
tickets: [newTicket, ...state.tickets],
|
||||
isLoading: false
|
||||
}));
|
||||
|
||||
return newTicket;
|
||||
} catch (error) {
|
||||
console.error('Create ticket error:', error);
|
||||
update((state: TicketsState) => ({
|
||||
update(state => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create ticket'
|
||||
@@ -228,22 +283,18 @@ function createTicketsStore() {
|
||||
}
|
||||
},
|
||||
|
||||
// Add comment to ticket
|
||||
addComment: async (ticketId: string, content: string) => {
|
||||
try {
|
||||
const comment = await apiCall(`/tickets/${ticketId}/comments`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content })
|
||||
// is_internal siempre false desde el frontend cliente
|
||||
// el backend además lo bloquearía si fuera true (fix de seguridad pendiente)
|
||||
body: JSON.stringify({ content, is_internal: false })
|
||||
});
|
||||
|
||||
update((state: TicketsState) => ({
|
||||
...state,
|
||||
comments: [...state.comments, comment]
|
||||
}));
|
||||
|
||||
update(state => ({ ...state, comments: [...state.comments, comment] }));
|
||||
return comment;
|
||||
} catch (error) {
|
||||
update((state: TicketsState) => ({
|
||||
update(state => ({
|
||||
...state,
|
||||
error: error instanceof Error ? error.message : 'Failed to add comment'
|
||||
}));
|
||||
@@ -251,24 +302,21 @@ function createTicketsStore() {
|
||||
}
|
||||
},
|
||||
|
||||
// Upload attachment
|
||||
uploadAttachment: async (ticketId: string, file: File) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const authState = get(auth);
|
||||
if (!authState.user) throw new Error('Not authenticated');
|
||||
|
||||
if (!authState.token || !authState.user) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
const uploadHeaders: Record<string, string> = { 'X-App': 'client' };
|
||||
if (authState.token) uploadHeaders['Authorization'] = `Bearer ${authState.token}`;
|
||||
if (authState.user.tenant_id) uploadHeaders['X-Tenant-ID'] = authState.user.tenant_id;
|
||||
|
||||
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authState.token}`,
|
||||
'X-Tenant-ID': authState.user.tenant_id
|
||||
},
|
||||
credentials: 'include',
|
||||
headers: uploadHeaders,
|
||||
body: formData
|
||||
});
|
||||
|
||||
@@ -279,15 +327,10 @@ function createTicketsStore() {
|
||||
|
||||
const result = await response.json();
|
||||
const attachment = result.data || result;
|
||||
|
||||
update((state: TicketsState) => ({
|
||||
...state,
|
||||
attachments: [...state.attachments, attachment]
|
||||
}));
|
||||
|
||||
update(state => ({ ...state, attachments: [...state.attachments, attachment] }));
|
||||
return attachment;
|
||||
} catch (error) {
|
||||
update((state: TicketsState) => ({
|
||||
update(state => ({
|
||||
...state,
|
||||
error: error instanceof Error ? error.message : 'Failed to upload attachment'
|
||||
}));
|
||||
@@ -295,23 +338,20 @@ function createTicketsStore() {
|
||||
}
|
||||
},
|
||||
|
||||
// Close ticket (client can close their own tickets)
|
||||
closeTicket: async (ticketId: string, resolution?: string) => {
|
||||
try {
|
||||
const updatedTicket = await apiCall(`/tickets/${ticketId}/close`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ resolution })
|
||||
body: JSON.stringify({ resolution_notes: resolution })
|
||||
});
|
||||
|
||||
update((state: TicketsState) => ({
|
||||
update(state => ({
|
||||
...state,
|
||||
currentTicket: state.currentTicket?.id === ticketId ? updatedTicket : state.currentTicket,
|
||||
tickets: state.tickets.map((t: Ticket) => t.id === ticketId ? updatedTicket : t)
|
||||
tickets: state.tickets.map(t => t.id === ticketId ? updatedTicket : t)
|
||||
}));
|
||||
|
||||
return updatedTicket;
|
||||
} catch (error) {
|
||||
update((state: TicketsState) => ({
|
||||
update(state => ({
|
||||
...state,
|
||||
error: error instanceof Error ? error.message : 'Failed to close ticket'
|
||||
}));
|
||||
@@ -319,43 +359,36 @@ function createTicketsStore() {
|
||||
}
|
||||
},
|
||||
|
||||
// Clear error
|
||||
clearError: () => {
|
||||
update((state: TicketsState) => ({ ...state, error: null }));
|
||||
},
|
||||
clearError: () => { update(state => ({ ...state, error: null })); },
|
||||
|
||||
// Clear current ticket
|
||||
clearCurrentTicket: () => {
|
||||
update((state: TicketsState) => ({
|
||||
update(state => ({
|
||||
...state,
|
||||
currentTicket: null,
|
||||
comments: [],
|
||||
attachments: []
|
||||
attachments: [],
|
||||
issues: []
|
||||
}));
|
||||
},
|
||||
|
||||
// Download attachment
|
||||
downloadAttachment: async (ticketId: string, attachmentId: string, filename: string) => {
|
||||
const authState = get(auth);
|
||||
if (!authState.user) throw new Error('Not authenticated');
|
||||
|
||||
if (!authState.token || !authState.user) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
const dlHeaders: Record<string, string> = { 'X-App': 'client' };
|
||||
if (authState.token) dlHeaders['Authorization'] = `Bearer ${authState.token}`;
|
||||
if (authState.user.tenant_id) dlHeaders['X-Tenant-ID'] = authState.user.tenant_id;
|
||||
|
||||
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authState.token}`,
|
||||
'X-Tenant-ID': authState.user.tenant_id
|
||||
}
|
||||
});
|
||||
const response = await fetch(
|
||||
`/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`,
|
||||
{ method: 'GET', credentials: 'include', headers: dlHeaders }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Download failed' }));
|
||||
throw new Error(error.detail || 'Download failed');
|
||||
}
|
||||
|
||||
// Crear blob y descargar
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
|
||||
112
frontend-client/src/lib/utils/api.ts
Normal file
112
frontend-client/src/lib/utils/api.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { auth } from '$lib/stores/auth';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
const API_BASE = '/api/v1';
|
||||
interface RequestOptions extends RequestInit {
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { params, ...init } = options;
|
||||
|
||||
let url = `${API_BASE}${endpoint}`;
|
||||
if (params) {
|
||||
const filteredParams = Object.entries(params)
|
||||
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
|
||||
if (Object.keys(filteredParams).length > 0) {
|
||||
url += `?${new URLSearchParams(filteredParams).toString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
const authState = get(auth);
|
||||
const headers = new Headers(init.headers);
|
||||
|
||||
if (authState.token) {
|
||||
headers.set('Authorization', `Bearer ${authState.token}`);
|
||||
}
|
||||
|
||||
if (!headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
headers.set('X-App', 'client');
|
||||
const slug = get(auth)?.user?.tenant_slug || get(auth)?.user?.tenant_id || '';
|
||||
headers.set('X-Tenant-Slug', slug);
|
||||
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `API error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function downloadFile(endpoint: string, filename: string): Promise<void> {
|
||||
const authState = get(auth);
|
||||
const headers = new Headers();
|
||||
|
||||
if (authState.token) {
|
||||
headers.set('Authorization', `Bearer ${authState.token}`);
|
||||
}
|
||||
}
|
||||
headers.set('X-App', 'client');
|
||||
const slug = get(auth)?.user?.tenant_slug || get(auth)?.user?.tenant_id || '';
|
||||
headers.set('X-Tenant-Slug', slug);
|
||||
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
if (typeof window !== 'undefined') window.location.href = '/login';
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `Download error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(endpoint: string, params?: Record<string, string>) =>
|
||||
request<T>(endpoint, { method: 'GET', params }),
|
||||
post: <T>(endpoint: string, body?: any) =>
|
||||
request<T>(endpoint, { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined }),
|
||||
put: <T>(endpoint: string, body?: any) =>
|
||||
request<T>(endpoint, { method: 'PUT', body: body !== undefined ? JSON.stringify(body) : undefined }),
|
||||
patch: <T>(endpoint: string, body?: any) =>
|
||||
request<T>(endpoint, { method: 'PATCH', body: body !== undefined ? JSON.stringify(body) : undefined }),
|
||||
delete: <T>(endpoint: string) =>
|
||||
request<T>(endpoint, { method: 'DELETE' }),
|
||||
downloadFile: (endpoint: string, filename: string) =>
|
||||
downloadFile(endpoint, filename)
|
||||
};
|
||||
7
frontend-client/src/routes/+layout.server.ts
Normal file
7
frontend-client/src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = ({ locals }) => {
|
||||
return {
|
||||
user: locals.user ?? null
|
||||
};
|
||||
};
|
||||
@@ -4,17 +4,39 @@
|
||||
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';
|
||||
|
||||
export let data;
|
||||
|
||||
let mounted = false;
|
||||
|
||||
onMount(() => {
|
||||
auth.init();
|
||||
if (data.user && !$auth.isAuthenticated) {
|
||||
auth.setUser(data.user);
|
||||
}
|
||||
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">
|
||||
{#if !mounted}
|
||||
<!-- Esperando inicialización de sesión -->
|
||||
<div class="flex items-center justify-center min-h-screen bg-gray-50">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
{:else}
|
||||
{#if showHeader}
|
||||
<Header />
|
||||
{/if}
|
||||
@@ -27,6 +49,7 @@
|
||||
<footer class="py-4 text-center border-t border-gray-200 bg-white">
|
||||
<p class="text-xs text-gray-400">ServiceManagerWeb v1.9.0 · © 2026 Aduanasoft</p>
|
||||
</footer>
|
||||
{/if}
|
||||
|
||||
<!-- Toast notifications -->
|
||||
{#each $toast.toasts as toastMessage (toastMessage.id)}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
let email = '';
|
||||
let password = '';
|
||||
let tenantSlug = 'aduanasoft-demo';
|
||||
let tenantSlug = 'ventas';
|
||||
let totpCode = '';
|
||||
let isLoading = false;
|
||||
let showTwoFactor = false;
|
||||
@@ -34,7 +34,7 @@
|
||||
await auth.login({
|
||||
email,
|
||||
password,
|
||||
tenant_slug: tenantSlug.trim() || 'aduanasoft-demo',
|
||||
tenant_slug: tenantSlug.trim() || 'ventas',
|
||||
totp_code: totpCode || undefined
|
||||
});
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
<div
|
||||
class="p-3 rounded-md bg-red-50 border border-red-100 flex items-center gap-3 animate-fade-in text-sm text-red-600"
|
||||
>
|
||||
<Icon name="alert-circle" class="w-4 h-4 flex-shrink-0" />
|
||||
<Icon name="alert-circle" className="w-4 h-4 flex-shrink-0" />
|
||||
{errorMessage}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -141,7 +141,7 @@
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Icon
|
||||
name="mail"
|
||||
class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
|
||||
className="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
@@ -166,7 +166,7 @@
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Icon
|
||||
name="lock"
|
||||
class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
|
||||
className="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
{#if showPassword}
|
||||
@@ -197,7 +197,7 @@
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer text-gray-400 hover:text-gray-600 focus:outline-none"
|
||||
on:click={() => (showPassword = !showPassword)}
|
||||
>
|
||||
<Icon name={showPassword ? 'eye-off' : 'eye'} class="w-5 h-5" />
|
||||
<Icon name={showPassword ? 'eye-off' : 'eye'} className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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>
|
||||
Olvidé mi clave
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -234,7 +235,7 @@
|
||||
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Icon name="shield-check" class="w-5 h-5 text-blue-500" />
|
||||
<Icon name="shield-check" className="w-5 h-5 text-blue-500" />
|
||||
</div>
|
||||
<input
|
||||
id="code"
|
||||
@@ -258,7 +259,7 @@
|
||||
disabled={isLoading}
|
||||
>
|
||||
{#if isLoading}
|
||||
<Icon name="loader-2" class="w-5 h-5 animate-spin mr-2" />
|
||||
<Icon name="loader-2" className="w-5 h-5 animate-spin mr-2" />
|
||||
Procesando...
|
||||
{:else}
|
||||
{showTwoFactor ? 'Verificar Acceso' : 'Acceder al Portal'}
|
||||
|
||||
@@ -38,11 +38,14 @@
|
||||
async function loadProfile() {
|
||||
isLoading = true;
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'X-App': 'client',
|
||||
'X-Tenant-ID': $auth.user?.tenant_id ?? ''
|
||||
};
|
||||
if ($auth.token) headers['Authorization'] = `Bearer ${$auth.token}`;
|
||||
const response = await fetch('/api/v1/client-profile/', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${$auth.token}`,
|
||||
'X-Tenant-ID': $auth.user?.tenant_id ?? ''
|
||||
}
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
if (!response.ok) throw new Error((await response.json()).detail);
|
||||
profile = await response.json();
|
||||
@@ -62,13 +65,16 @@
|
||||
async function saveProfile() {
|
||||
isSaving = true;
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-App': 'client',
|
||||
'X-Tenant-ID': $auth.user?.tenant_id ?? ''
|
||||
};
|
||||
if ($auth.token) headers['Authorization'] = `Bearer ${$auth.token}`;
|
||||
const response = await fetch('/api/v1/client-profile/', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${$auth.token}`,
|
||||
'X-Tenant-ID': $auth.user?.tenant_id ?? ''
|
||||
},
|
||||
credentials: 'include',
|
||||
headers,
|
||||
body: JSON.stringify(form)
|
||||
});
|
||||
if (!response.ok) throw new Error((await response.json()).detail);
|
||||
|
||||
@@ -34,7 +34,11 @@
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/2fa/setup', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${$auth.token}` }
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'X-App': 'client',
|
||||
...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {})
|
||||
}
|
||||
});
|
||||
if (!response.ok) throw new Error((await response.json()).detail);
|
||||
const data = await response.json();
|
||||
@@ -57,7 +61,12 @@
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/2fa/enable', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${$auth.token}` },
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-App': 'client',
|
||||
...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {})
|
||||
},
|
||||
body: JSON.stringify({ totp_code: totpSetupCode })
|
||||
});
|
||||
if (!response.ok) throw new Error((await response.json()).detail);
|
||||
@@ -84,7 +93,12 @@
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/2fa/disable', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${$auth.token}` },
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-App': 'client',
|
||||
...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {})
|
||||
},
|
||||
body: JSON.stringify({ totp_code: disableTotpCode })
|
||||
});
|
||||
if (!response.ok) throw new Error((await response.json()).detail);
|
||||
@@ -157,16 +171,20 @@
|
||||
|
||||
async function loadBusinessProfile() {
|
||||
try {
|
||||
if (!$auth.token || !$auth.user) {
|
||||
if (!$auth.user) {
|
||||
console.warn('Usuario no autenticado');
|
||||
return;
|
||||
}
|
||||
|
||||
const _lpHeaders: Record<string, string> = {
|
||||
'X-App': 'client',
|
||||
'X-Tenant-ID': $auth.user.tenant_id
|
||||
};
|
||||
if ($auth.token) _lpHeaders['Authorization'] = `Bearer ${$auth.token}`;
|
||||
|
||||
const response = await fetch('/api/v1/client-profile/', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${$auth.token}`,
|
||||
'X-Tenant-ID': $auth.user.tenant_id
|
||||
}
|
||||
credentials: 'include',
|
||||
headers: _lpHeaders
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
@@ -267,9 +285,11 @@
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/profile', {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${$auth.token}`
|
||||
'X-App': 'client',
|
||||
...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
first_name: firstName.trim(),
|
||||
@@ -300,9 +320,11 @@
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/change-password', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${$auth.token}`
|
||||
'X-App': 'client',
|
||||
...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
@@ -349,13 +371,16 @@
|
||||
profileData.credit_limit = parseFloat(profileData.credit_limit);
|
||||
}
|
||||
|
||||
const _bpHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-App': 'client',
|
||||
'X-Tenant-ID': $auth.user?.tenant_id ?? ''
|
||||
};
|
||||
if ($auth.token) _bpHeaders['Authorization'] = `Bearer ${$auth.token}`;
|
||||
const response = await fetch('/api/v1/client-profile/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${$auth.token}`,
|
||||
'X-Tenant-ID': $auth.user.tenant_id
|
||||
},
|
||||
credentials: 'include',
|
||||
headers: _bpHeaders,
|
||||
body: JSON.stringify(profileData)
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { tickets } from '$lib/stores/tickets.js';
|
||||
import { toast } from '$lib/stores/toast.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import IssueModal from '$lib/components/IssueModal.svelte';
|
||||
|
||||
let ticketId: string;
|
||||
let newComment = '';
|
||||
@@ -14,55 +15,38 @@
|
||||
let closeResolution = '';
|
||||
let fileInput: HTMLInputElement;
|
||||
let isUploading = false;
|
||||
let showAttachments = true;
|
||||
let showIssueModal = false;
|
||||
let pollingInterval: any = null;
|
||||
let showAttachments = true; // Variable para controlar la visibilidad de attachments
|
||||
|
||||
async function loadComments() {
|
||||
try {
|
||||
await tickets.reloadComments(ticketId);
|
||||
} catch (error) {
|
||||
// No mostrar error en polling silencioso
|
||||
console.error('Error recargando comentarios:', error);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Redirect if not authenticated
|
||||
if (!$auth.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
ticketId = $page.params.id;
|
||||
if (ticketId) {
|
||||
tickets.loadTicket(ticketId);
|
||||
|
||||
// Polling cada 3 segundos
|
||||
pollingInterval = setInterval(() => {
|
||||
loadComments();
|
||||
}, 3000);
|
||||
pollingInterval = setInterval(() => { loadComments(); }, 3000);
|
||||
}
|
||||
|
||||
// Cleanup cuando se desmonte el componente
|
||||
return () => {
|
||||
if (pollingInterval) {
|
||||
clearInterval(pollingInterval);
|
||||
}
|
||||
};
|
||||
return () => { if (pollingInterval) clearInterval(pollingInterval); };
|
||||
});
|
||||
|
||||
// Format date
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// Status mapping
|
||||
const statusConfig: Record<string, { label: string; class: string }> = {
|
||||
NEW: { label: 'Nuevo', class: 'badge-new' },
|
||||
IN_PROGRESS: { label: 'En Progreso', class: 'badge-in-progress' },
|
||||
@@ -74,7 +58,6 @@
|
||||
};
|
||||
const fallbackStatus = { label: 'Desconocido', class: 'badge-new' };
|
||||
|
||||
// Priority mapping
|
||||
const priorityConfig: Record<string, { label: string; class: string }> = {
|
||||
LOW: { label: 'Baja', class: 'badge-priority-low' },
|
||||
MEDIUM: { label: 'Media', class: 'badge-priority-medium' },
|
||||
@@ -85,7 +68,6 @@
|
||||
|
||||
async function handleAddComment() {
|
||||
if (!newComment.trim()) return;
|
||||
|
||||
isSubmittingComment = true;
|
||||
try {
|
||||
await tickets.addComment(ticketId, newComment.trim());
|
||||
@@ -102,34 +84,23 @@
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file size (max 10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error('El archivo es demasiado grande. Máximo 10MB');
|
||||
target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'application/msword',
|
||||
'image/jpeg','image/png','image/gif','image/webp','application/pdf',
|
||||
'text/plain','application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
];
|
||||
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
toast.error('Tipo de archivo no permitido');
|
||||
target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
isUploading = true;
|
||||
try {
|
||||
await tickets.uploadAttachment(ticketId, file);
|
||||
@@ -147,14 +118,11 @@
|
||||
await tickets.downloadAttachment(ticketId, attachment.id, attachment.original_filename);
|
||||
toast.success('Descarga iniciada');
|
||||
} catch (error: any) {
|
||||
console.error('Download error:', error);
|
||||
toast.error(error.message || 'Error al descargar el archivo');
|
||||
}
|
||||
}
|
||||
|
||||
function handleCloseTicket() {
|
||||
showCloseDialog = true;
|
||||
}
|
||||
function handleCloseTicket() { showCloseDialog = true; }
|
||||
|
||||
async function confirmCloseTicket() {
|
||||
isClosingTicket = true;
|
||||
@@ -175,7 +143,6 @@
|
||||
closeResolution = '';
|
||||
}
|
||||
|
||||
// Check if user can close ticket
|
||||
$: canClose =
|
||||
$tickets.currentTicket &&
|
||||
['RESOLVED', 'WAITING_CUSTOMER'].includes($tickets.currentTicket.status);
|
||||
@@ -195,16 +162,6 @@
|
||||
</div>
|
||||
{:else if $tickets.error}
|
||||
<div class="text-center py-12">
|
||||
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Error al cargar ticket</h3>
|
||||
<p class="text-gray-600 mb-4">{$tickets.error}</p>
|
||||
<button on:click={() => tickets.loadTicket(ticketId)} class="btn-primary px-4 py-2">
|
||||
@@ -244,32 +201,19 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if canClose}
|
||||
<button
|
||||
on:click={handleCloseTicket}
|
||||
class="btn-success px-4 py-2"
|
||||
disabled={isClosingTicket}
|
||||
>
|
||||
<button on:click={handleCloseTicket} class="btn-success px-4 py-2" disabled={isClosingTicket}>
|
||||
Cerrar Ticket
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<div class="prose max-w-none">
|
||||
<p class="whitespace-pre-wrap text-gray-700">
|
||||
{$tickets.currentTicket.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="whitespace-pre-wrap text-gray-700">{$tickets.currentTicket.description}</p>
|
||||
{#if $tickets.currentTicket.resolution}
|
||||
<div class="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<h4 class="font-medium text-green-900 mb-2">Resolución:</h4>
|
||||
<p class="text-green-800 whitespace-pre-wrap">
|
||||
{$tickets.currentTicket.resolution}
|
||||
</p>
|
||||
<p class="text-green-800 whitespace-pre-wrap">{$tickets.currentTicket.resolution}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -282,74 +226,34 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 flex items-center space-x-2">
|
||||
<span>Archivos Adjuntos</span>
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
||||
>
|
||||
{$tickets.attachments.length} archivo{$tickets.attachments.length !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{$tickets.attachments.length}
|
||||
</span>
|
||||
</h3>
|
||||
<button
|
||||
class="text-sm text-gray-500 hover:text-gray-700"
|
||||
title="Ver/Ocultar archivos adjuntos"
|
||||
on:click={() => (showAttachments = !showAttachments)}
|
||||
>
|
||||
<button class="text-sm text-gray-500 hover:text-gray-700"
|
||||
on:click={() => (showAttachments = !showAttachments)}>
|
||||
{showAttachments ? 'Ocultar' : 'Ver'} archivos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if showAttachments}
|
||||
<div class="card-content">
|
||||
<div class="space-y-3">
|
||||
{#each $tickets.attachments as attachment}
|
||||
<div
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-gray-200 rounded flex items-center justify-center">
|
||||
<svg
|
||||
class="w-4 h-4 text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{attachment.original_filename}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{Math.round(attachment.size_bytes / 1024)} KB • Subido por {attachment.uploaded_by_name}
|
||||
•
|
||||
{formatDate(attachment.uploaded_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
on:click={() => handleDownloadAttachment(attachment)}
|
||||
class="btn-ghost p-2 hover:bg-blue-100 rounded-md transition-colors"
|
||||
title="Descargar archivo"
|
||||
>
|
||||
<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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="card-content space-y-3">
|
||||
{#each $tickets.attachments as attachment}
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">{attachment.original_filename}</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{Math.round(attachment.size_bytes / 1024)} KB • {attachment.uploaded_by_name}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button on:click={() => handleDownloadAttachment(attachment)} class="btn-ghost p-2">
|
||||
<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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -362,107 +266,69 @@
|
||||
</div>
|
||||
<div class="card-content">
|
||||
{#if $tickets.comments.length === 0}
|
||||
<p class="text-gray-500 text-center py-4">
|
||||
No hay comentarios aún. ¡Sé el primero en comentar!
|
||||
</p>
|
||||
<p class="text-gray-500 text-center py-4">No hay comentarios aún.</p>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each $tickets.comments as comment}
|
||||
{console.log('Comment:', comment)}
|
||||
<div class="flex space-x-3">
|
||||
<div
|
||||
class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-primary-600 text-xs font-medium">
|
||||
{comment.author_name
|
||||
? comment.author_name
|
||||
.split(' ')
|
||||
.map(n => n[0])
|
||||
.join('')
|
||||
: '??'}
|
||||
{comment.author_name?.split(' ').map(n => n[0]).join('') ?? '??'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center space-x-2 mb-1">
|
||||
<span class="text-sm font-medium text-gray-900">
|
||||
{comment.author_name}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{formatDate(comment.created_at)}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-900">{comment.author_name}</span>
|
||||
<span class="text-xs text-gray-500">{formatDate(comment.created_at)}</span>
|
||||
{#if comment.is_internal}
|
||||
<span class="bg-red-100 text-red-700 text-xs px-2 py-0.5 rounded">
|
||||
Interno
|
||||
</span>
|
||||
<span class="bg-red-100 text-red-700 text-xs px-2 py-0.5 rounded">Interno</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-gray-700 whitespace-pre-wrap">
|
||||
{comment.content}
|
||||
</p>
|
||||
<p class="text-gray-700 whitespace-pre-wrap">{comment.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add Comment Form -->
|
||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||
<div class="space-y-4">
|
||||
<textarea
|
||||
rows="4"
|
||||
class="form-input"
|
||||
placeholder="Escribe tu comentario o respuesta..."
|
||||
bind:value={newComment}
|
||||
disabled={isSubmittingComment}
|
||||
/>
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center space-x-4">
|
||||
<input
|
||||
type="file"
|
||||
bind:this={fileInput}
|
||||
on:change={handleFileUpload}
|
||||
class="hidden"
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.txt,.doc,.docx,.xls,.xlsx"
|
||||
disabled={isUploading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => fileInput.click()}
|
||||
class="btn-ghost p-2 flex items-center space-x-2"
|
||||
disabled={isUploading}
|
||||
>
|
||||
{#if isUploading}
|
||||
<div class="spinner w-4 h-4" />
|
||||
{:else}
|
||||
<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="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="text-sm">Adjuntar archivo</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
on:click={handleAddComment}
|
||||
class="btn-primary px-4 py-2"
|
||||
disabled={isSubmittingComment || !newComment.trim()}
|
||||
>
|
||||
{#if isSubmittingComment}
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="spinner w-4 h-4" />
|
||||
<span>Enviando...</span>
|
||||
</div>
|
||||
<div class="mt-6 pt-6 border-t border-gray-200 space-y-4">
|
||||
<textarea
|
||||
rows="4"
|
||||
class="form-input"
|
||||
placeholder="Escribe tu comentario..."
|
||||
bind:value={newComment}
|
||||
disabled={isSubmittingComment}
|
||||
/>
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<input type="file" bind:this={fileInput} on:change={handleFileUpload}
|
||||
class="hidden"
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.txt,.doc,.docx,.xls,.xlsx"
|
||||
disabled={isUploading} />
|
||||
<button type="button" on:click={() => fileInput.click()}
|
||||
class="btn-ghost p-2 flex items-center space-x-2" disabled={isUploading}>
|
||||
{#if isUploading}
|
||||
<div class="spinner w-4 h-4" />
|
||||
{:else}
|
||||
Enviar Comentario
|
||||
<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="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="text-sm">Adjuntar</span>
|
||||
</button>
|
||||
</div>
|
||||
<button on:click={handleAddComment} class="btn-primary px-4 py-2"
|
||||
disabled={isSubmittingComment || !newComment.trim()}>
|
||||
{#if isSubmittingComment}
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="spinner w-4 h-4" />
|
||||
<span>Enviando...</span>
|
||||
</div>
|
||||
{:else}
|
||||
Enviar
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -479,40 +345,30 @@
|
||||
<div class="card-content space-y-4">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">ID del Ticket</dt>
|
||||
<dd class="text-sm text-gray-900 font-mono">
|
||||
#{$tickets.currentTicket.id.substring(0, 8)}
|
||||
</dd>
|
||||
<dd class="text-sm text-gray-900 font-mono">#{$tickets.currentTicket.id.substring(0, 8)}</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Categoría</dt>
|
||||
<dd class="text-sm text-gray-900">
|
||||
{$tickets.currentTicket.category_name || 'Sin categoría'}
|
||||
</dd>
|
||||
<dd class="text-sm text-gray-900">{$tickets.currentTicket.category_name || 'Sin categoría'}</dd>
|
||||
</div>
|
||||
|
||||
{#if $tickets.currentTicket.assigned_to_name}
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Asignado a</dt>
|
||||
<dd class="text-sm text-gray-900">{$tickets.currentTicket.assigned_to_name}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Creado</dt>
|
||||
<dd class="text-sm text-gray-900">{formatDate($tickets.currentTicket.created_at)}</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Última actualización</dt>
|
||||
<dd class="text-sm text-gray-900">{formatDate($tickets.currentTicket.updated_at)}</dd>
|
||||
</div>
|
||||
|
||||
<!-- SLA -->
|
||||
{#if $tickets.currentTicket.sla_response_due || $tickets.currentTicket.sla_resolution_due}
|
||||
<div class="pt-3 border-t border-gray-100">
|
||||
<dt class="text-sm font-medium text-gray-500 mb-2">Tiempos de SLA</dt>
|
||||
|
||||
{#if $tickets.currentTicket.sla_response_due}
|
||||
{@const respDue = new Date($tickets.currentTicket.sla_response_due)}
|
||||
{@const respVencido = respDue < new Date() && !$tickets.currentTicket.first_response_at}
|
||||
@@ -528,7 +384,6 @@
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $tickets.currentTicket.sla_resolution_due}
|
||||
{@const resDue = new Date($tickets.currentTicket.sla_resolution_due)}
|
||||
{@const resVencido = resDue < new Date() && !$tickets.currentTicket.resolved_at}
|
||||
@@ -546,119 +401,81 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $tickets.currentTicket.due_date}
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Fecha límite</dt>
|
||||
<dd
|
||||
class="text-sm text-gray-900 {new Date($tickets.currentTicket.due_date) <
|
||||
new Date()
|
||||
? 'text-red-600'
|
||||
: ''}"
|
||||
>
|
||||
{formatDate($tickets.currentTicket.due_date)}
|
||||
{#if new Date($tickets.currentTicket.due_date) < new Date()}
|
||||
<span class="block text-xs text-red-500">¡Vencido!</span>
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Asuntos -->
|
||||
<div class="card">
|
||||
<div class="card-header flex justify-between items-center">
|
||||
<h3 class="text-lg font-semibold text-gray-900">
|
||||
Asuntos
|
||||
{#if $tickets.issues.length > 0}
|
||||
<span class="text-gray-400 font-normal text-sm ml-1">({$tickets.issues.length})</span>
|
||||
{/if}
|
||||
</h3>
|
||||
<button type="button" on:click={() => showIssueModal = true}
|
||||
class="text-sm text-blue-600 hover:text-blue-700 font-medium">
|
||||
+ Crear
|
||||
</button>
|
||||
</div>
|
||||
{#if $tickets.issues.length > 0}
|
||||
<div class="divide-y divide-gray-100">
|
||||
{#each $tickets.issues as issue}
|
||||
<div class="card-content py-3">
|
||||
<p class="text-sm text-gray-700">{issue.content}</p>
|
||||
<span class="text-xs text-gray-400 mt-1 block">{issue.priority} · {issue.created_by_name}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card-content">
|
||||
<p class="text-sm text-gray-500">No hay asuntos creados.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Issue Modal -->
|
||||
{#if showIssueModal && $tickets.currentTicket}
|
||||
<IssueModal
|
||||
ticketId={$tickets.currentTicket.id}
|
||||
on:close={() => showIssueModal = false}
|
||||
on:created={() => showIssueModal = false}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Close Ticket Dialog -->
|
||||
{#if showCloseDialog}
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div
|
||||
class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"
|
||||
>
|
||||
<div
|
||||
class="fixed inset-0 transition-opacity"
|
||||
role="dialog"
|
||||
tabindex="0"
|
||||
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20">
|
||||
<div class="fixed inset-0 bg-gray-500 opacity-75"
|
||||
role="dialog" tabindex="0"
|
||||
on:click={cancelCloseTicket}
|
||||
on:keydown={e => e.key === 'Escape' && cancelCloseTicket()}
|
||||
>
|
||||
<div class="absolute inset-0 bg-gray-500 opacity-75" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
|
||||
>
|
||||
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div
|
||||
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-green-100 sm:mx-0 sm:h-10 sm:w-10"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">Cerrar Ticket</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500">
|
||||
¿Estás seguro de que quieres cerrar este ticket? Esta acción indica que el
|
||||
problema ha sido resuelto satisfactoriamente.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="close-resolution" class="form-label">
|
||||
Comentario de cierre (opcional)
|
||||
</label>
|
||||
<textarea
|
||||
id="close-resolution"
|
||||
rows="3"
|
||||
class="form-input"
|
||||
placeholder="Describe cómo se resolvió el problema o agrega comentarios finales..."
|
||||
bind:value={closeResolution}
|
||||
disabled={isClosingTicket}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full inline-flex justify-center btn-success px-4 py-2 sm:ml-3 sm:w-auto disabled:opacity-50"
|
||||
disabled={isClosingTicket}
|
||||
on:click={confirmCloseTicket}
|
||||
>
|
||||
on:keydown={e => e.key === 'Escape' && cancelCloseTicket()} />
|
||||
<div class="relative bg-white rounded-lg shadow-xl sm:max-w-lg w-full z-10 p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Cerrar Ticket</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
¿Estás seguro de que quieres cerrar este ticket?
|
||||
</p>
|
||||
<textarea rows="3" class="form-input w-full mb-4"
|
||||
placeholder="Comentario de cierre (opcional)..."
|
||||
bind:value={closeResolution} disabled={isClosingTicket} />
|
||||
<div class="flex justify-end gap-3">
|
||||
<button class="btn-secondary px-4 py-2" disabled={isClosingTicket} on:click={cancelCloseTicket}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button class="btn-success px-4 py-2 disabled:opacity-50" disabled={isClosingTicket} on:click={confirmCloseTicket}>
|
||||
{#if isClosingTicket}
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="spinner w-4 h-4" />
|
||||
<span>Cerrando...</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2"><div class="spinner w-4 h-4" /><span>Cerrando...</span></div>
|
||||
{:else}
|
||||
Cerrar Ticket
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 w-full inline-flex justify-center btn-secondary px-4 py-2 sm:mt-0 sm:w-auto"
|
||||
disabled={isClosingTicket}
|
||||
on:click={cancelCloseTicket}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
548
frontend-client/src/routes/usuarios/+page.svelte
Normal file
548
frontend-client/src/routes/usuarios/+page.svelte
Normal file
@@ -0,0 +1,548 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { toast } from '$lib/stores/toast.js';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
// ---- Tipos ----
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
tenant_id: string;
|
||||
can_manage_users?: boolean;
|
||||
}
|
||||
|
||||
// ---- Estado ----
|
||||
let users: User[] = [];
|
||||
let isLoading = true;
|
||||
let showModal = false;
|
||||
let showDeleteConfirm = false;
|
||||
let modalMode: 'create' | 'edit' = 'create';
|
||||
let selectedUser: User | null = null;
|
||||
let searchQuery = '';
|
||||
let filterRole = '';
|
||||
let filterStatus = '';
|
||||
|
||||
let form = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'CLIENT_USER',
|
||||
is_active: true,
|
||||
can_manage_users: false
|
||||
};
|
||||
|
||||
let isSaving = false;
|
||||
let isDeleting = false;
|
||||
|
||||
// ---- Permisos ----
|
||||
$: currentRole = $auth.user?.role ?? '';
|
||||
$: isClientAdmin = currentRole === 'CLIENT_ADMIN';
|
||||
$: canManageUsers = isClientAdmin;
|
||||
|
||||
// ---- Utilidades ----
|
||||
function apiHeaders(): Record<string, string> {
|
||||
const h: Record<string, string> = {
|
||||
'X-App': 'client',
|
||||
'X-Tenant-Slug': $auth.user?.tenant_slug || $auth.user?.tenant_id || '',
|
||||
};
|
||||
if ($auth.token) h['Authorization'] = `Bearer ${$auth.token}`;
|
||||
return h;
|
||||
}
|
||||
|
||||
// ---- Carga de usuarios ----
|
||||
async function loadUsers() {
|
||||
isLoading = true;
|
||||
try {
|
||||
const res = await fetch('/api/v1/users/', {
|
||||
credentials: 'include',
|
||||
headers: apiHeaders()
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).detail || 'Error al cargar usuarios');
|
||||
users = await res.json();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!$auth.isAuthenticated) { goto('/login'); return; }
|
||||
await loadUsers();
|
||||
});
|
||||
|
||||
// ---- Filtros reactivos ----
|
||||
$: filteredUsers = users.filter(u => {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const matchSearch = !q ||
|
||||
u.first_name?.toLowerCase().includes(q) ||
|
||||
u.last_name?.toLowerCase().includes(q) ||
|
||||
u.email?.toLowerCase().includes(q);
|
||||
const matchRole = !filterRole || u.role === filterRole;
|
||||
const matchStatus = filterStatus === '' ? true :
|
||||
filterStatus === 'active' ? u.is_active : !u.is_active;
|
||||
return matchSearch && matchRole && matchStatus;
|
||||
});
|
||||
|
||||
// ---- Modal ----
|
||||
function openCreate() {
|
||||
modalMode = 'create';
|
||||
form = { first_name: '', last_name: '', email: '', password: '', role: 'CLIENT_USER', is_active: true, can_manage_users: false };
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
function openEdit(user: User) {
|
||||
modalMode = 'edit';
|
||||
selectedUser = user;
|
||||
form = {
|
||||
first_name: user.first_name || '',
|
||||
last_name: user.last_name || '',
|
||||
email: user.email,
|
||||
password: '',
|
||||
role: user.role,
|
||||
is_active: user.is_active,
|
||||
can_manage_users: user.can_manage_users || false
|
||||
};
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal = false;
|
||||
selectedUser = null;
|
||||
}
|
||||
|
||||
// ---- CRUD ----
|
||||
async function saveUser() {
|
||||
isSaving = true;
|
||||
try {
|
||||
const payload: any = { ...form };
|
||||
if (modalMode === 'edit' && !payload.password) delete payload.password;
|
||||
|
||||
const url = modalMode === 'create'
|
||||
? '/api/v1/users/'
|
||||
: `/api/v1/users/${selectedUser?.id}`;
|
||||
const method = modalMode === 'create' ? 'POST' : 'PUT';
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: { ...apiHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error((await res.json()).detail || 'Error al guardar');
|
||||
toast.success(modalMode === 'create' ? 'Usuario creado correctamente' : 'Usuario actualizado');
|
||||
closeModal();
|
||||
await loadUsers();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(user: User) {
|
||||
try {
|
||||
const endpoint = user.is_active
|
||||
? `/api/v1/users/${user.id}`
|
||||
: `/api/v1/users/${user.id}/activate`;
|
||||
const method = user.is_active ? 'DELETE' : 'PATCH';
|
||||
const res = await fetch(endpoint, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: apiHeaders()
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).detail || 'Error');
|
||||
toast.success(user.is_active ? 'Usuario desactivado' : 'Usuario activado');
|
||||
await loadUsers();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser() {
|
||||
if (!selectedUser) return;
|
||||
isDeleting = true;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/users/${selectedUser.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: apiHeaders()
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).detail || 'Error al eliminar');
|
||||
toast.success('Usuario eliminado');
|
||||
showDeleteConfirm = false;
|
||||
selectedUser = null;
|
||||
await loadUsers();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
isDeleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(user: User) {
|
||||
selectedUser = user;
|
||||
showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
// ---- Helpers visuales ----
|
||||
function roleLabel(role: string): string {
|
||||
return role === 'CLIENT_ADMIN' ? 'Administrador' : 'Usuario';
|
||||
}
|
||||
|
||||
function roleBadgeClass(role: string): string {
|
||||
return role === 'CLIENT_ADMIN'
|
||||
? 'bg-violet-100 text-violet-700 ring-1 ring-violet-200'
|
||||
: 'bg-sky-50 text-sky-700 ring-1 ring-sky-200';
|
||||
}
|
||||
|
||||
function initials(u: User): string {
|
||||
const f = u.first_name?.[0] || '';
|
||||
const l = u.last_name?.[0] || '';
|
||||
return (f + l).toUpperCase() || u.email[0].toUpperCase();
|
||||
}
|
||||
|
||||
function avatarColor(email: string): string {
|
||||
const colors = [
|
||||
'bg-rose-400', 'bg-orange-400', 'bg-amber-400',
|
||||
'bg-emerald-400', 'bg-teal-400', 'bg-cyan-400',
|
||||
'bg-blue-400', 'bg-violet-400', 'bg-pink-400'
|
||||
];
|
||||
let hash = 0;
|
||||
for (const c of email) hash = (hash << 5) - hash + c.charCodeAt(0);
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Usuarios - ServiceManager</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
<!-- Encabezado -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Usuarios</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Miembros de tu organización· {users.length} en total
|
||||
</p>
|
||||
</div>
|
||||
{#if isClientAdmin}
|
||||
<button
|
||||
type="button"
|
||||
on:click={openCreate}
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors shadow-sm"
|
||||
>
|
||||
<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="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
Nuevo usuario
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Filtros -->
|
||||
<div class="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
<div class="relative flex-1">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por nombre o email..."
|
||||
bind:value={searchQuery}
|
||||
class="w-full pl-9 pr-4 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
|
||||
/>
|
||||
</div>
|
||||
<select bind:value={filterRole} class="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Roles </option>
|
||||
<option value="CLIENT_ADMIN">Administrador</option>
|
||||
<option value="CLIENT_USER">Usuario</option>
|
||||
</select>
|
||||
<select bind:value={filterStatus} class="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Estado </option>
|
||||
<option value="active">Activos</option>
|
||||
<option value="inactive">Inactivos</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Tabla -->
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center py-24">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
|
||||
{:else if filteredUsers.length === 0}
|
||||
<div class="text-center py-20 bg-white rounded-xl border border-gray-100">
|
||||
<svg class="w-12 h-12 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
<p class="text-gray-500 font-medium">No se encontraron usuarios</p>
|
||||
<p class="text-sm text-gray-400 mt-1">Intenta ajustar los filtros de búsqueda</p>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-100 bg-gray-50">
|
||||
<th class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Usuario</th>
|
||||
<th class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Rol</th>
|
||||
<th class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider hidden sm:table-cell">Miembro desde</th>
|
||||
<th class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Estado</th>
|
||||
{#if canManageUsers}
|
||||
<th class="px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider text-right">Acciones</th>
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
{#each filteredUsers as user (user.id)}
|
||||
<tr class="hover:bg-gray-50/50 transition-colors group">
|
||||
<!-- Avatar + info -->
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-full {avatarColor(user.email)} flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
||||
{initials(user)}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium text-gray-900 truncate">
|
||||
{user.first_name || ''} {user.last_name || ''}
|
||||
{#if user.id === $auth.user?.id}
|
||||
<span class="ml-1 text-xs text-gray-400">(tú)</span>
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 truncate">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Rol -->
|
||||
<td class="px-6 py-4">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {roleBadgeClass(user.role)}">
|
||||
{roleLabel(user.role)}
|
||||
</span>
|
||||
{#if user.can_manage_users && user.role !== 'CLIENT_ADMIN'}
|
||||
<span class="ml-1 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-700 ring-1 ring-amber-200">
|
||||
Gestión
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<!-- Fecha -->
|
||||
<td class="px-6 py-4 text-gray-500 hidden sm:table-cell">
|
||||
{user.created_at ? new Date(user.created_at).toLocaleDateString('es-MX', { year: 'numeric', month: 'short', day: 'numeric' }) : '—'}
|
||||
</td>
|
||||
|
||||
<!-- Estado -->
|
||||
<td class="px-6 py-4">
|
||||
{#if user.is_active}
|
||||
<span class="inline-flex items-center gap-1.5 text-xs font-medium text-emerald-700">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
|
||||
Activo
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center gap-1.5 text-xs font-medium text-gray-400">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-gray-300"></span>
|
||||
Inactivo
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<!-- Acciones -->
|
||||
{#if canManageUsers}
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
|
||||
<!-- Editar -->
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => openEdit(user)}
|
||||
class="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 transition-colors"
|
||||
title="Editar usuario"
|
||||
>
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Activar/Desactivar -->
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => toggleActive(user)}
|
||||
class="p-1.5 rounded-lg transition-colors {user.is_active
|
||||
? 'text-gray-400 hover:text-amber-600 hover:bg-amber-50'
|
||||
: 'text-gray-400 hover:text-emerald-600 hover:bg-emerald-50'}"
|
||||
title={user.is_active ? 'Desactivar' : 'Activar'}
|
||||
disabled={user.id === $auth.user?.id}
|
||||
>
|
||||
{#if user.is_active}
|
||||
<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="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Eliminar (solo CLIENT_ADMIN) -->
|
||||
{#if isClientAdmin && user.id !== $auth.user?.id}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => confirmDelete(user)}
|
||||
class="p-1.5 rounded-lg text-gray-400 hover:text-rose-600 hover:bg-rose-50 transition-colors"
|
||||
title="Eliminar usuario"
|
||||
>
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Modal crear/editar -->
|
||||
{#if showModal}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" on:click={closeModal}></div>
|
||||
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-md p-6 z-10">
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">
|
||||
{modalMode === 'create' ? 'Nuevo usuario' : 'Editar usuario'}
|
||||
</h2>
|
||||
<button type="button" on:click={closeModal} class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form on:submit|preventDefault={saveUser} class="space-y-4">
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Nombre</label>
|
||||
<input type="text" bind:value={form.first_name} required
|
||||
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Apellido</label>
|
||||
<input type="text" bind:value={form.last_name}
|
||||
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input type="email" bind:value={form.email} required
|
||||
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
{modalMode === 'create' ? 'Contraseña' : 'Nueva contraseña (dejar vacío para no cambiar)'}
|
||||
</label>
|
||||
<input type="password" bind:value={form.password} required={modalMode === 'create'}
|
||||
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Rol</label>
|
||||
<select bind:value={form.role}
|
||||
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white">
|
||||
<option value="CLIENT_USER">Usuario</option>
|
||||
<option value="CLIENT_ADMIN">Administrador</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Permiso de gestión (solo si el rol es CLIENT_USER) -->
|
||||
{#if form.role === 'CLIENT_USER'}
|
||||
<div class="flex items-start gap-3 p-3 bg-amber-50 rounded-lg border border-amber-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="can_manage_users"
|
||||
bind:checked={form.can_manage_users}
|
||||
class="mt-0.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<label for="can_manage_users" class="text-sm text-gray-700 cursor-pointer">
|
||||
<span class="font-medium">Permitir gestión de usuarios</span>
|
||||
<p class="text-xs text-gray-500 mt-0.5">Podrá crear y editar usuarios, pero no eliminarlos</p>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if modalMode === 'edit'}
|
||||
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_active"
|
||||
bind:checked={form.is_active}
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<label for="is_active" class="text-sm font-medium text-gray-700 cursor-pointer">Usuario activo</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button type="button" on:click={closeModal}
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" disabled={isSaving}
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-60 transition-colors">
|
||||
{isSaving ? 'Guardando...' : modalMode === 'create' ? 'Crear usuario' : 'Guardar cambios'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Confirmacióclsn eliminar -->
|
||||
{#if showDeleteConfirm && selectedUser}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" on:click={() => showDeleteConfirm = false}></div>
|
||||
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-sm p-6 z-10">
|
||||
<div class="w-12 h-12 rounded-full bg-rose-100 flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-6 h-6 text-rose-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-center font-semibold text-gray-900 mb-1">Eliminar usuario</h3>
|
||||
<p class="text-center text-sm text-gray-500 mb-6">
|
||||
¿Seguro que quieres eliminar a <strong>{selectedUser.first_name} {selectedUser.last_name}</strong>? Esta acción no se puede deshacer.
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button type="button" on:click={() => showDeleteConfirm = false}
|
||||
class="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="button" on:click={deleteUser} disabled={isDeleting}
|
||||
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-rose-600 rounded-lg hover:bg-rose-700 disabled:opacity-60 transition-colors">
|
||||
{isDeleting ? 'Eliminando...' : 'Eliminar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
BIN
frontend-client/static/favicon.png
Normal file
BIN
frontend-client/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -1,28 +1,35 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
watch: {
|
||||
usePolling: true,
|
||||
interval: 500
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.PUBLIC_API_URL || 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
},
|
||||
preview: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0'
|
||||
},
|
||||
build: {
|
||||
target: 'esnext'
|
||||
}
|
||||
});
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
watch: {
|
||||
usePolling: true,
|
||||
interval: 500
|
||||
},
|
||||
hmr: {
|
||||
host: 'localhost',
|
||||
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3000')
|
||||
},
|
||||
fs: {
|
||||
allow: ['/app', '.'],
|
||||
strict: false
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.PUBLIC_API_URL || 'http://backend:8000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
},
|
||||
preview: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0'
|
||||
},
|
||||
build: {
|
||||
target: 'esnext'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
11
frontend-internal/src/app.d.ts
vendored
Normal file
11
frontend-internal/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { InternalUser } from '$lib/stores/auth';
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
user: InternalUser | null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="description" content="ServiceManager - Mesa de Ayuda Empresarial - Portal Interno" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" type="image/png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
19
frontend-internal/src/hooks.server.ts
Normal file
19
frontend-internal/src/hooks.server.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const cookie = event.request.headers.get('cookie') ?? '';
|
||||
if (cookie) {
|
||||
try {
|
||||
const apiUrl = process.env.PUBLIC_API_URL ?? 'http://backend:8000';
|
||||
const response = await fetch(`${apiUrl}/v1/auth/me`, {
|
||||
headers: { cookie, 'X-App': 'internal' }
|
||||
});
|
||||
event.locals.user = response.ok ? await response.json() : null;
|
||||
} catch {
|
||||
event.locals.user = null;
|
||||
}
|
||||
} else {
|
||||
event.locals.user = null;
|
||||
}
|
||||
return resolve(event);
|
||||
};
|
||||
230
frontend-internal/src/lib/components/IssueModal.svelte
Normal file
230
frontend-internal/src/lib/components/IssueModal.svelte
Normal file
@@ -0,0 +1,230 @@
|
||||
<script lang="ts">
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import { api } from '$lib/utils/api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let ticketId: string;
|
||||
export let users: any[] = [];
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const PRIORITIES = [
|
||||
{ value: 'LOW', label: 'Baja' },
|
||||
{ value: 'MEDIUM', label: 'Media' },
|
||||
{ value: 'HIGH', label: 'Alta' },
|
||||
{ value: 'URGENT', label: 'Urgente' }
|
||||
];
|
||||
|
||||
let content = '';
|
||||
let priority = 'MEDIUM';
|
||||
let taggedUserIds: string[] = [];
|
||||
let file: File | null = null;
|
||||
let isSubmitting = false;
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
function toggleUser(userId: string) {
|
||||
if (taggedUserIds.includes(userId)) {
|
||||
taggedUserIds = taggedUserIds.filter(id => id !== userId);
|
||||
} else {
|
||||
taggedUserIds = [...taggedUserIds, userId];
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
file = input.files?.[0] ?? null;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!content.trim()) {
|
||||
toast.error('El contenido del asunto es obligatorio');
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
try {
|
||||
// 1. Crear el issue con JSON
|
||||
const newIssue = await api.post(`/tickets/${ticketId}/issues`, {
|
||||
content: content.trim(),
|
||||
priority,
|
||||
tagged_user_ids: taggedUserIds
|
||||
});
|
||||
|
||||
// 2. Si hay archivo, subirlo en request separado
|
||||
if (file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
await api.postForm(`/tickets/${ticketId}/issues/${newIssue.id}/attachment`, formData);
|
||||
}
|
||||
|
||||
toast.success('Asunto creado correctamente');
|
||||
dispatch('created');
|
||||
dispatch('close');
|
||||
} catch (e) {
|
||||
toast.error(e.message || 'Error al crear el asunto');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
dispatch('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Overlay -->
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
on:click={handleClose}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
on:keydown={e => e.key === 'Escape' && handleClose()}
|
||||
/>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="relative bg-white rounded-lg shadow-xl w-full max-w-lg z-10">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Crear Asunto</h3>
|
||||
<button type="button" on:click={handleClose} class="text-gray-400 hover:text-gray-500">
|
||||
<span class="sr-only">Cerrar</span>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-6 py-5 space-y-5">
|
||||
<!-- Contenido -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Descripción del asunto <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
rows="4"
|
||||
bind:value={content}
|
||||
disabled={isSubmitting}
|
||||
placeholder="Describe el asunto de escalación..."
|
||||
class="block w-full rounded-md border border-gray-300 shadow-sm
|
||||
focus:border-blue-500 focus:ring-blue-500 sm:text-sm p-2
|
||||
disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Prioridad -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Prioridad</label>
|
||||
<select
|
||||
bind:value={priority}
|
||||
disabled={isSubmitting}
|
||||
class="block w-full rounded-md border border-gray-300 shadow-sm
|
||||
focus:border-blue-500 focus:ring-blue-500 sm:text-sm p-2
|
||||
disabled:opacity-50"
|
||||
>
|
||||
{#each PRIORITIES as p}
|
||||
<option value={p.value}>{p.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Usuarios a etiquetar -->
|
||||
{#if users.length > 0}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2"> Etiquetar usuarios </label>
|
||||
<div
|
||||
class="max-h-40 overflow-y-auto border border-gray-200 rounded-md divide-y divide-gray-100"
|
||||
>
|
||||
{#each users as user}
|
||||
<label class="flex items-center gap-3 px-3 py-2 hover:bg-gray-50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={taggedUserIds.includes(user.id)}
|
||||
on:change={() => toggleUser(user.id)}
|
||||
disabled={isSubmitting}
|
||||
class="h-4 w-4 rounded border-gray-300 text-blue-600
|
||||
focus:ring-blue-500 disabled:opacity-50"
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium text-gray-900">
|
||||
{user.first_name}
|
||||
{user.last_name}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">{user.email}</span>
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{#if taggedUserIds.length > 0}
|
||||
<p class="text-xs text-blue-600 mt-1">
|
||||
{taggedUserIds.length} usuario{taggedUserIds.length > 1 ? 's' : ''} seleccionado{taggedUserIds.length >
|
||||
1
|
||||
? 's'
|
||||
: ''}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Adjunto -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"> Adjunto (opcional) </label>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
on:change={handleFileChange}
|
||||
disabled={isSubmitting}
|
||||
class="block w-full text-sm text-gray-500
|
||||
file:mr-4 file:py-2 file:px-4 file:rounded-md
|
||||
file:border-0 file:text-sm file:font-medium
|
||||
file:bg-blue-50 file:text-blue-700
|
||||
hover:file:bg-blue-100 disabled:opacity-50"
|
||||
/>
|
||||
{#if file}
|
||||
<p class="text-xs text-gray-500 mt-1">{file.name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
on:click={handleClose}
|
||||
disabled={isSubmitting}
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border
|
||||
border-gray-300 rounded-md shadow-sm hover:bg-gray-50
|
||||
disabled:opacity-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={handleSubmit}
|
||||
disabled={isSubmitting || !content.trim()}
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-700 border
|
||||
border-transparent rounded-md shadow-sm hover:bg-blue-800
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2
|
||||
focus:ring-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{#if isSubmitting}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
||||
<span>Creando...</span>
|
||||
</div>
|
||||
{:else}
|
||||
Crear Asunto
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
242
frontend-internal/src/lib/components/ParticipantsModal.svelte
Normal file
242
frontend-internal/src/lib/components/ParticipantsModal.svelte
Normal file
@@ -0,0 +1,242 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { api } from '$lib/utils/api';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import { auth } from '$lib/stores/auth';
|
||||
|
||||
export let ticketId: string;
|
||||
export let createdBy: string;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
interface Participant {
|
||||
id: string;
|
||||
user_id: string;
|
||||
ticket_id: string;
|
||||
role: string;
|
||||
user_email: string;
|
||||
user_name: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
let participants: Participant[] = [];
|
||||
let allUsers: User[] = [];
|
||||
let isLoading = true;
|
||||
let isAdding = false;
|
||||
let searchQuery = '';
|
||||
let selectedUserId = '';
|
||||
|
||||
$: isCreator = $auth.user?.id === createdBy;
|
||||
$: isGlobalAdmin = $auth.user?.role === 'ADMIN';
|
||||
$: canManage = isCreator || isGlobalAdmin;
|
||||
|
||||
$: participantIds = new Set(participants.map(p => p.user_id));
|
||||
|
||||
$: filteredUsers = allUsers.filter(u => {
|
||||
if (participantIds.has(u.id)) return false;
|
||||
if (u.id === createdBy) return false;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return !q ||
|
||||
u.email.toLowerCase().includes(q) ||
|
||||
`${u.first_name} ${u.last_name}`.toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
await Promise.all([loadParticipants(), loadUsers()]);
|
||||
isLoading = false;
|
||||
});
|
||||
|
||||
async function loadParticipants() {
|
||||
try {
|
||||
participants = await api.get(`/tickets/${ticketId}/participants`);
|
||||
} catch (e: any) {
|
||||
toast.error('Error cargando participantes');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
allUsers = await api.get('/users/');
|
||||
} catch (e: any) {
|
||||
toast.error('Error cargando usuarios');
|
||||
}
|
||||
}
|
||||
|
||||
async function addParticipant(userId: string) {
|
||||
if (!userId) return;
|
||||
isAdding = true;
|
||||
try {
|
||||
const p = await api.post(`/tickets/${ticketId}/participants`, { user_id: userId });
|
||||
participants = [...participants, p];
|
||||
searchQuery = '';
|
||||
toast.success('Participante agregado');
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Error al agregar participante');
|
||||
} finally {
|
||||
isAdding = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeParticipant(userId: string) {
|
||||
try {
|
||||
await api.delete(`/tickets/${ticketId}/participants/${userId}`);
|
||||
participants = participants.filter(p => p.user_id !== userId);
|
||||
toast.success('Participante eliminado');
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Error al eliminar participante');
|
||||
}
|
||||
}
|
||||
|
||||
function avatarInitials(name: string, email: string): string {
|
||||
if (name.trim()) {
|
||||
const parts = name.trim().split(' ');
|
||||
return (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase();
|
||||
}
|
||||
return email[0].toUpperCase();
|
||||
}
|
||||
|
||||
function avatarColor(email: string): string {
|
||||
const colors = ['bg-blue-400', 'bg-violet-400', 'bg-emerald-400', 'bg-rose-400', 'bg-amber-400', 'bg-cyan-400'];
|
||||
let hash = 0;
|
||||
for (const c of email) hash = (hash << 5) - hash + c.charCodeAt(0);
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Overlay -->
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" on:click={() => dispatch('close')}></div>
|
||||
|
||||
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-lg z-10 overflow-hidden">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">Participantes del asunto</h2>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{participants.length} persona{participants.length !== 1 ? 's' : ''} involucrada{participants.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
<button type="button" on:click={() => dispatch('close')}
|
||||
class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-5 max-h-[70vh] overflow-y-auto">
|
||||
|
||||
<!-- Buscador para agregar -->
|
||||
{#if canManage}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Agregar persona</label>
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por nombre o email..."
|
||||
bind:value={searchQuery}
|
||||
class="w-full pl-9 pr-4 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Resultados de búsqueda -->
|
||||
{#if searchQuery && filteredUsers.length > 0}
|
||||
<div class="mt-2 border border-gray-200 rounded-lg overflow-hidden shadow-sm">
|
||||
{#each filteredUsers.slice(0, 6) as user (user.id)}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => addParticipant(user.id)}
|
||||
disabled={isAdding}
|
||||
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-blue-50 transition-colors text-left border-b border-gray-100 last:border-0"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full {avatarColor(user.email)} flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
||||
{avatarInitials(`${user.first_name} ${user.last_name}`, user.email)}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{user.first_name} {user.last_name}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{user.email}</p>
|
||||
</div>
|
||||
<span class="ml-auto text-xs text-blue-600 font-medium flex-shrink-0">+ Agregar</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if searchQuery && filteredUsers.length === 0}
|
||||
<p class="mt-2 text-sm text-gray-400 text-center py-2">No se encontraron usuarios disponibles</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Lista de participantes -->
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-3">
|
||||
{canManage ? 'Participantes actuales' : 'Personas involucradas'}
|
||||
</h3>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="flex justify-center py-6">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
|
||||
{:else if participants.length === 0}
|
||||
<div class="text-center py-8 bg-gray-50 rounded-lg border border-dashed border-gray-200">
|
||||
<svg class="w-10 h-10 mx-auto text-gray-300 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
<p class="text-sm text-gray-400">Aún no hay participantes</p>
|
||||
{#if canManage}
|
||||
<p class="text-xs text-gray-300 mt-1">Usa el buscador para agregar personas</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each participants as p (p.id)}
|
||||
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg group">
|
||||
<div class="w-9 h-9 rounded-full {avatarColor(p.user_email)} flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
||||
{avatarInitials(p.user_name, p.user_email)}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{p.user_name || p.user_email}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{p.user_email}</p>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 bg-white px-2 py-0.5 rounded-full border border-gray-200">
|
||||
Participante
|
||||
</span>
|
||||
{#if canManage}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => removeParticipant(p.user_id)}
|
||||
class="p-1 rounded text-gray-300 hover:text-rose-500 hover:bg-rose-50 transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="Quitar participante"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-6 py-4 bg-gray-50 border-t border-gray-100 flex justify-end">
|
||||
<button type="button" on:click={() => dispatch('close')}
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,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'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
// Types
|
||||
export interface InternalUser {
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
role: 'ADMIN' | 'SUPPORT_MANAGER' | 'AGENT' | 'AUDITOR';
|
||||
tenant_id: string;
|
||||
tenant_slug: string;
|
||||
role: 'CLIENT_ADMIN' | 'CLIENT_USER';
|
||||
is_active: boolean;
|
||||
is_two_factor_enabled: boolean;
|
||||
created_at: string;
|
||||
tenant_id: string;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: InternalUser | null;
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
refreshToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
@@ -25,190 +24,90 @@ export interface AuthState {
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
tenant_slug: string;
|
||||
tenant_slug?: string;
|
||||
totp_code?: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
user: InternalUser;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface RefreshTokenRequest {
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
// Initial state
|
||||
const initialState: AuthState = {
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false
|
||||
};
|
||||
|
||||
// Create auth store
|
||||
function createAuthStore() {
|
||||
const { subscribe, set, update } = writable<AuthState>(initialState);
|
||||
const { subscribe, set, update }: Writable<AuthState> = writable(initialState);
|
||||
let _state = initialState;
|
||||
subscribe(s => { _state = s; });
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
// Initialize auth from localStorage
|
||||
init: () => {
|
||||
init: async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('internal_auth_token');
|
||||
const refreshToken = localStorage.getItem('internal_auth_refresh_token');
|
||||
const user = localStorage.getItem('internal_auth_user');
|
||||
|
||||
if (token && user) {
|
||||
try {
|
||||
const parsedUser = JSON.parse(user);
|
||||
set({
|
||||
user: parsedUser,
|
||||
token,
|
||||
refreshToken: refreshToken || null,
|
||||
isAuthenticated: true,
|
||||
isLoading: false
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error parsing stored auth data:', error);
|
||||
localStorage.removeItem('internal_auth_token');
|
||||
localStorage.removeItem('internal_auth_refresh_token');
|
||||
localStorage.removeItem('internal_auth_user');
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/me', {
|
||||
credentials: 'include',
|
||||
headers: { 'X-App': 'internal' }
|
||||
});
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
set({ user, token: null, isAuthenticated: true, isLoading: false });
|
||||
}
|
||||
}
|
||||
} catch (error) { }
|
||||
}
|
||||
},
|
||||
|
||||
// Login
|
||||
login: async (credentials: LoginRequest): Promise<void> => {
|
||||
update(state => ({ ...state, isLoading: true }));
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-App': 'client',
|
||||
},
|
||||
body: JSON.stringify(credentials)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Login failed');
|
||||
}
|
||||
|
||||
const data: LoginResponse = await response.json();
|
||||
|
||||
// Store auth data
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('internal_auth_token', data.access_token);
|
||||
if (data.refresh_token) {
|
||||
localStorage.setItem('internal_auth_refresh_token', data.refresh_token);
|
||||
}
|
||||
localStorage.setItem('internal_auth_user', JSON.stringify(data.user));
|
||||
}
|
||||
|
||||
set({
|
||||
user: data.user,
|
||||
token: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false
|
||||
});
|
||||
set({ user: data.user, token: data.access_token, isAuthenticated: true, isLoading: false });
|
||||
} catch (error) {
|
||||
update(state => ({ ...state, isLoading: false }));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Refresh Session
|
||||
refreshSession: async (): Promise<void> => {
|
||||
// Need to get current state to access refresh token, logic simplified
|
||||
let currentRefreshToken: string | null = null;
|
||||
if (typeof window !== 'undefined') {
|
||||
currentRefreshToken = localStorage.getItem('internal_auth_refresh_token');
|
||||
}
|
||||
|
||||
if (!currentRefreshToken) {
|
||||
throw new Error("No refresh token available");
|
||||
}
|
||||
|
||||
update (state => ({ ...state, isLoading: true }));
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ refresh_token: currentRefreshToken })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// If refresh fails, logout
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
auth.logout();
|
||||
}
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Refresh failed');
|
||||
}
|
||||
|
||||
const data: TokenResponse = await response.json();
|
||||
|
||||
// Update token in storage and state
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('internal_auth_token', data.access_token);
|
||||
}
|
||||
|
||||
update(state => ({
|
||||
...state,
|
||||
token: data.access_token,
|
||||
isLoading: false
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
update(state => ({ ...state, isLoading: false }));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Logout
|
||||
logout: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('internal_auth_token');
|
||||
localStorage.removeItem('internal_auth_refresh_token');
|
||||
localStorage.removeItem('internal_auth_user');
|
||||
}
|
||||
logout: async () => {
|
||||
try {
|
||||
const token = _state.token;
|
||||
const slug = _state.user?.tenant_slug || _state.user?.tenant_id || '';
|
||||
await fetch('/api/v1/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'X-App': 'internal',
|
||||
'X-Tenant-Slug': slug,
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
}
|
||||
});
|
||||
} catch { }
|
||||
set(initialState);
|
||||
// Optional: Redirect to login
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
window.location.href = '/login';
|
||||
}
|
||||
},
|
||||
|
||||
// Update user data
|
||||
updateUser: (user: InternalUser) => {
|
||||
update(state => ({ ...state, user }));
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('internal_auth_user', JSON.stringify(user));
|
||||
}
|
||||
},
|
||||
|
||||
// Set loading state
|
||||
setLoading: (isLoading: boolean) => {
|
||||
update(state => ({ ...state, isLoading }));
|
||||
}
|
||||
updateUser: (user: User) => { update(state => ({ ...state, user })); },
|
||||
setUser: (user: User) => { set({ user, token: null, isAuthenticated: true, isLoading: false }); },
|
||||
setLoading: (isLoading: boolean) => { update(state => ({ ...state, isLoading })); }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
161
frontend-internal/src/lib/stores/dashboardConfig.ts
Normal file
161
frontend-internal/src/lib/stores/dashboardConfig.ts
Normal 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();
|
||||
@@ -24,16 +24,8 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
|
||||
}
|
||||
|
||||
const authState = get(auth);
|
||||
const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null);
|
||||
|
||||
// Resolve tenant_id from store or from the persisted user object in localStorage
|
||||
let tenantId = authState.user?.tenant_id ?? null;
|
||||
if (!tenantId && typeof window !== 'undefined') {
|
||||
try {
|
||||
const stored = localStorage.getItem('internal_auth_user');
|
||||
if (stored) tenantId = JSON.parse(stored)?.tenant_id ?? null;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
const token = authState.token;
|
||||
const tenantId = authState.user?.tenant_id ?? null;
|
||||
|
||||
const headers = new Headers(init.headers);
|
||||
if (token) {
|
||||
@@ -45,17 +37,18 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
|
||||
if (!headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
// Identifica este frontend para que el backend use la cookie correcta
|
||||
headers.set('X-App', 'internal');
|
||||
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
// Token expired or invalid
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('internal_auth_token');
|
||||
localStorage.removeItem('internal_auth_user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new Error('Unauthorized');
|
||||
@@ -76,15 +69,8 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
|
||||
|
||||
async function downloadFile(endpoint: string, filename: string): Promise<void> {
|
||||
const authState = get(auth);
|
||||
const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null);
|
||||
|
||||
let tenantId = authState.user?.tenant_id ?? null;
|
||||
if (!tenantId && typeof window !== 'undefined') {
|
||||
try {
|
||||
const stored = localStorage.getItem('internal_auth_user');
|
||||
if (stored) tenantId = JSON.parse(stored)?.tenant_id ?? null;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
const token = authState.token;
|
||||
const tenantId = authState.user?.tenant_id ?? null;
|
||||
|
||||
const headers = new Headers();
|
||||
if (token) {
|
||||
@@ -93,16 +79,16 @@ async function downloadFile(endpoint: string, filename: string): Promise<void> {
|
||||
if (tenantId) {
|
||||
headers.set('X-Tenant-ID', tenantId);
|
||||
}
|
||||
headers.set('X-App', 'internal');
|
||||
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('internal_auth_token');
|
||||
localStorage.removeItem('internal_auth_user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new Error('Unauthorized');
|
||||
|
||||
7
frontend-internal/src/routes/+layout.server.ts
Normal file
7
frontend-internal/src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = ({ locals }) => {
|
||||
return {
|
||||
user: locals.user ?? null
|
||||
};
|
||||
};
|
||||
@@ -5,21 +5,40 @@
|
||||
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';
|
||||
|
||||
export let data;
|
||||
|
||||
let sidebarOpen = false;
|
||||
let mounted = false;
|
||||
|
||||
onMount(() => {
|
||||
auth.init();
|
||||
if (data.user && !$auth.isAuthenticated) {
|
||||
auth.setUser(data.user);
|
||||
}
|
||||
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;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
{#if $auth.isAuthenticated}
|
||||
{#if !mounted}
|
||||
<!-- Esperando inicialización de sesión -->
|
||||
<div class="flex items-center justify-center min-h-screen bg-gray-50">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
{:else if $auth.isAuthenticated}
|
||||
<!-- Internal Layout with Sidebar -->
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
<!-- Sidebar -->
|
||||
|
||||
@@ -2,44 +2,27 @@
|
||||
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) {
|
||||
goto('/login');
|
||||
}
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { toast } from '$lib/stores/toast.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
|
||||
|
||||
let email = '';
|
||||
let password = '';
|
||||
let totpCode = '';
|
||||
let isLoading = false;
|
||||
let showTwoFactor = false;
|
||||
let errorMessage = '';
|
||||
|
||||
|
||||
onMount(() => {
|
||||
// Redirect if already authenticated
|
||||
if ($auth.isAuthenticated) {
|
||||
goto('/');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
async function handleLogin() {
|
||||
if (!email || !password) {
|
||||
errorMessage = 'Por favor completa todos los campos';
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
isLoading = true;
|
||||
errorMessage = '';
|
||||
|
||||
|
||||
try {
|
||||
await auth.login({
|
||||
email,
|
||||
password,
|
||||
tenant_slug: 'aduanasoft-demo',
|
||||
tenant_slug: 'aduanasoft',
|
||||
totp_code: totpCode || undefined
|
||||
});
|
||||
|
||||
|
||||
toast.success('¡Bienvenido! Has iniciado sesión correctamente');
|
||||
goto('/');
|
||||
} catch (error: any) {
|
||||
console.error('Login error:', error);
|
||||
|
||||
|
||||
// Check if 2FA is required
|
||||
if (error.message.includes('two-factor') || error.message.includes('2FA')) {
|
||||
showTwoFactor = true;
|
||||
@@ -53,7 +53,7 @@
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
handleLogin();
|
||||
@@ -61,44 +61,50 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<svelte:head>
|
||||
<title>Acceso Admin - ServiceManager</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-950 p-4 font-sans">
|
||||
<div class="w-full max-w-5xl grid grid-cols-1 md:grid-cols-2 bg-white dark:bg-gray-900 rounded-lg shadow-xl overflow-hidden border border-gray-200 dark:border-gray-800">
|
||||
|
||||
<div
|
||||
class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-950 p-4 font-sans"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-5xl grid grid-cols-1 md:grid-cols-2 bg-white dark:bg-gray-900 rounded-lg shadow-xl overflow-hidden border border-gray-200 dark:border-gray-800"
|
||||
>
|
||||
<!-- Left Side: Internal Branding -->
|
||||
<div class="hidden md:flex flex-col justify-between p-12 bg-gray-900 text-white relative overflow-hidden">
|
||||
<div
|
||||
class="hidden md:flex flex-col justify-between p-12 bg-gray-900 text-white relative overflow-hidden"
|
||||
>
|
||||
<!-- Grid pattern overlay -->
|
||||
<div class="absolute inset-0 opacity-10" style="background-image: radial-gradient(white 1px, transparent 1px); background-size: 30px 30px;"></div>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 opacity-10"
|
||||
style="background-image: radial-gradient(white 1px, transparent 1px); background-size: 30px 30px;"
|
||||
/>
|
||||
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center space-x-3 mb-6">
|
||||
<div class="p-2 bg-blue-500/20 rounded border border-blue-500/30">
|
||||
<Icon name="server" class="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
<span class="text-sm font-mono tracking-wider text-blue-400">INTERNAL_ACCESS_V2</span>
|
||||
<div class="p-2 bg-blue-500/20 rounded border border-blue-500/30">
|
||||
<Icon name="server" className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
<span class="text-sm font-mono tracking-wider text-blue-400">INTERNAL_ACCESS_V2</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-bold tracking-tight mb-4">
|
||||
Panel de Administración
|
||||
</h1>
|
||||
|
||||
<h1 class="text-3xl font-bold tracking-tight mb-4">Panel de Administración</h1>
|
||||
<p class="text-gray-400 text-sm leading-relaxed max-w-sm">
|
||||
Plataforma de gestión de servicios, monitoreo de tickets y administración de usuarios. Acceso restringido únicamente a personal autorizado.
|
||||
Plataforma de gestión de servicios, monitoreo de tickets y administración de usuarios.
|
||||
Acceso restringido únicamente a personal autorizado.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 mt-12">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center space-x-3 text-xs text-gray-400 font-mono">
|
||||
<Icon name="check-circle" class="w-4 h-4 text-green-500" />
|
||||
<span>System Status: Operational</span>
|
||||
<Icon name="check-circle" className="w-4 h-4 text-green-500" />
|
||||
<span>System Status: Operational</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3 text-xs text-gray-400 font-mono">
|
||||
<Icon name="shield" class="w-4 h-4 text-blue-500" />
|
||||
<span>256-bit Encryption Enabled</span>
|
||||
<Icon name="shield" className="w-4 h-4 text-blue-500" />
|
||||
<span>256-bit Encryption Enabled</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,106 +112,129 @@
|
||||
|
||||
<!-- Right Side: Login Form -->
|
||||
<div class="p-8 md:p-12 flex flex-col justify-center">
|
||||
|
||||
<div class="max-w-sm mx-auto w-full">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">Iniciar Sesión</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Acceso al sistema central</p>
|
||||
</div>
|
||||
<div class="max-w-sm mx-auto w-full">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">Iniciar Sesión</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Acceso al sistema central</p>
|
||||
</div>
|
||||
|
||||
<form on:submit|preventDefault={handleLogin} class="space-y-5">
|
||||
{#if errorMessage}
|
||||
<div class="p-3 rounded-md bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-900 flex items-start gap-3">
|
||||
<Icon name="alert-triangle" class="w-5 h-5 text-red-600 dark:text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p class="text-sm text-red-600 dark:text-red-500">{errorMessage}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<form on:submit|preventDefault={handleLogin} class="space-y-5">
|
||||
{#if errorMessage}
|
||||
<div
|
||||
class="p-3 rounded-md bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-900 flex items-start gap-3"
|
||||
>
|
||||
<Icon
|
||||
name="alert-triangle"
|
||||
className="w-5 h-5 text-red-600 dark:text-red-500 flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<p class="text-sm text-red-600 dark:text-red-500">{errorMessage}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !showTwoFactor}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">Usuario / Correo</label>
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors">
|
||||
<Icon name="user" class="w-5 h-5" />
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
on:keydown={handleKeyDown}
|
||||
class="form-input w-full pl-10 py-2.5 bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all font-mono text-sm"
|
||||
placeholder="admin@aduanasoft.com"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if !showTwoFactor}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="email"
|
||||
class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1"
|
||||
>Usuario / Correo</label
|
||||
>
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors"
|
||||
>
|
||||
<Icon name="user" className="w-5 h-5" />
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
on:keydown={handleKeyDown}
|
||||
class="form-input w-full pl-10 py-2.5 bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all font-mono text-sm"
|
||||
placeholder="admin@aduanasoft.com"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">Clave de Acceso</label>
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors">
|
||||
<Icon name="lock" class="w-5 h-5" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
on:keydown={handleKeyDown}
|
||||
class="form-input w-full pl-10 py-2.5 bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all font-mono text-sm"
|
||||
placeholder="••••••••••••"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- 2FA Input -->
|
||||
<div class="bg-blue-50 dark:bg-blue-900/10 p-4 rounded-lg border border-blue-100 dark:border-blue-800/30">
|
||||
<label for="code" class="block text-xs font-semibold uppercase tracking-wider text-blue-800 dark:text-blue-300 mb-2 text-center">Verificación de Seguridad</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="code"
|
||||
type="text"
|
||||
bind:value={totpCode}
|
||||
on:keydown={handleKeyDown}
|
||||
class="form-input w-full py-3 rounded border-blue-300 dark:border-blue-700 focus:ring-blue-500 focus:border-blue-500 text-center tracking-[0.5em] font-mono text-lg bg-white dark:bg-gray-800"
|
||||
placeholder="000000"
|
||||
maxlength="6"
|
||||
required
|
||||
disabled={isLoading}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-blue-600 dark:text-blue-400 mt-2 text-center">
|
||||
Consulte su dispositivo autenticador
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<label
|
||||
for="password"
|
||||
class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1"
|
||||
>Clave de Acceso</label
|
||||
>
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors"
|
||||
>
|
||||
<Icon name="lock" className="w-5 h-5" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
on:keydown={handleKeyDown}
|
||||
class="form-input w-full pl-10 py-2.5 bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all font-mono text-sm"
|
||||
placeholder="••••••••••••"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- 2FA Input -->
|
||||
<div
|
||||
class="bg-blue-50 dark:bg-blue-900/10 p-4 rounded-lg border border-blue-100 dark:border-blue-800/30"
|
||||
>
|
||||
<label
|
||||
for="code"
|
||||
class="block text-xs font-semibold uppercase tracking-wider text-blue-800 dark:text-blue-300 mb-2 text-center"
|
||||
>Verificación de Seguridad</label
|
||||
>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="code"
|
||||
type="text"
|
||||
bind:value={totpCode}
|
||||
on:keydown={handleKeyDown}
|
||||
class="form-input w-full py-3 rounded border-blue-300 dark:border-blue-700 focus:ring-blue-500 focus:border-blue-500 text-center tracking-[0.5em] font-mono text-lg bg-white dark:bg-gray-800"
|
||||
placeholder="000000"
|
||||
maxlength="6"
|
||||
required
|
||||
disabled={isLoading}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-blue-600 dark:text-blue-400 mt-2 text-center">
|
||||
Consulte su dispositivo autenticador
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full flex justify-center py-2.5 px-4 rounded bg-gray-900 dark:bg-gray-700 text-white font-medium hover:bg-gray-800 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{#if isLoading}
|
||||
<Icon name="loader" class="w-4 h-4 animate-spin mr-2" />
|
||||
Autenticando...
|
||||
{:else}
|
||||
{showTwoFactor ? 'Verificar Token' : 'Entrar al Panel'}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800">
|
||||
<p class="text-[10px] text-gray-400 text-center uppercase tracking-widest">Aduanasoft Internal Systems © 2024</p>
|
||||
</div>
|
||||
<div class="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full flex justify-center py-2.5 px-4 rounded bg-gray-900 dark:bg-gray-700 text-white font-medium hover:bg-gray-800 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{#if isLoading}
|
||||
<Icon name="loader" className="w-4 h-4 animate-spin mr-2" />
|
||||
Autenticando...
|
||||
{:else}
|
||||
{showTwoFactor ? 'Verificar Token' : 'Entrar al Panel'}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800">
|
||||
<p class="text-[10px] text-gray-400 text-center uppercase tracking-widest">
|
||||
Aduanasoft Internal Systems © 2024
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import IssueModal from '$lib/components/IssueModal.svelte';
|
||||
import ParticipantsModal from '$lib/components/ParticipantsModal.svelte';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import { api } from '$lib/utils/api';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let showParticipants = false;
|
||||
let showIssueModal = false;
|
||||
let issues = [];
|
||||
let ticketId: string;
|
||||
let ticket = null;
|
||||
let comments = [];
|
||||
@@ -42,33 +47,37 @@
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
isLoading = true;
|
||||
try {
|
||||
const [ticketData, commentsData, attachmentsData, usersData] = await Promise.all([
|
||||
api.get(`/tickets/${ticketId}`),
|
||||
api.get(`/tickets/${ticketId}/comments`),
|
||||
api.get(`/tickets/${ticketId}/attachments`),
|
||||
api.get('/users/')
|
||||
]);
|
||||
ticket = ticketData;
|
||||
comments = commentsData;
|
||||
attachments = attachmentsData;
|
||||
users = usersData;
|
||||
} catch (e) {
|
||||
toast.error('Error cargando ticket: ' + (e.message || 'Error desconocido'));
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
isLoading = true;
|
||||
try {
|
||||
const [ticketData, commentsData, attachmentsData, usersData, issuesData] = await Promise.all([
|
||||
api.get(`/tickets/${ticketId}`),
|
||||
api.get(`/tickets/${ticketId}/comments`),
|
||||
api.get(`/tickets/${ticketId}/attachments`),
|
||||
api.get('/users/'),
|
||||
api.get(`/tickets/${ticketId}/issues`)
|
||||
]);
|
||||
ticket = ticketData;
|
||||
comments = commentsData;
|
||||
attachments = attachmentsData;
|
||||
users = usersData;
|
||||
issues = issuesData;
|
||||
} catch (e) {
|
||||
toast.error('Error cargando ticket: ' + (e.message || 'Error desconocido'));
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddComment() {
|
||||
if (!newComment.trim()) return;
|
||||
|
||||
isSubmittingComment = true;
|
||||
try {
|
||||
const comment = await api.post(`/tickets/${ticketId}/comments`, {
|
||||
content: newComment.trim(),
|
||||
is_internal: false
|
||||
const comment = await api.post(`/tickets/${ticketId}/issues`, {
|
||||
content: content.trim(),
|
||||
priority,
|
||||
tagged_user_ids: taggedUserIds
|
||||
|
||||
});
|
||||
comments = [...comments, comment];
|
||||
newComment = '';
|
||||
@@ -288,7 +297,9 @@
|
||||
{formatDate(comment.created_at)}
|
||||
</span>
|
||||
{#if comment.is_internal}
|
||||
<span class="bg-gray-50 text-red-700 text-xs px-2 py-0.5 rounded border border-red-200">
|
||||
<span
|
||||
class="bg-gray-50 text-red-700 text-xs px-2 py-0.5 rounded border border-red-200"
|
||||
>
|
||||
Interno
|
||||
</span>
|
||||
{/if}
|
||||
@@ -374,26 +385,80 @@
|
||||
<dd class="text-sm text-gray-900">{formatDate(ticket.updated_at)}</dd>
|
||||
</div>
|
||||
|
||||
<!-- Participantes -->
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-5 border-b border-gray-200 flex justify-between items-center">
|
||||
<h3 class="text-lg font-semibold text-gray-900">
|
||||
Asuntos {#if issues.length > 0}<span class="text-gray-400 font-normal"
|
||||
>({issues.length})</span
|
||||
>{/if}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (showIssueModal = true)}
|
||||
class="text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
Crear asunto
|
||||
</button>
|
||||
</div>
|
||||
{#if issues.length > 0}
|
||||
<div class="divide-y divide-gray-100">
|
||||
{#each issues as issue}
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<p class="text-sm text-gray-700 flex-1">{issue.content}</p>
|
||||
<span
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 shrink-0"
|
||||
>
|
||||
{issue.priority}
|
||||
</span>
|
||||
</div>
|
||||
{#if issue.tagged_users?.length > 0}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{#each issue.tagged_users as u}
|
||||
<span class="text-xs bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full">
|
||||
{u.full_name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<p class="text-xs text-gray-400 mt-1">{issue.created_by_name}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="px-6 py-4 text-sm text-gray-500">No hay asuntos creados.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- SLA Information -->
|
||||
{#if ticket.sla_response_due || ticket.sla_resolution_due}
|
||||
<div class="pt-4 border-t border-gray-200">
|
||||
<h4 class="text-sm font-semibold text-gray-900 mb-3">SLA (Acuerdos de Nivel de Servicio)</h4>
|
||||
|
||||
<h4 class="text-sm font-semibold text-gray-900 mb-3">
|
||||
SLA (Acuerdos de Nivel de Servicio)
|
||||
</h4>
|
||||
|
||||
{#if ticket.sla_response_due}
|
||||
<div class="mb-3">
|
||||
<dt class="text-xs font-medium text-gray-500">Tiempo de Respuesta</dt>
|
||||
<dd class="text-sm text-gray-900 mt-1">
|
||||
{formatDate(ticket.sla_response_due)}
|
||||
{#if new Date(ticket.sla_response_due) < new Date() && !ticket.sla_response_met}
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-red-700 border border-red-200">
|
||||
<span
|
||||
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-red-700 border border-red-200"
|
||||
>
|
||||
Vencido
|
||||
</span>
|
||||
{:else if ticket.sla_response_met}
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-green-700 border border-green-200">
|
||||
<span
|
||||
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-green-700 border border-green-200"
|
||||
>
|
||||
Cumplido
|
||||
</span>
|
||||
{:else}
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-blue-700 border border-blue-200">
|
||||
<span
|
||||
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-blue-700 border border-blue-200"
|
||||
>
|
||||
En plazo
|
||||
</span>
|
||||
{/if}
|
||||
@@ -407,15 +472,21 @@
|
||||
<dd class="text-sm text-gray-900 mt-1">
|
||||
{formatDate(ticket.sla_resolution_due)}
|
||||
{#if new Date(ticket.sla_resolution_due) < new Date() && !ticket.sla_resolution_met}
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-red-700 border border-red-200">
|
||||
<span
|
||||
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-red-700 border border-red-200"
|
||||
>
|
||||
Vencido
|
||||
</span>
|
||||
{:else if ticket.sla_resolution_met}
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-green-700 border border-green-200">
|
||||
<span
|
||||
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-green-700 border border-green-200"
|
||||
>
|
||||
Cumplido
|
||||
</span>
|
||||
{:else}
|
||||
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-blue-700 border border-blue-200">
|
||||
<span
|
||||
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-blue-700 border border-blue-200"
|
||||
>
|
||||
En plazo
|
||||
</span>
|
||||
{/if}
|
||||
@@ -429,4 +500,22 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showParticipants && ticket}
|
||||
<ParticipantsModal
|
||||
ticketId={ticket.id}
|
||||
createdBy={ticket.created_by}
|
||||
on:close={() => (showParticipants = false)}
|
||||
/>
|
||||
{/if}
|
||||
{#if showIssueModal && ticket}
|
||||
<IssueModal
|
||||
ticketId={ticket.id}
|
||||
{users}
|
||||
on:close={() => (showIssueModal = false)}
|
||||
on:created={() => {
|
||||
showIssueModal = false;
|
||||
loadData();
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
BIN
frontend-internal/static/favicon.png
Normal file
BIN
frontend-internal/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -1,28 +1,36 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
watch: {
|
||||
usePolling: true,
|
||||
interval: 500
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.PUBLIC_API_URL || 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
},
|
||||
preview: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0'
|
||||
},
|
||||
build: {
|
||||
target: 'esnext'
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: parseInt(process.env.PORT || '3001'),
|
||||
host: '0.0.0.0',
|
||||
watch: {
|
||||
usePolling: true,
|
||||
interval: 500
|
||||
},
|
||||
hmr: {
|
||||
host: 'localhost',
|
||||
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3001')
|
||||
},
|
||||
fs: {
|
||||
allow: ['/app', '.'],
|
||||
strict: false
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.PUBLIC_API_URL || 'http://backend:8000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
preview: {
|
||||
port: parseInt(process.env.PORT || '3001'),
|
||||
host: '0.0.0.0'
|
||||
},
|
||||
build: {
|
||||
target: 'esnext'
|
||||
}
|
||||
});
|
||||
BIN
migrations.sql
Normal file
BIN
migrations.sql
Normal file
Binary file not shown.
130
participants_router.py
Normal file
130
participants_router.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
import uuid
|
||||
|
||||
from app.api import deps
|
||||
from app.core.database import get_db
|
||||
from app.models.ticket import Ticket, TicketParticipant
|
||||
from app.models.user import User
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class ParticipantAdd(BaseModel):
|
||||
user_id: uuid.UUID
|
||||
|
||||
class ParticipantResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
ticket_id: uuid.UUID
|
||||
role: str
|
||||
user_email: str
|
||||
user_name: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@router.get("/{ticket_id}/participants", response_model=list[ParticipantResponse])
|
||||
async def list_participants(
|
||||
ticket_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_user)
|
||||
):
|
||||
result = await db.execute(
|
||||
select(TicketParticipant)
|
||||
.where(TicketParticipant.ticket_id == ticket_id)
|
||||
.options(selectinload(TicketParticipant.user))
|
||||
)
|
||||
participants = result.scalars().all()
|
||||
return [
|
||||
ParticipantResponse(
|
||||
id=p.id,
|
||||
user_id=p.user_id,
|
||||
ticket_id=p.ticket_id,
|
||||
role=p.role,
|
||||
user_email=p.user.email,
|
||||
user_name=f"{p.user.first_name or ''} {p.user.last_name or ''}".strip()
|
||||
)
|
||||
for p in participants
|
||||
]
|
||||
|
||||
@router.post("/{ticket_id}/participants", response_model=ParticipantResponse)
|
||||
async def add_participant(
|
||||
ticket_id: uuid.UUID,
|
||||
data: ParticipantAdd,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_user)
|
||||
):
|
||||
# Verificar que el ticket existe
|
||||
ticket_result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
|
||||
ticket = ticket_result.scalar_one_or_none()
|
||||
if not ticket:
|
||||
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||
|
||||
# Solo el creador puede agregar participantes
|
||||
if ticket.created_by != current_user.id and not current_user.role.is_global:
|
||||
raise HTTPException(status_code=403, detail="Only the ticket creator can add participants")
|
||||
|
||||
# Verificar que el usuario existe
|
||||
user_result = await db.execute(select(User).where(User.id == data.user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Verificar que no sea duplicado
|
||||
existing = await db.execute(
|
||||
select(TicketParticipant).where(
|
||||
TicketParticipant.ticket_id == ticket_id,
|
||||
TicketParticipant.user_id == data.user_id
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="User is already a participant")
|
||||
|
||||
participant = TicketParticipant(
|
||||
ticket_id=ticket_id,
|
||||
user_id=data.user_id,
|
||||
role="participant"
|
||||
)
|
||||
db.add(participant)
|
||||
await db.commit()
|
||||
await db.refresh(participant)
|
||||
|
||||
return ParticipantResponse(
|
||||
id=participant.id,
|
||||
user_id=participant.user_id,
|
||||
ticket_id=participant.ticket_id,
|
||||
role=participant.role,
|
||||
user_email=user.email,
|
||||
user_name=f"{user.first_name or ''} {user.last_name or ''}".strip()
|
||||
)
|
||||
|
||||
@router.delete("/{ticket_id}/participants/{user_id}", status_code=204)
|
||||
async def remove_participant(
|
||||
ticket_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_user)
|
||||
):
|
||||
ticket_result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
|
||||
ticket = ticket_result.scalar_one_or_none()
|
||||
if not ticket:
|
||||
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||
|
||||
if ticket.created_by != current_user.id and not current_user.role.is_global:
|
||||
raise HTTPException(status_code=403, detail="Only the ticket creator can remove participants")
|
||||
|
||||
result = await db.execute(
|
||||
select(TicketParticipant).where(
|
||||
TicketParticipant.ticket_id == ticket_id,
|
||||
TicketParticipant.user_id == user_id
|
||||
)
|
||||
)
|
||||
participant = result.scalar_one_or_none()
|
||||
if not participant:
|
||||
raise HTTPException(status_code=404, detail="Participant not found")
|
||||
|
||||
await db.delete(participant)
|
||||
await db.commit()
|
||||
43
scripts/README-powershell.md
Normal file
43
scripts/README-powershell.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Scripts PowerShell en ServiceManagerWeb
|
||||
|
||||
## security-test-data.ps1
|
||||
- **Propósito:** Genera o limpia datos de prueba para análisis de seguridad.
|
||||
- **Uso:**
|
||||
- `. ools\security-test-data.ps1 generar` → Genera datos de prueba.
|
||||
- `. ools\security-test-data.ps1 limpiar` → Limpia los datos de prueba.
|
||||
- **Funcionamiento:** Verifica que el contenedor backend esté corriendo y ejecuta el script Python correspondiente dentro del contenedor.
|
||||
|
||||
## test_critical_sync.ps1
|
||||
- **Propósito:** Verifica la sincronización de incidentes críticos entre Auditoría y Seguridad.
|
||||
- **Pasos:**
|
||||
1. Login como admin y obtiene token.
|
||||
2. Consulta estadísticas del módulo Auditoría.
|
||||
3. Consulta estadísticas del módulo Seguridad.
|
||||
- **Resultado:** Muestra si los incidentes críticos están sincronizados.
|
||||
|
||||
## test_tenant_update.ps1
|
||||
- **Propósito:** Prueba el endpoint de actualización de tenants.
|
||||
- **Pasos:**
|
||||
1. Login como admin.
|
||||
2. Obtiene lista de tenants.
|
||||
3. Actualiza el tenant (ejemplo: teléfono y status).
|
||||
- **Resultado:** Verifica que la actualización funcione correctamente.
|
||||
|
||||
## test_manual.ps1
|
||||
- **Propósito:** Pruebas manuales de endpoints clave.
|
||||
- **Pasos:**
|
||||
1. Login y obtención de token.
|
||||
2. Listar categorías.
|
||||
3. Crear ticket con SLA automático.
|
||||
- **Resultado:** Permite validar manualmente el flujo de API.
|
||||
|
||||
## test_frontend_integration.ps1
|
||||
- **Propósito:** Verifica la integración entre frontend y backend.
|
||||
- **Pasos:**
|
||||
1. Verifica servicios Docker.
|
||||
2. Login y obtención de token.
|
||||
3. Verifica tickets con SLA.
|
||||
- **Resultado:** Confirma que el frontend puede consumir correctamente el backend.
|
||||
|
||||
---
|
||||
**Recomendación:** Conserva estos scripts para testing e integración. Documenta cualquier script nuevo siguiendo este formato.
|
||||
29
scripts/README-uso-rapido.md
Normal file
29
scripts/README-uso-rapido.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Guía rápida de scripts esenciales
|
||||
|
||||
## 1. setup-dev.sh
|
||||
Configura el entorno de desarrollo completo (servicios, dependencias).
|
||||
|
||||
## 2. seed_data.py
|
||||
Inicializa categorías, sistemas y usuarios demo.
|
||||
|
||||
## 3. seed_tickets.py
|
||||
Genera tickets de prueba (requiere seed_data.py ejecutado).
|
||||
|
||||
## 4. run_tests.sh
|
||||
Ejecuta la suite de tests de integración.
|
||||
|
||||
## 5. reset_passwords.py
|
||||
Resetea contraseñas de usuarios demo para pruebas de login.
|
||||
|
||||
## 6. generate_sla_test_data.py
|
||||
Crea tickets con diferentes estados de SLA.
|
||||
|
||||
## 7. generate_security_test_data.py
|
||||
Genera logs de auditoría de prueba.
|
||||
|
||||
## 8. check_tenants.py
|
||||
Lista y audita los tenants existentes.
|
||||
|
||||
---
|
||||
|
||||
**Recomendación:** Ejecuta los scripts en este orden para tener un entorno funcional y datos de prueba completos. Elimina los scripts de debugging/manuales si no los necesitas para troubleshooting avanzado.
|
||||
@@ -1,7 +0,0 @@
|
||||
from app.models.ticket import Ticket
|
||||
from app.models import relationships # ensure relationships are loaded
|
||||
from sqlalchemy import inspect
|
||||
|
||||
mapper = inspect(Ticket)
|
||||
print("Relationships:", [r.key for r in mapper.relationships])
|
||||
print("Columns:", [c.key for c in mapper.columns])
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Check SLA state of tickets and categories"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/app')
|
||||
os.chdir('/app')
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
async def run():
|
||||
database_url = os.environ.get('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager')
|
||||
engine = create_async_engine(database_url)
|
||||
|
||||
async with engine.connect() as c:
|
||||
print("=== CATEGORIES SLA HOURS ===")
|
||||
r = await c.execute(text(
|
||||
"SELECT name, sla_response_hours, sla_resolution_hours "
|
||||
"FROM ticket_categories "
|
||||
"ORDER BY name"
|
||||
))
|
||||
for row in r.fetchall():
|
||||
print(f" {row[0]}: response={row[1]}h, resolution={row[2]}h")
|
||||
|
||||
print("\n=== TICKETS SLA DATES ===")
|
||||
r2 = await c.execute(text(
|
||||
"SELECT ticket_number, category_id, sla_response_due, sla_resolution_due "
|
||||
"FROM tickets "
|
||||
"ORDER BY created_at "
|
||||
"LIMIT 10"
|
||||
))
|
||||
for row in r2.fetchall():
|
||||
print(f" {row[0]}: cat={str(row[1])[:8] if row[1] else 'None'}, sla_resp={row[2]}, sla_res={row[3]}")
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
asyncio.run(run())
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Debug: check if ticket's category_id maps to a valid category and what tenant it belongs to"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/app')
|
||||
os.chdir('/app')
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
async def run():
|
||||
database_url = os.environ.get('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager')
|
||||
engine = create_async_engine(database_url)
|
||||
|
||||
async with engine.connect() as c:
|
||||
# Check tickets and their category names via direct JOIN
|
||||
r = await c.execute(text("""
|
||||
SELECT t.ticket_number, t.category_id,
|
||||
cat.name as category_name, cat.tenant_id as cat_tenant,
|
||||
t.tenant_id as ticket_tenant
|
||||
FROM tickets t
|
||||
LEFT JOIN ticket_categories cat ON cat.id = t.category_id
|
||||
WHERE t.category_id IS NOT NULL
|
||||
LIMIT 10
|
||||
"""))
|
||||
print("=== TICKET -> CATEGORY JOIN ===")
|
||||
for row in r.fetchall():
|
||||
match = "✓ SAME TENANT" if row[3] == row[4] else "✗ DIFFERENT TENANT"
|
||||
print(f" {row[0]}: cat_id={str(row[1])[:8]}, cat_name={row[2]}, {match}")
|
||||
|
||||
# Check what tenant aduanasoft-demo is
|
||||
r2 = await c.execute(text("SELECT id, slug FROM tenants WHERE slug='aduanasoft-demo'"))
|
||||
tenant = r2.fetchone()
|
||||
print(f"\nTenant aduanasoft-demo: {tenant[0] if tenant else 'NOT FOUND'}")
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
asyncio.run(run())
|
||||
@@ -1,49 +0,0 @@
|
||||
"""Debug: check SQLAlchemy ORM category loading"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/app')
|
||||
os.chdir('/app')
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker, selectinload
|
||||
from sqlalchemy import select
|
||||
from app.models.ticket import Ticket
|
||||
from app.models.category import Category
|
||||
|
||||
|
||||
async def run():
|
||||
database_url = os.environ.get('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager')
|
||||
engine = create_async_engine(database_url, echo=True)
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session() as session:
|
||||
# Test selectinload
|
||||
result = await session.execute(
|
||||
select(Ticket)
|
||||
.options(selectinload(Ticket.category))
|
||||
.where(Ticket.category_id != None)
|
||||
.limit(3)
|
||||
)
|
||||
tickets = result.scalars().all()
|
||||
|
||||
print(f"\n=== ORM RESULTS ({len(tickets)} tickets) ===")
|
||||
for t in tickets:
|
||||
print(f" {t.ticket_number}: category_id={t.category_id}, category={t.category}")
|
||||
if t.category:
|
||||
print(f" -> category.name={t.category.name}")
|
||||
else:
|
||||
print(f" -> category is None!")
|
||||
|
||||
# Check if Category model can be queried directly
|
||||
r2 = await session.execute(select(Category).limit(3))
|
||||
cats = r2.scalars().all()
|
||||
print(f"\n=== DIRECT CATEGORY QUERY ({len(cats)} categories) ===")
|
||||
for c in cats:
|
||||
print(f" id={c.id}, name={c.name}")
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
asyncio.run(run())
|
||||
22
scripts/reset-fabrica.sh
Normal file
22
scripts/reset-fabrica.sh
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
# Script para resetear datos a estado de fábrica (solo datos, no afecta estructura ni funcionalidad)
|
||||
|
||||
set -e
|
||||
|
||||
echo "🧹 Reseteando datos del sistema..."
|
||||
|
||||
# 1. Eliminar datos de tickets, comentarios, logs, auditoría, uploads
|
||||
# (Ejemplo: usando comandos SQL directos desde el contenedor)
|
||||
docker-compose exec backend psql -U servicemanager -d servicemanager -c "TRUNCATE tickets, ticket_comments, audit_logs, uploads RESTART IDENTITY CASCADE;"
|
||||
|
||||
echo "✅ Datos eliminados."
|
||||
|
||||
# 2. Volver a poblar datos demo
|
||||
|
||||
docker-compose exec backend python scripts/seed_data.py
|
||||
docker-compose exec backend python scripts/seed_tickets.py
|
||||
docker-compose exec backend python scripts/generate_sla_test_data.py
|
||||
docker-compose exec backend python scripts/generate_security_test_data.py
|
||||
docker-compose exec backend python scripts/reset_passwords.py
|
||||
|
||||
echo "🎉 Sistema restaurado a estado de fábrica demo."
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user