From de5b6feef4e5fa6c73968d3c5ed50d47f7d1fe3e Mon Sep 17 00:00:00 2001 From: ernestohc21 Date: Mon, 12 Jan 2026 08:17:17 -0700 Subject: [PATCH] Initial commit --- .env.example | 110 ++++ .github/copilot-instructions.md | 101 ++++ .gitignore | 32 + README.md | 142 +++++ backend/README.md | 171 ++++++ backend/add_columns.py | 22 + backend/app/api/deps.py | 57 ++ backend/app/api/v1/endpoints/auth.py | 297 ++++++++++ backend/app/api/v1/endpoints/categories.py | 55 ++ backend/app/api/v1/endpoints/health.py | 88 +++ backend/app/api/v1/endpoints/systems.py | 53 ++ backend/app/api/v1/endpoints/tenants.py | 94 +++ backend/app/api/v1/endpoints/users.py | 68 +++ backend/app/api/v1/router.py | 47 ++ backend/app/core/config.py | 142 +++++ backend/app/core/database.py | 94 +++ backend/app/core/logging.py | 145 +++++ backend/app/core/security.py | 271 +++++++++ backend/app/main.py | 201 +++++++ backend/app/middleware/correlation_id.py | 46 ++ backend/app/middleware/tenant.py | 72 +++ backend/app/models/category.py | 28 + backend/app/models/system.py | 25 + backend/app/models/tenant.py | 68 +++ backend/app/models/ticket.py | 65 +++ backend/app/models/user.py | 135 +++++ backend/fix_password_script.py | 41 ++ backend/pyproject.toml | 181 ++++++ backend/requirements.txt | 86 +++ db/schema.sql | 500 ++++++++++++++++ docker-compose.yml | 294 ++++++++++ docker/Dockerfile.backend | 43 ++ docker/Dockerfile.frontend | 42 ++ docker/Dockerfile.worker | 35 ++ docker/nginx/default.conf | 144 +++++ docker/nginx/nginx.conf | 65 +++ docs/api-contract.md | 547 ++++++++++++++++++ docs/database-schema.md | 96 +++ frontend-client/.eslintrc.json | 42 ++ frontend-client/.prettierignore | 11 + frontend-client/.prettierrc | 22 + frontend-client/package.json | 47 ++ frontend-client/postcss.config.js | 6 + frontend-client/src/app.css | 183 ++++++ frontend-client/src/app.html | 30 + .../src/lib/components/Header.svelte | 130 +++++ .../src/lib/components/Icon.svelte | 39 ++ .../src/lib/components/TicketCard.svelte | 124 ++++ .../src/lib/components/Toast.svelte | 117 ++++ frontend-client/src/lib/stores/app.ts | 80 +++ frontend-client/src/lib/stores/auth.ts | 139 +++++ frontend-client/src/lib/stores/tickets.ts | 272 +++++++++ frontend-client/src/lib/stores/toast.ts | 75 +++ frontend-client/src/routes/+layout.svelte | 35 ++ frontend-client/src/routes/+page.svelte | 178 ++++++ frontend-client/src/routes/login/+page.svelte | 244 ++++++++ .../src/routes/profile/+page.svelte | 392 +++++++++++++ .../src/routes/tickets/+page.svelte | 270 +++++++++ .../src/routes/tickets/[id]/+page.svelte | 501 ++++++++++++++++ .../src/routes/tickets/new/+page.svelte | 241 ++++++++ frontend-client/static/images/Icono AS(1).png | Bin 0 -> 16944 bytes .../static/images/Logo AS 192px -192px(1).png | Bin 0 -> 17761 bytes .../images/Logo AS 512px - 512px(1).png | Bin 0 -> 51052 bytes .../static/images/Logo AS blanco(1).png | Bin 0 -> 48315 bytes frontend-client/static/images/SOPORTE.webp | Bin 0 -> 48978 bytes frontend-client/svelte.config.js | 25 + frontend-client/tailwind.config.js | 101 ++++ frontend-client/vite.config.js | 24 + frontend-internal/.eslintrc.json | 42 ++ frontend-internal/.prettierignore | 11 + frontend-internal/.prettierrc | 22 + frontend-internal/package.json | 49 ++ frontend-internal/postcss.config.js | 6 + frontend-internal/src/app.css | 225 +++++++ frontend-internal/src/app.html | 16 + .../src/lib/components/Header.svelte | 89 +++ .../src/lib/components/Icon.svelte | 43 ++ .../src/lib/components/Modal.svelte | 44 ++ .../src/lib/components/Sidebar.svelte | 153 +++++ .../src/lib/components/Toast.svelte | 117 ++++ frontend-internal/src/lib/stores/auth.ts | 215 +++++++ frontend-internal/src/lib/stores/toast.ts | 75 +++ frontend-internal/src/lib/utils/api.ts | 73 +++ frontend-internal/src/routes/+layout.svelte | 53 ++ frontend-internal/src/routes/+page.svelte | 98 ++++ .../src/routes/categories/+page.svelte | 184 ++++++ .../src/routes/login/+page.svelte | 211 +++++++ .../src/routes/systems/+page.svelte | 147 +++++ .../src/routes/tenants/+page.svelte | 157 +++++ .../src/routes/users/+page.svelte | 231 ++++++++ frontend-internal/svelte.config.js | 25 + frontend-internal/tailwind.config.js | 23 + frontend-internal/vite.config.js | 24 + scripts/README.md | 104 ++++ scripts/setup-dev.sh | 165 ++++++ workers/README.md | 279 +++++++++ workers/app/celery.py | 129 +++++ workers/app/core/config.py | 117 ++++ workers/app/core/logging.py | 122 ++++ workers/app/tasks/email_tasks.py | 404 +++++++++++++ workers/app/tasks/maintenance_tasks.py | 371 ++++++++++++ workers/app/tasks/notification_tasks.py | 469 +++++++++++++++ workers/app/tasks/sla_tasks.py | 327 +++++++++++ workers/requirements.txt | 74 +++ 104 files changed, 12925 insertions(+) create mode 100644 .env.example create mode 100644 .github/copilot-instructions.md create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/README.md create mode 100644 backend/add_columns.py create mode 100644 backend/app/api/deps.py create mode 100644 backend/app/api/v1/endpoints/auth.py create mode 100644 backend/app/api/v1/endpoints/categories.py create mode 100644 backend/app/api/v1/endpoints/health.py create mode 100644 backend/app/api/v1/endpoints/systems.py create mode 100644 backend/app/api/v1/endpoints/tenants.py create mode 100644 backend/app/api/v1/endpoints/users.py create mode 100644 backend/app/api/v1/router.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/database.py create mode 100644 backend/app/core/logging.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/main.py create mode 100644 backend/app/middleware/correlation_id.py create mode 100644 backend/app/middleware/tenant.py create mode 100644 backend/app/models/category.py create mode 100644 backend/app/models/system.py create mode 100644 backend/app/models/tenant.py create mode 100644 backend/app/models/ticket.py create mode 100644 backend/app/models/user.py create mode 100644 backend/fix_password_script.py create mode 100644 backend/pyproject.toml create mode 100644 backend/requirements.txt create mode 100644 db/schema.sql create mode 100644 docker-compose.yml create mode 100644 docker/Dockerfile.backend create mode 100644 docker/Dockerfile.frontend create mode 100644 docker/Dockerfile.worker create mode 100644 docker/nginx/default.conf create mode 100644 docker/nginx/nginx.conf create mode 100644 docs/api-contract.md create mode 100644 docs/database-schema.md create mode 100644 frontend-client/.eslintrc.json create mode 100644 frontend-client/.prettierignore create mode 100644 frontend-client/.prettierrc create mode 100644 frontend-client/package.json create mode 100644 frontend-client/postcss.config.js create mode 100644 frontend-client/src/app.css create mode 100644 frontend-client/src/app.html create mode 100644 frontend-client/src/lib/components/Header.svelte create mode 100644 frontend-client/src/lib/components/Icon.svelte create mode 100644 frontend-client/src/lib/components/TicketCard.svelte create mode 100644 frontend-client/src/lib/components/Toast.svelte create mode 100644 frontend-client/src/lib/stores/app.ts create mode 100644 frontend-client/src/lib/stores/auth.ts create mode 100644 frontend-client/src/lib/stores/tickets.ts create mode 100644 frontend-client/src/lib/stores/toast.ts create mode 100644 frontend-client/src/routes/+layout.svelte create mode 100644 frontend-client/src/routes/+page.svelte create mode 100644 frontend-client/src/routes/login/+page.svelte create mode 100644 frontend-client/src/routes/profile/+page.svelte create mode 100644 frontend-client/src/routes/tickets/+page.svelte create mode 100644 frontend-client/src/routes/tickets/[id]/+page.svelte create mode 100644 frontend-client/src/routes/tickets/new/+page.svelte create mode 100644 frontend-client/static/images/Icono AS(1).png create mode 100644 frontend-client/static/images/Logo AS 192px -192px(1).png create mode 100644 frontend-client/static/images/Logo AS 512px - 512px(1).png create mode 100644 frontend-client/static/images/Logo AS blanco(1).png create mode 100644 frontend-client/static/images/SOPORTE.webp create mode 100644 frontend-client/svelte.config.js create mode 100644 frontend-client/tailwind.config.js create mode 100644 frontend-client/vite.config.js create mode 100644 frontend-internal/.eslintrc.json create mode 100644 frontend-internal/.prettierignore create mode 100644 frontend-internal/.prettierrc create mode 100644 frontend-internal/package.json create mode 100644 frontend-internal/postcss.config.js create mode 100644 frontend-internal/src/app.css create mode 100644 frontend-internal/src/app.html create mode 100644 frontend-internal/src/lib/components/Header.svelte create mode 100644 frontend-internal/src/lib/components/Icon.svelte create mode 100644 frontend-internal/src/lib/components/Modal.svelte create mode 100644 frontend-internal/src/lib/components/Sidebar.svelte create mode 100644 frontend-internal/src/lib/components/Toast.svelte create mode 100644 frontend-internal/src/lib/stores/auth.ts create mode 100644 frontend-internal/src/lib/stores/toast.ts create mode 100644 frontend-internal/src/lib/utils/api.ts create mode 100644 frontend-internal/src/routes/+layout.svelte create mode 100644 frontend-internal/src/routes/+page.svelte create mode 100644 frontend-internal/src/routes/categories/+page.svelte create mode 100644 frontend-internal/src/routes/login/+page.svelte create mode 100644 frontend-internal/src/routes/systems/+page.svelte create mode 100644 frontend-internal/src/routes/tenants/+page.svelte create mode 100644 frontend-internal/src/routes/users/+page.svelte create mode 100644 frontend-internal/svelte.config.js create mode 100644 frontend-internal/tailwind.config.js create mode 100644 frontend-internal/vite.config.js create mode 100644 scripts/README.md create mode 100644 scripts/setup-dev.sh create mode 100644 workers/README.md create mode 100644 workers/app/celery.py create mode 100644 workers/app/core/config.py create mode 100644 workers/app/core/logging.py create mode 100644 workers/app/tasks/email_tasks.py create mode 100644 workers/app/tasks/maintenance_tasks.py create mode 100644 workers/app/tasks/notification_tasks.py create mode 100644 workers/app/tasks/sla_tasks.py create mode 100644 workers/requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..716ae29 --- /dev/null +++ b/.env.example @@ -0,0 +1,110 @@ +# ServiceManagerWeb - Variables de Entorno +# Copia este archivo como .env y configura los valores apropiados + +# =================================== +# CONFIGURACIÓN GENERAL +# =================================== +ENVIRONMENT=development +DEBUG=true +SECRET_KEY=your-super-secret-key-change-in-production-min-32-chars +API_VERSION=v1 +CORS_ORIGINS=http://localhost:3000,http://localhost:3001 + +# =================================== +# BASE DE DATOS +# =================================== +# PostgreSQL +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_DB=servicemanager +POSTGRES_USER=servicemanager +POSTGRES_PASSWORD=servicemanager123 +DATABASE_URL=postgresql+asyncpg://servicemanager:servicemanager123@postgres:5432/servicemanager + +# =================================== +# REDIS Y CELERY +# =================================== +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 +REDIS_URL=redis://redis:6379/0 +CELERY_BROKER_URL=redis://redis:6379/0 +CELERY_RESULT_BACKEND=redis://redis:6379/0 + +# =================================== +# AUTENTICACIÓN JWT +# =================================== +JWT_SECRET_KEY=jwt-secret-key-change-in-production-min-32-chars +JWT_ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=60 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# =================================== +# EMAIL / NOTIFICACIONES +# =================================== +# SMTP Settings +SMTP_HOST=mailhog +SMTP_PORT=1025 +SMTP_USER= +SMTP_PASSWORD= +SMTP_USE_TLS=false +SMTP_USE_SSL=false + +# Email por defecto +DEFAULT_FROM_EMAIL=noreply@servicemanager.local +DEFAULT_FROM_NAME=ServiceManager + +# =================================== +# ARCHIVOS Y STORAGE +# =================================== +# Configuración de uploads +MAX_UPLOAD_SIZE_MB=10 +ALLOWED_FILE_EXTENSIONS=pdf,jpg,jpeg,png,doc,docx,xls,xlsx,txt,zip +UPLOAD_PATH=/app/uploads + +# =================================== +# SEGURIDAD +# =================================== +# Rate limiting +RATE_LIMIT_ENABLED=true +RATE_LIMIT_AUTH=10/minute +RATE_LIMIT_API=100/minute +RATE_LIMIT_UPLOAD=5/minute + +# Password hashing +PASSWORD_MIN_LENGTH=8 +ARGON2_TIME_COST=3 +ARGON2_MEMORY_COST=65536 +ARGON2_PARALLELISM=4 + +# =================================== +# LOGGING +# =================================== +LOG_LEVEL=INFO +LOG_FORMAT=json +LOG_FILE=/app/logs/app.log + +# =================================== +# FRONTEND URLS +# =================================== +CLIENT_FRONTEND_URL=http://localhost:3000 +INTERNAL_FRONTEND_URL=http://localhost:3001 +API_BASE_URL=http://localhost:8000 + +# =================================== +# HEALTH CHECKS +# =================================== +HEALTH_CHECK_TIMEOUT=30 + +# =================================== +# DEVELOPMENT ONLY +# =================================== +# Adminer (DB admin interface) +ADMINER_ENABLED=true + +# MailHog (email testing) +MAILHOG_ENABLED=true + +# Seed data +LOAD_SAMPLE_DATA=true \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..6e22b3b --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,101 @@ +# ServiceManagerWeb - Mesa de Ayuda B2B Copilot Instructions + +Este proyecto es un sistema multi-tenant de Mesa de Ayuda/Soporte Técnico empresarial. + +## Contexto del Proyecto + +- **Empresa**: Aduanasoft (B2B) +- **Sistema**: Mesa de Ayuda multi-tenant +- **Arquitectura**: Modular Monolith con Clean Architecture +- **Target**: MVP enterprise-grade + +## Stack Tecnológico + +### Backend +- Python FastAPI (async) +- Pydantic v2 para validación +- SQLAlchemy 2.0 (async ORM) +- PostgreSQL como base de datos principal +- Redis para cache y broker Celery +- Celery para tareas asíncronas +- Alembic para migraciones +- Argon2/Bcrypt para hash de passwords +- PyJWT para autenticación + +### Frontend +- SvelteKit + TypeScript +- Dos aplicaciones: cliente e interna +- TailwindCSS para estilos +- Zod para validación del lado cliente + +### DevOps +- Docker + Docker Compose +- Nginx como reverse proxy +- Variables de entorno para configuración +- Healthchecks para servicios + +## Dominios del Sistema + +1. **auth**: Autenticación, usuarios, roles, 2FA +2. **tenants**: Multi-tenancy, organizaciones cliente +3. **tickets**: Core del sistema - tickets, estados, SLAs +4. **notifications**: Email, plantillas, comunicaciones +5. **audit**: Bitácora de acciones para compliance + +## Roles de Usuario + +- **ADMIN**: Control total (usuarios internos) +- **SUPPORT_MANAGER**: Gestión equipos y SLAs +- **AGENT**: Atención de tickets +- **AUDITOR**: Solo lectura para auditoría +- **CLIENT_ADMIN**: Gestión organización cliente +- **CLIENT_USER**: Creación/seguimiento tickets + +## Reglas de Desarrollo + +### Seguridad +- Siempre validar inputs con Pydantic +- Rate limiting en endpoints críticos +- Sanitizar archivos adjuntos +- Correlation ID en logs +- CORS restrictivo + +### Código +- Clean Architecture por dominios +- Async/await en toda la aplicación +- Type hints obligatorios +- Docstrings en funciones públicas +- Tests unitarios + integración + +### Base de Datos +- Migrations solo con Alembic +- Constraints a nivel de BD +- Índices para queries frecuentes +- Soft deletes cuando aplique + +### API +- OpenAPI bien documentado +- Versionado con prefijo /v1/ +- Paginación en listados +- Responses consistentes + +## Comandos Útiles + +```bash +# Setup inicial +docker-compose up -d +alembic upgrade head + +# Desarrollo backend +uvicorn app.main:app --reload + +# Testing +pytest --cov=app tests/ + +# Linting +ruff check . --fix +black . +mypy . +``` + +Cuando trabajes en este proyecto, siempre considera la naturaleza multi-tenant y empresarial del sistema. \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ff236d --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +venv/ +.venv/ +.env + +# Node +node_modules/ +dist/ +.svelte-kit/ +build/ +.npmrc + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ + +# Logs +logs/ +*.log + +# Docker +docker-compose.override.yml + +# Uploads +uploads/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b040355 --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# 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 +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 +``` + +## 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 \ No newline at end of file diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..dcbb26d --- /dev/null +++ b/backend/README.md @@ -0,0 +1,171 @@ +# ServiceManagerWeb Backend + +FastAPI backend para el sistema de Mesa de Ayuda B2B multi-tenant. + +## Estructura + +``` +backend/ +├── app/ +│ ├── main.py # FastAPI app principal +│ ├── core/ # Configuración y utilidades core +│ │ ├── config.py # Configuración con Pydantic Settings +│ │ ├── database.py # SQLAlchemy async setup +│ │ ├── security.py # JWT, hashing, 2FA +│ │ └── logging.py # Structured logging +│ ├── models/ # Modelos SQLAlchemy +│ │ ├── tenant.py # Modelo de tenant (multi-tenancy) +│ │ ├── user.py # Modelo de usuario +│ │ └── ... # Otros modelos +│ ├── api/ # API routes +│ │ └── v1/ # API version 1 +│ │ ├── router.py # Router principal +│ │ └── endpoints/ # Endpoints por dominio +│ ├── middleware/ # Custom middleware +│ ├── services/ # Business logic +│ ├── repositories/ # Data access layer +│ ├── schemas/ # Pydantic schemas +│ └── utils/ # Utilidades compartidas +├── tests/ # Tests unitarios e integración +├── migrations/ # Migraciones Alembic +├── requirements.txt # Dependencias Python +└── pyproject.toml # Configuración del proyecto +``` + +## Características Implementadas + +### Core +- [x] FastAPI app con configuración async +- [x] Pydantic Settings para configuración +- [x] SQLAlchemy 2.0 async +- [x] Structured logging con structlog +- [x] JWT authentication con refresh tokens +- [x] 2FA con TOTP +- [x] Multi-tenancy middleware + +### API +- [x] Health checks (/health, /health/detailed) +- [x] Authentication endpoints básicos +- [x] Middleware de correlation ID y tenant +- [x] Error handling centralizado +- [x] CORS configurado + +### Seguridad +- [x] Argon2 password hashing +- [x] JWT con algoritmos seguros +- [x] TOTP 2FA implementation +- [x] Validation con Pydantic v2 + +## Quick Start + +```bash +# Instalar dependencias +pip install -r requirements.txt + +# Variables de entorno (copiar desde raíz del proyecto) +cp ../.env.example .env + +# Ejecutar en desarrollo +uvicorn app.main:app --reload --port 8000 + +# O usar Docker +docker-compose up backend +``` + +## Testing + +```bash +# Ejecutar tests +pytest + +# Con coverage +pytest --cov=app tests/ + +# Solo tests unitarios +pytest -m "unit" + +# Solo tests de integración +pytest -m "integration" +``` + +## Code Quality + +```bash +# Linting +ruff check . + +# Formateo +black . + +# Type checking +mypy . + +# Fix automático +ruff check . --fix +black . +``` + +## Desarrollo + +### Agregar nuevos endpoints + +1. Crear schema en `app/schemas/` +2. Crear endpoint en `app/api/v1/endpoints/` +3. Registrar router en `app/api/v1/router.py` +4. Agregar tests en `tests/` + +### Modelos de base de datos + +1. Crear modelo en `app/models/` +2. Importar en `app/models/__init__.py` +3. Crear migración: `alembic revision --autogenerate -m "descripción"` +4. Aplicar migración: `alembic upgrade head` + +### Variables de entorno + +Todas las configuraciones están en `app/core/config.py` usando Pydantic Settings. + +Ver `.env.example` para todas las variables disponibles. + +## Arquitectura + +### Clean Architecture + +- **Presentation**: FastAPI endpoints y schemas +- **Application**: Services y casos de uso +- **Domain**: Entidades y reglas de negocio +- **Infrastructure**: Repositorios, DB, external APIs + +### Patrones implementados + +- Repository pattern para acceso a datos +- Dependency injection con FastAPI Depends +- Unit of Work para transacciones +- Command/Query separation + +## Monitoring + +- Structured logging con correlation IDs +- Health checks para load balancer +- Métricas con Prometheus (TODO) +- Error tracking (TODO) + +## Security Checklist + +- [x] Password hashing con Argon2 +- [x] JWT con secret keys seguras +- [x] CORS restrictivo +- [x] Input validation con Pydantic +- [x] SQL injection protection (SQLAlchemy) +- [x] Rate limiting (TODO: implementar) +- [x] File upload validation (TODO: implementar) +- [x] XSS protection (headers en nginx) + +## Próximos pasos + +1. Implementar repositorios y services +2. Completar autenticación con base de datos +3. Agregar endpoints de users y tickets +4. Implementar rate limiting +5. Agregar métricas y monitoring +6. Tests de integración completos \ No newline at end of file diff --git a/backend/add_columns.py b/backend/add_columns.py new file mode 100644 index 0000000..7063d58 --- /dev/null +++ b/backend/add_columns.py @@ -0,0 +1,22 @@ +import asyncio +from sqlalchemy import text +from app.core.database import engine + +async def add_columns(): + print("Starting schema update...") + async with engine.begin() as conn: + try: + await conn.execute(text("ALTER TABLE tickets ADD COLUMN system_id UUID REFERENCES systems(id)")) + print("Added system_id column") + except Exception as e: + print(f"Error adding system_id (might exist): {e}") + + try: + await conn.execute(text("ALTER TABLE tickets ADD COLUMN category_id UUID REFERENCES categories(id)")) + print("Added category_id column") + except Exception as e: + print(f"Error adding category_id (might exist): {e}") + print("Schema update finished.") + +if __name__ == "__main__": + asyncio.run(add_columns()) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..742d254 --- /dev/null +++ b/backend/app/api/deps.py @@ -0,0 +1,57 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import jwt, JWTError +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import ValidationError + +from app.core.database import get_db +from app.core.security import security +from app.core.config import get_settings +from app.models.user import User, UserRole + +settings = get_settings() + +# Define OAuth2 scheme here or import from auth if needed. +# Defining here creates a separate instance which is fine as they share config. +# Ideally auth.py should import from here, but modifying auth.py is risky now. +oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login") + +async def get_current_user( + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db) +) -> User: + payload = security.verify_token(token) + if payload is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + user_id: str = payload.get("sub") + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalars().first() + + if user is None: + raise HTTPException(status_code=404, detail="User not found") + + if not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + + return user + +async def get_current_active_superuser( + current_user: User = Depends(get_current_user), +) -> User: + if current_user.role != UserRole.ADMIN: + raise HTTPException( + status_code=403, detail="The user doesn't have enough privileges" + ) + return current_user diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..c9a04d0 --- /dev/null +++ b/backend/app/api/v1/endpoints/auth.py @@ -0,0 +1,297 @@ +""" +Authentication Endpoints - ServiceManagerWeb + +Endpoints para autenticación y autorización +""" + +from fastapi import APIRouter, HTTPException, status, Depends +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel, EmailStr +from typing import Optional +import structlog + +from app.core.database import get_db +from app.core.security import security +from app.core.config import get_settings +from app.models.user import User +from app.models.tenant import Tenant + +router = APIRouter() +logger = structlog.get_logger(__name__) +settings = get_settings() + +# OAuth2 scheme +oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login") + + +# =================================== +# PYDANTIC SCHEMAS +# =================================== + +class LoginRequest(BaseModel): + """Schema for login request.""" + email: EmailStr + password: str + tenant_slug: str + totp_code: Optional[str] = None + + +class LoginResponse(BaseModel): + """Schema for login response.""" + access_token: str + refresh_token: str + token_type: str = "bearer" + expires_in: int + user: dict + + +class RefreshTokenRequest(BaseModel): + """Schema for refresh token request.""" + refresh_token: str + + +class TokenResponse(BaseModel): + """Schema for token response.""" + access_token: str + token_type: str = "bearer" + expires_in: int + + +# =================================== +# ENDPOINTS +# =================================== + +@router.post("/login", response_model=LoginResponse) +async def login( + login_data: LoginRequest, + db: AsyncSession = Depends(get_db) +): + """ + Authenticate user and return access/refresh tokens. + + Args: + login_data: Login credentials + db: Database session + + Returns: + LoginResponse with tokens and user info + + Raises: + HTTPException: If authentication fails + """ + logger.info( + "Login attempt", + email=login_data.email, + tenant_slug=login_data.tenant_slug + ) + + # 1. Buscar usuario en base de datos + query = select(User).where(User.email == login_data.email) + result = await db.execute(query) + user = result.scalar_one_or_none() + + # 2. 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 + ) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Credenciales inválidas" + ) + + # 3. Verificar si está activo + if not user.is_active: + logger.warning( + "Login failed - user inactive", + email=login_data.email + ) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Usuario inactivo" + ) + + # Create tokens + token_data = { + "sub": str(user.id), + "email": user.email, + "role": user.role.value if hasattr(user.role, "value") else user.role, + "tenant_id": str(user.tenant_id) + } + + access_token = security.create_access_token(token_data) + refresh_token = security.create_refresh_token(token_data) + + logger.info( + "Login successful", + email=login_data.email, + tenant_slug=login_data.tenant_slug, + user_id=str(user.id) + ) + + return LoginResponse( + access_token=access_token, + refresh_token=refresh_token, + expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + user={ + "id": str(user.id), + "email": user.email, + "first_name": user.first_name, + "last_name": user.last_name, + "role": user.role, + "tenant_id": 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 + } + ) + + +@router.post("/refresh", response_model=TokenResponse) +async def refresh_token( + refresh_data: RefreshTokenRequest, + db: AsyncSession = Depends(get_db) +): + """ + Refresh access token using refresh token. + + Args: + refresh_data: Refresh token data + db: Database session + + Returns: + New access token + + Raises: + HTTPException: If refresh token is invalid + """ + logger.info("Token refresh attempt") + + # Verify refresh token + payload = security.verify_token(refresh_data.refresh_token) + if not payload or payload.get("type") != "refresh": + logger.warning("Token refresh failed - invalid token") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token" + ) + + # TODO: Check if refresh token exists in database and is not revoked + + # Create new access token + token_data = { + "sub": payload["sub"], + "email": payload["email"], + "role": payload["role"], + "tenant_id": payload["tenant_id"] + } + + access_token = security.create_access_token(token_data) + + logger.info("Token refresh successful", user_id=payload["sub"]) + + return TokenResponse( + access_token=access_token, + expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60 + ) + + +@router.post("/logout") +async def logout( + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db) +): + """ + Logout user and revoke refresh token. + + Args: + token: Access token + db: Database session + + Returns: + Success message + """ + logger.info("Logout attempt") + + # Verify token + payload = security.verify_token(token) + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token" + ) + + # TODO: Revoke refresh token in database + + logger.info("Logout successful", user_id=payload["sub"]) + + return {"message": "Successfully logged out"} + + +@router.get("/me") +async def get_current_user( + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db) +): + """ + Get current user information. + + Args: + token: Access token + db: Database session + + Returns: + Current user data + + Raises: + HTTPException: If token is invalid + """ + # Verify token + payload = security.verify_token(token) + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token" + ) + + # TODO: Fetch actual user from database + + return { + "id": payload["sub"], + "email": payload["email"], + "role": payload["role"], + "tenant_id": payload["tenant_id"] + } + + +# =================================== +# DEPENDENCIES +# =================================== + +async def get_current_active_user(token: str = Depends(oauth2_scheme)): + """ + Dependency to get current active user from token. + + Args: + token: Access token + + Returns: + Current user data + + Raises: + HTTPException: If token is invalid or user is inactive + """ + payload = security.verify_token(token) + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # TODO: Verify user exists and is active + + return payload \ No newline at end of file diff --git a/backend/app/api/v1/endpoints/categories.py b/backend/app/api/v1/endpoints/categories.py new file mode 100644 index 0000000..4aaabd4 --- /dev/null +++ b/backend/app/api/v1/endpoints/categories.py @@ -0,0 +1,55 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel, ConfigDict +from typing import List, Optional +import uuid + +from app.core.database import get_db +from app.models.category import Category +from app.api import deps + +router = APIRouter() + +class CategoryBase(BaseModel): + name: str + description: Optional[str] = None + is_active: bool = True + tenant_id: Optional[uuid.UUID] = None + +class CategoryCreate(CategoryBase): + pass + +class CategoryUpdate(CategoryBase): + name: Optional[str] = None + description: Optional[str] = None + is_active: Optional[bool] = None + tenant_id: Optional[uuid.UUID] = None + +class CategoryResponse(CategoryBase): + id: uuid.UUID + + model_config = ConfigDict(from_attributes=True) + +@router.get("/", response_model=List[CategoryResponse]) +async def read_categories( + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_active_superuser) +): + query = select(Category).offset(skip).limit(limit) + result = await db.execute(query) + return result.scalars().all() + +@router.post("/", response_model=CategoryResponse) +async def create_category( + category: CategoryCreate, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_active_superuser) +): + db_category = Category(**category.model_dump()) + db.add(db_category) + await db.commit() + await db.refresh(db_category) + return db_category diff --git a/backend/app/api/v1/endpoints/health.py b/backend/app/api/v1/endpoints/health.py new file mode 100644 index 0000000..deba749 --- /dev/null +++ b/backend/app/api/v1/endpoints/health.py @@ -0,0 +1,88 @@ +""" +Health Check Endpoints - ServiceManagerWeb + +Endpoints para health checks y monitoring +""" + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession +import structlog + +from app.core.database import get_db, check_database_health +from app.core.config import get_settings + +router = APIRouter() +logger = structlog.get_logger(__name__) +settings = get_settings() + + +@router.get("/health") +async def health_check(): + """ + Basic health check endpoint. + + Returns basic service information and status. + """ + return { + "status": "healthy", + "service": "ServiceManagerWeb API", + "version": settings.API_VERSION, + "environment": settings.ENVIRONMENT + } + + +@router.get("/health/detailed") +async def detailed_health_check(db: AsyncSession = Depends(get_db)): + """ + Detailed health check with database connectivity. + + Checks database connection and returns detailed status. + """ + # Check database + db_healthy = await check_database_health() + + # TODO: Add Redis health check + # TODO: Add Celery health check + + overall_status = "healthy" if db_healthy else "unhealthy" + status_code = status.HTTP_200_OK if db_healthy else status.HTTP_503_SERVICE_UNAVAILABLE + + health_data = { + "status": overall_status, + "service": "ServiceManagerWeb API", + "version": settings.API_VERSION, + "environment": settings.ENVIRONMENT, + "checks": { + "database": "healthy" if db_healthy else "unhealthy", + "redis": "not_implemented", + "celery": "not_implemented" + } + } + + if not db_healthy: + logger.error("Health check failed - database unhealthy") + + return health_data + + +@router.get("/readiness") +async def readiness_check(): + """ + Kubernetes readiness probe endpoint. + + Returns 200 if service is ready to accept traffic. + """ + # For now, just return ready + # In production, this might check for startup completion, + # database migrations, etc. + return {"status": "ready"} + + +@router.get("/liveness") +async def liveness_check(): + """ + Kubernetes liveness probe endpoint. + + Returns 200 if service is alive and should not be restarted. + """ + return {"status": "alive"} \ No newline at end of file diff --git a/backend/app/api/v1/endpoints/systems.py b/backend/app/api/v1/endpoints/systems.py new file mode 100644 index 0000000..a5aad98 --- /dev/null +++ b/backend/app/api/v1/endpoints/systems.py @@ -0,0 +1,53 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel, ConfigDict +from typing import List, Optional +import uuid + +from app.core.database import get_db +from app.models.system import System +from app.api import deps + +router = APIRouter() + +class SystemBase(BaseModel): + name: str + description: Optional[str] = None + is_active: bool = True + +class SystemCreate(SystemBase): + pass + +class SystemUpdate(SystemBase): + name: Optional[str] = None + description: Optional[str] = None + is_active: Optional[bool] = None + +class SystemResponse(SystemBase): + id: uuid.UUID + + model_config = ConfigDict(from_attributes=True) + +@router.get("/", response_model=List[SystemResponse]) +async def read_systems( + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_active_superuser) +): + query = select(System).offset(skip).limit(limit) + result = await db.execute(query) + return result.scalars().all() + +@router.post("/", response_model=SystemResponse) +async def create_system( + system: SystemCreate, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_active_superuser) +): + db_system = System(**system.model_dump()) + db.add(db_system) + await db.commit() + await db.refresh(db_system) + return db_system diff --git a/backend/app/api/v1/endpoints/tenants.py b/backend/app/api/v1/endpoints/tenants.py new file mode 100644 index 0000000..ae4e035 --- /dev/null +++ b/backend/app/api/v1/endpoints/tenants.py @@ -0,0 +1,94 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel, ConfigDict, EmailStr +from typing import List, Optional +import uuid + +from app.core.database import get_db +from app.models.tenant import Tenant, TenantStatus +from app.api import deps + +router = APIRouter() + +class TenantBase(BaseModel): + name: str + slug: str + domain: Optional[str] = None + contact_email: Optional[EmailStr] = None + +class TenantCreate(TenantBase): + pass + +class TenantUpdate(BaseModel): + name: Optional[str] = None + slug: Optional[str] = None + domain: Optional[str] = None + contact_email: Optional[EmailStr] = None + status: Optional[TenantStatus] = None + +class TenantResponse(TenantBase): + id: uuid.UUID + status: TenantStatus + + model_config = ConfigDict(from_attributes=True) + +@router.get("/", response_model=List[TenantResponse]) +async def read_tenants( + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_active_superuser) +): + query = select(Tenant).offset(skip).limit(limit) + result = await db.execute(query) + return result.scalars().all() + +@router.post("/", response_model=TenantResponse) +async def create_tenant( + tenant: TenantCreate, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_active_superuser) +): + # 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()) + db.add(db_tenant) + await db.commit() + await db.refresh(db_tenant) + return db_tenant + +@router.get("/{tenant_id}", response_model=TenantResponse) +async def read_tenant( + tenant_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_active_superuser) +): + tenant = await db.get(Tenant, tenant_id) + if not tenant: + raise HTTPException(status_code=404, detail="Tenant not found") + return tenant + +@router.put("/{tenant_id}", response_model=TenantResponse) +async def update_tenant( + tenant_id: uuid.UUID, + tenant_in: TenantUpdate, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_active_superuser) +): + tenant = await db.get(Tenant, tenant_id) + if not tenant: + raise HTTPException(status_code=404, detail="Tenant not found") + + update_data = tenant_in.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(tenant, field, value) + + db.add(tenant) + await db.commit() + await db.refresh(tenant) + return tenant diff --git a/backend/app/api/v1/endpoints/users.py b/backend/app/api/v1/endpoints/users.py new file mode 100644 index 0000000..82d3272 --- /dev/null +++ b/backend/app/api/v1/endpoints/users.py @@ -0,0 +1,68 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel, ConfigDict, EmailStr +from typing import List, Optional +import uuid + +from app.core.database import get_db +from app.core.security import security +from app.models.user import User, UserRole +from app.api import deps + +router = APIRouter() + +class UserBase(BaseModel): + email: EmailStr + first_name: str + last_name: str + role: UserRole + is_active: bool = True + tenant_id: Optional[uuid.UUID] = None + +class UserCreate(UserBase): + password: str + +class UserUpdate(BaseModel): + email: Optional[EmailStr] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + role: Optional[UserRole] = None + is_active: Optional[bool] = None + password: Optional[str] = None # Optional password update + +class UserResponse(UserBase): + id: uuid.UUID + + model_config = ConfigDict(from_attributes=True) + +@router.get("/", response_model=List[UserResponse]) +async def read_users( + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_active_superuser) +): + query = select(User).offset(skip).limit(limit) + result = await db.execute(query) + return result.scalars().all() + +@router.post("/", response_model=UserResponse) +async def create_user( + user: UserCreate, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_active_superuser) +): + query = select(User).where(User.email == user.email) + result = await db.execute(query) + if result.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Email already registered") + + user_data = user.model_dump(exclude={"password"}) + password_hash = security.get_password_hash(user.password) + + db_user = User(**user_data, password_hash=password_hash) + db.add(db_user) + await db.commit() + await db.refresh(db_user) + return db_user diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py new file mode 100644 index 0000000..924133f --- /dev/null +++ b/backend/app/api/v1/router.py @@ -0,0 +1,47 @@ +""" +API v1 Router - ServiceManagerWeb + +Router principal para la API v1 +""" + +from fastapi import APIRouter +from app.api.v1.endpoints import auth, health, tenants, users, systems, categories + +api_router = APIRouter() + +# Health check routes +api_router.include_router( + health.router, + tags=["health"] +) + +# Authentication routes +api_router.include_router( + auth.router, + prefix="/auth", + tags=["authentication"] +) + +api_router.include_router( + tenants.router, + prefix="/tenants", + tags=["tenants"] +) + +api_router.include_router( + users.router, + prefix="/users", + tags=["users"] +) + +api_router.include_router( + systems.router, + prefix="/systems", + tags=["systems"] +) + +api_router.include_router( + categories.router, + prefix="/categories", + tags=["categories"] +) diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..2c0a7b9 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,142 @@ +""" +Core Configuration - ServiceManagerWeb + +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 + + +class Settings(BaseSettings): + """Configuración de la aplicación.""" + + model_config = { + "env_file": ".env", + "env_file_encoding": "utf-8", + "case_sensitive": False + } + + # =================================== + # GENERAL + # =================================== + ENVIRONMENT: str = Field(default="development", env="ENVIRONMENT") + DEBUG: bool = Field(default=False, env="DEBUG") + SECRET_KEY: str = Field(..., env="SECRET_KEY") + API_VERSION: str = Field(default="v1", env="API_VERSION") + + # =================================== + # DATABASE + # =================================== + DATABASE_URL: str = Field(..., env="DATABASE_URL") + + # =================================== + # REDIS + # =================================== + REDIS_URL: str = Field(..., env="REDIS_URL") + + # =================================== + # JWT AUTHENTICATION + # =================================== + JWT_SECRET_KEY: str = Field(..., env="JWT_SECRET_KEY") + JWT_ALGORITHM: str = Field(default="HS256", env="JWT_ALGORITHM") + ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=60, env="ACCESS_TOKEN_EXPIRE_MINUTES") + REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7, env="REFRESH_TOKEN_EXPIRE_DAYS") + + # =================================== + # CORS + # =================================== + CORS_ORIGINS: str = Field( + default="http://localhost:3000,http://localhost:3001", + env="CORS_ORIGINS" + ) + + # =================================== + # EMAIL + # =================================== + SMTP_HOST: str = Field(default="localhost", env="SMTP_HOST") + SMTP_PORT: int = Field(default=587, env="SMTP_PORT") + SMTP_USER: Optional[str] = Field(default=None, env="SMTP_USER") + SMTP_PASSWORD: Optional[str] = Field(default=None, env="SMTP_PASSWORD") + SMTP_USE_TLS: bool = Field(default=True, env="SMTP_USE_TLS") + SMTP_USE_SSL: bool = Field(default=False, env="SMTP_USE_SSL") + + DEFAULT_FROM_EMAIL: str = Field(default="noreply@servicemanager.local", env="DEFAULT_FROM_EMAIL") + DEFAULT_FROM_NAME: str = Field(default="ServiceManager", env="DEFAULT_FROM_NAME") + + # =================================== + # FILE UPLOADS + # =================================== + MAX_UPLOAD_SIZE_MB: int = Field(default=10, env="MAX_UPLOAD_SIZE_MB") + ALLOWED_FILE_EXTENSIONS: List[str] = Field( + default=["pdf", "jpg", "jpeg", "png", "doc", "docx", "xls", "xlsx", "txt"], + env="ALLOWED_FILE_EXTENSIONS" + ) + UPLOAD_PATH: str = Field(default="/app/uploads", env="UPLOAD_PATH") + + @field_validator("ALLOWED_FILE_EXTENSIONS", mode='before') + @classmethod + def validate_file_extensions(cls, v): + if isinstance(v, str): + return [ext.strip().lower() for ext in v.split(",")] + return [ext.lower() for ext in v] + + # =================================== + # SECURITY + # =================================== + RATE_LIMIT_ENABLED: bool = Field(default=True, env="RATE_LIMIT_ENABLED") + PASSWORD_MIN_LENGTH: int = Field(default=8, env="PASSWORD_MIN_LENGTH") + + # Argon2 settings + ARGON2_TIME_COST: int = Field(default=3, env="ARGON2_TIME_COST") + ARGON2_MEMORY_COST: int = Field(default=65536, env="ARGON2_MEMORY_COST") + ARGON2_PARALLELISM: int = Field(default=4, env="ARGON2_PARALLELISM") + + # =================================== + # LOGGING + # =================================== + LOG_LEVEL: str = Field(default="INFO", env="LOG_LEVEL") + LOG_FORMAT: str = Field(default="json", env="LOG_FORMAT") + LOG_FILE: Optional[str] = Field(default=None, env="LOG_FILE") + + # =================================== + # FRONTEND URLS + # =================================== + CLIENT_FRONTEND_URL: str = Field(default="http://localhost:3000", env="CLIENT_FRONTEND_URL") + INTERNAL_FRONTEND_URL: str = Field(default="http://localhost:3001", env="INTERNAL_FRONTEND_URL") + + # =================================== + # HEALTH CHECKS + # =================================== + HEALTH_CHECK_TIMEOUT: int = Field(default=30, env="HEALTH_CHECK_TIMEOUT") + + # =================================== + # CELERY + # =================================== + CELERY_BROKER_URL: str = Field(..., env="CELERY_BROKER_URL") + CELERY_RESULT_BACKEND: str = Field(..., env="CELERY_RESULT_BACKEND") + + def is_production(self) -> bool: + """Check if environment is production.""" + return self.ENVIRONMENT.lower() == "production" + + def is_development(self) -> bool: + """Check if environment is development.""" + return self.ENVIRONMENT.lower() == "development" + + def is_testing(self) -> bool: + """Check if environment is testing.""" + return self.ENVIRONMENT.lower() == "testing" + + +@lru_cache() +def get_settings() -> Settings: + """ + Get cached settings instance. + + Using lru_cache to create a singleton pattern for settings. + """ + return Settings() \ No newline at end of file diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..46ec699 --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,94 @@ +""" +Database Configuration - ServiceManagerWeb + +SQLAlchemy 2.0 async setup con PostgreSQL +""" + +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from sqlalchemy import String, DateTime, func +from typing import AsyncGenerator +import uuid +from datetime import datetime + +from app.core.config import get_settings + +settings = get_settings() + +# Create async engine +engine = create_async_engine( + settings.DATABASE_URL, + echo=settings.DEBUG, + pool_size=5, + max_overflow=10, + pool_pre_ping=True, # Verify connections before use + pool_recycle=3600, # Recycle connections after 1 hour +) + +# Create session factory +AsyncSessionLocal = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + autoflush=True, + autocommit=False +) + + +class Base(DeclarativeBase): + """Base class para todos los modelos SQLAlchemy.""" + + # Columnas comunes para auditoría + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now() + ) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + """ + Dependency para obtener sesión de base de datos. + + Yields: + AsyncSession: Sesión de base de datos + """ + async with AsyncSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() + + +async def create_tables(): + """Crear todas las tablas en desarrollo.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +async def drop_tables(): + """Eliminar todas las tablas (solo para testing).""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +# Health check function +async def check_database_health() -> bool: + """ + Verificar conectividad con la base de datos. + + Returns: + bool: True si la conexión es exitosa + """ + try: + async with AsyncSessionLocal() as session: + await session.execute("SELECT 1") + return True + except Exception: + return False \ No newline at end of file diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py new file mode 100644 index 0000000..1323013 --- /dev/null +++ b/backend/app/core/logging.py @@ -0,0 +1,145 @@ +""" +Structured Logging Configuration - ServiceManagerWeb + +Configuración de logging estructurado con structlog +""" + +import logging +import logging.config +import sys +from typing import Any, Dict +import structlog +from app.core.config import get_settings + +settings = get_settings() + + +def add_correlation_id(logger: Any, method_name: str, event_dict: Dict[str, Any]) -> Dict[str, Any]: + """Agregar correlation ID a los logs si está disponible.""" + # En un contexto de request real, esto vendría del middleware + # Por ahora es un placeholder + return event_dict + + +def configure_structlog(): + """Configurar structlog para logging estructurado.""" + + processors = [ + # Add the log level and a timestamp to the event_dict + structlog.stdlib.filter_by_level, + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.stdlib.PositionalArgumentsFormatter(), + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.UnicodeDecoder(), + add_correlation_id, + ] + + if settings.LOG_FORMAT == "json": + processors.append(structlog.processors.JSONRenderer()) + else: + processors.append(structlog.dev.ConsoleRenderer()) + + structlog.configure( + processors=processors, + wrapper_class=structlog.stdlib.BoundLogger, + logger_factory=structlog.stdlib.LoggerFactory(), + context_class=dict, + cache_logger_on_first_use=True, + ) + + +def setup_logging(): + """Configurar el sistema de logging completo.""" + + # Configure structlog + configure_structlog() + + # Configure standard library logging + logging_config = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "json": { + "()": structlog.stdlib.ProcessorFormatter, + "processor": structlog.processors.JSONRenderer(), + }, + "console": { + "()": structlog.stdlib.ProcessorFormatter, + "processor": structlog.dev.ConsoleRenderer(colors=True), + }, + }, + "handlers": { + "console": { + "level": settings.LOG_LEVEL, + "class": "logging.StreamHandler", + "stream": sys.stdout, + "formatter": "json" if settings.LOG_FORMAT == "json" else "console", + }, + }, + "loggers": { + "": { # root logger + "handlers": ["console"], + "level": settings.LOG_LEVEL, + "propagate": False, + }, + "uvicorn": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, + "uvicorn.error": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, + "uvicorn.access": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, + "sqlalchemy": { + "handlers": ["console"], + "level": "WARNING", + "propagate": False, + }, + "celery": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, + }, + } + + # Add file handler if specified + if settings.LOG_FILE: + logging_config["handlers"]["file"] = { + "level": settings.LOG_LEVEL, + "class": "logging.handlers.RotatingFileHandler", + "filename": settings.LOG_FILE, + "maxBytes": 10 * 1024 * 1024, # 10MB + "backupCount": 5, + "formatter": "json", + } + + # Add file handler to all loggers + for logger_config in logging_config["loggers"].values(): + logger_config["handlers"].append("file") + + logging.config.dictConfig(logging_config) + + +# Convenience function to get logger +def get_logger(name: str = None) -> structlog.BoundLogger: + """ + Get a configured structlog logger. + + Args: + name: Logger name (optional) + + Returns: + Configured structlog logger + """ + return structlog.get_logger(name) \ No newline at end of file diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..a6c4dd4 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,271 @@ +""" +Security Utilities - ServiceManagerWeb + +Funciones de seguridad para autenticación y autorización +""" + +from datetime import datetime, timedelta +from typing import Optional, Union, Dict, Any +from passlib.context import CryptContext +from passlib.handlers.argon2 import argon2 +from jose import JWTError, jwt +import pyotp +import secrets +import base64 +import struct + +from app.core.config import get_settings + +settings = get_settings() + +# Password hashing context +pwd_context = CryptContext( + schemes=["argon2"], + deprecated="auto", + argon2__time_cost=settings.ARGON2_TIME_COST, + argon2__memory_cost=settings.ARGON2_MEMORY_COST, + argon2__parallelism=settings.ARGON2_PARALLELISM, +) + + +class SecurityUtils: + """Utilidades de seguridad centralizadas.""" + + @staticmethod + def hash_password(password: str) -> str: + """ + Hash a password using Argon2. + + Args: + password: Plain text password + + Returns: + Hashed password + """ + return pwd_context.hash(password) + + @staticmethod + def verify_password(plain_password: str, hashed_password: str) -> bool: + """ + Verify a password against its hash. + + Args: + plain_password: Plain text password + hashed_password: Hashed password + + Returns: + True if password matches + """ + return pwd_context.verify(plain_password, hashed_password) + + @staticmethod + def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str: + """ + Create a JWT access token. + + Args: + data: Token payload + expires_delta: Token expiration time + + Returns: + JWT token string + """ + to_encode = data.copy() + + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire}) + + encoded_jwt = jwt.encode( + to_encode, + settings.JWT_SECRET_KEY, + algorithm=settings.JWT_ALGORITHM + ) + + return encoded_jwt + + @staticmethod + def create_refresh_token(data: Dict[str, Any]) -> str: + """ + Create a JWT refresh token. + + Args: + data: Token payload + + Returns: + JWT refresh token string + """ + to_encode = data.copy() + expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + to_encode.update({"exp": expire, "type": "refresh"}) + + encoded_jwt = jwt.encode( + to_encode, + settings.JWT_SECRET_KEY, + algorithm=settings.JWT_ALGORITHM + ) + + return encoded_jwt + + @staticmethod + def verify_token(token: str) -> Optional[Dict[str, Any]]: + """ + Verify and decode a JWT token. + + Args: + token: JWT token string + + Returns: + Token payload if valid, None otherwise + """ + try: + payload = jwt.decode( + token, + settings.JWT_SECRET_KEY, + algorithms=[settings.JWT_ALGORITHM] + ) + return payload + except JWTError: + return None + + @staticmethod + def generate_totp_secret() -> str: + """ + Generate a base32-encoded secret for TOTP. + + Returns: + Base32 encoded secret + """ + return pyotp.random_base32() + + @staticmethod + def generate_totp_uri(secret: str, email: str, issuer_name: str = "ServiceManager") -> str: + """ + Generate TOTP URI for QR code. + + Args: + secret: Base32 encoded secret + email: User email + issuer_name: Application name + + Returns: + TOTP URI + """ + totp = pyotp.TOTP(secret) + return totp.provisioning_uri( + name=email, + issuer_name=issuer_name + ) + + @staticmethod + def verify_totp(secret: str, token: str, window: int = 1) -> bool: + """ + Verify a TOTP token. + + Args: + secret: Base32 encoded secret + token: TOTP token + window: Time window tolerance + + Returns: + True if token is valid + """ + totp = pyotp.TOTP(secret) + return totp.verify(token, valid_window=window) + + @staticmethod + def generate_backup_codes(count: int = 8) -> list[str]: + """ + Generate backup codes for 2FA. + + Args: + count: Number of codes to generate + + Returns: + List of backup codes + """ + codes = [] + for _ in range(count): + code = secrets.token_hex(4).upper() + # Format as XXXX-XXXX + formatted_code = f"{code[:4]}-{code[4:]}" + codes.append(formatted_code) + return codes + + @staticmethod + def hash_token(token: str) -> str: + """ + Hash a token for secure storage. + + Args: + token: Token to hash + + Returns: + Hashed token + """ + return pwd_context.hash(token) + + @staticmethod + def verify_hashed_token(token: str, hashed_token: str) -> bool: + """ + Verify a token against its hash. + + Args: + token: Plain token + hashed_token: Hashed token + + Returns: + True if token matches + """ + return pwd_context.verify(token, hashed_token) + + @staticmethod + def generate_secure_token(length: int = 32) -> str: + """ + Generate a cryptographically secure random token. + + Args: + length: Token length in bytes + + Returns: + URL-safe base64 encoded token + """ + token = secrets.token_bytes(length) + return base64.urlsafe_b64encode(token).decode('utf-8').rstrip('=') + + @staticmethod + def is_strong_password(password: str) -> tuple[bool, list[str]]: + """ + Check if password meets security requirements. + + Args: + password: Password to check + + Returns: + Tuple of (is_valid, list_of_issues) + """ + issues = [] + + if len(password) < settings.PASSWORD_MIN_LENGTH: + issues.append(f"Password must be at least {settings.PASSWORD_MIN_LENGTH} characters long") + + if not any(c.islower() for c in password): + issues.append("Password must contain at least one lowercase letter") + + if not any(c.isupper() for c in password): + issues.append("Password must contain at least one uppercase letter") + + if not any(c.isdigit() for c in password): + issues.append("Password must contain at least one digit") + + if not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in password): + issues.append("Password must contain at least one special character") + + return len(issues) == 0, issues + + +# Create singleton instance +security = SecurityUtils() \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..f00588d --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,201 @@ +""" +ServiceManagerWeb Backend - FastAPI Application + +Mesa de Ayuda B2B multi-tenant con Clean Architecture +""" + +from fastapi import FastAPI, Request, Response +from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware +from fastapi.responses import JSONResponse +from contextlib import asynccontextmanager +import structlog +import time +import uuid + +from app.core.config import get_settings +from app.core.database import engine, create_tables +# Import models to register them with SQLAlchemy +from app.models.tenant import Tenant +from app.models.system import System +from app.models.category import Category +from app.models.user import User +from app.models.ticket import Ticket + +from app.core.logging import setup_logging +from app.api.v1.router import api_router +from app.middleware.tenant import TenantMiddleware +from app.middleware.correlation_id import CorrelationIDMiddleware + +settings = get_settings() +setup_logging() +logger = structlog.get_logger() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Lifecycle manager para la aplicación.""" + # Startup + logger.info("Iniciando ServiceManagerWeb Backend", version=settings.API_VERSION) + + if settings.ENVIRONMENT == "development": + await create_tables() + logger.info("Tablas de base de datos verificadas") + + yield + + # Shutdown + logger.info("Cerrando ServiceManagerWeb Backend") + + +# Crear aplicación FastAPI +app = FastAPI( + title="ServiceManagerWeb API", + description="Mesa de Ayuda B2B multi-tenant para Aduanasoft", + version=settings.API_VERSION, + lifespan=lifespan, + docs_url=f"/{settings.API_VERSION}/docs" if settings.ENVIRONMENT == "development" else None, + redoc_url=f"/{settings.API_VERSION}/redoc" if settings.ENVIRONMENT == "development" else None, + openapi_url=f"/{settings.API_VERSION}/openapi.json" +) + +# =================================== +# MIDDLEWARE +# =================================== + +# CORS +cors_origins = settings.CORS_ORIGINS.split(",") if isinstance(settings.CORS_ORIGINS, str) else settings.CORS_ORIGINS +app.add_middleware( + CORSMiddleware, + allow_origins=cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Compression +app.add_middleware(GZipMiddleware, minimum_size=1000) + +# Custom middleware +app.add_middleware(CorrelationIDMiddleware) +app.add_middleware(TenantMiddleware) + +# Request logging middleware +@app.middleware("http") +async def request_logging_middleware(request: Request, call_next): + """Log todas las requests con métricas de performance.""" + start_time = time.time() + correlation_id = getattr(request.state, "correlation_id", str(uuid.uuid4())) + + # Log request + logger.info( + "Request iniciada", + method=request.method, + url=str(request.url), + correlation_id=correlation_id, + user_agent=request.headers.get("user-agent"), + remote_addr=request.client.host if request.client else None + ) + + # Process request + response = await call_next(request) + + # Log response + duration = time.time() - start_time + logger.info( + "Request completada", + method=request.method, + url=str(request.url), + status_code=response.status_code, + duration=f"{duration:.3f}s", + correlation_id=correlation_id + ) + + # Add correlation ID to response headers + response.headers["X-Correlation-ID"] = correlation_id + + return response + + +# =================================== +# EXCEPTION HANDLERS +# =================================== + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + """Handler global para excepciones no capturadas.""" + correlation_id = getattr(request.state, "correlation_id", str(uuid.uuid4())) + + logger.error( + "Excepción no manejada", + error=str(exc), + correlation_id=correlation_id, + url=str(request.url), + method=request.method, + exc_info=True + ) + + return JSONResponse( + status_code=500, + content={ + "success": False, + "error": { + "code": "INTERNAL_ERROR", + "message": "Error interno del servidor" + }, + "correlation_id": correlation_id + } + ) + + +# =================================== +# ROUTES +# =================================== + +# Health check endpoint +@app.get("/health") +async def health_check(): + """Health check para load balancer y monitoring.""" + return { + "status": "healthy", + "service": "ServiceManagerWeb API", + "version": settings.API_VERSION, + "environment": settings.ENVIRONMENT + } + + +# Root endpoint +@app.get("/") +async def root(): + """Endpoint raíz con información básica.""" + return { + "service": "ServiceManagerWeb API", + "version": settings.API_VERSION, + "docs": f"/{settings.API_VERSION}/docs", + "environment": settings.ENVIRONMENT + } + + +# API routes +app.include_router( + api_router, + prefix=f"/{settings.API_VERSION}", + responses={ + 400: {"description": "Bad Request"}, + 401: {"description": "Unauthorized"}, + 403: {"description": "Forbidden"}, + 404: {"description": "Not Found"}, + 422: {"description": "Validation Error"}, + 500: {"description": "Internal Server Error"} + } +) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=8000, + reload=settings.ENVIRONMENT == "development" + ) \ No newline at end of file diff --git a/backend/app/middleware/correlation_id.py b/backend/app/middleware/correlation_id.py new file mode 100644 index 0000000..c3e1877 --- /dev/null +++ b/backend/app/middleware/correlation_id.py @@ -0,0 +1,46 @@ +""" +Correlation ID Middleware - ServiceManagerWeb + +Middleware para rastrear requests con correlation ID +""" + +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response +import uuid +import structlog + +logger = structlog.get_logger(__name__) + + +class CorrelationIDMiddleware(BaseHTTPMiddleware): + """ + Middleware para manejar correlation IDs. + + Extrae el correlation ID del header X-Correlation-ID o genera uno nuevo. + Lo almacena en el estado de la request para uso en logs y responses. + """ + + async def dispatch(self, request: Request, call_next) -> Response: + """Process request and add correlation ID.""" + + # Extract or generate correlation ID + correlation_id = request.headers.get("X-Correlation-ID") + if not correlation_id: + correlation_id = str(uuid.uuid4()) + + # Store in request state + request.state.correlation_id = correlation_id + + # Add to structlog context + with structlog.contextvars.bound_contextvars( + correlation_id=correlation_id, + path=request.url.path, + method=request.method + ): + response = await call_next(request) + + # Add correlation ID to response headers + response.headers["X-Correlation-ID"] = correlation_id + + return response \ No newline at end of file diff --git a/backend/app/middleware/tenant.py b/backend/app/middleware/tenant.py new file mode 100644 index 0000000..11aa855 --- /dev/null +++ b/backend/app/middleware/tenant.py @@ -0,0 +1,72 @@ +""" +Tenant Middleware - ServiceManagerWeb + +Middleware para manejo de multi-tenancy +""" + +from fastapi import Request, HTTPException, status +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response +import structlog + +logger = structlog.get_logger(__name__) + + +class TenantMiddleware(BaseHTTPMiddleware): + """ + Middleware para extraer y validar información del tenant. + + Extrae el tenant_id del header X-Tenant-ID y lo almacena + en el estado de la request para uso posterior. + """ + + # Rutas que no requieren tenant + EXCLUDED_PATHS = { + "/health", + "/", + "/v1/auth/login", + "/docs", + "/openapi.json", + "/redoc" + } + + async def dispatch(self, request: Request, call_next) -> Response: + """Process request and add tenant information.""" + + # Skip tenant validation for excluded paths + if request.url.path in self.EXCLUDED_PATHS or request.url.path.startswith("/docs"): + return await call_next(request) + + # Extract tenant from header + tenant_id = request.headers.get("X-Tenant-ID") + tenant_slug = request.headers.get("X-Tenant-Slug") + + # For now, we'll be more permissive in development + # In production, tenant should be strictly required + if not tenant_id and not tenant_slug: + logger.warning( + "Request without tenant information", + path=request.url.path, + method=request.method + ) + # For now, continue without tenant for development + # raise HTTPException( + # status_code=status.HTTP_400_BAD_REQUEST, + # detail="Tenant information required (X-Tenant-ID or X-Tenant-Slug header)" + # ) + + # Store tenant info in request state + request.state.tenant_id = tenant_id + request.state.tenant_slug = tenant_slug + + # TODO: Validate tenant exists and is active + # This would involve a database query which we'll implement later + + logger.debug( + "Tenant middleware processed", + tenant_id=tenant_id, + tenant_slug=tenant_slug, + path=request.url.path + ) + + return await call_next(request) \ No newline at end of file diff --git a/backend/app/models/category.py b/backend/app/models/category.py new file mode 100644 index 0000000..8ccb9b5 --- /dev/null +++ b/backend/app/models/category.py @@ -0,0 +1,28 @@ + +""" +Category Model - ServiceManagerWeb +""" +from sqlalchemy import String, Text, Boolean, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID +from typing import List, Optional +import uuid + +from app.core.database import Base + +class Category(Base): + __tablename__ = "categories" + + name: Mapped[str] = mapped_column(String(100), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + + # Optional: Tenant specific categories? + tenant_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True) + + # Relationships + tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="category") + tenant: Mapped["Tenant"] = relationship("Tenant") # Assuming Tenant model is imported + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/models/system.py b/backend/app/models/system.py new file mode 100644 index 0000000..70929d1 --- /dev/null +++ b/backend/app/models/system.py @@ -0,0 +1,25 @@ + +""" +System Model - ServiceManagerWeb +""" +from sqlalchemy import String, Text, Boolean +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import List, Optional +import uuid + +from app.core.database import Base + +class System(Base): + __tablename__ = "systems" + + name: Mapped[str] = mapped_column(String(100), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + + # Relationships + # If we want tickets to link to systems, we will add relationship in Ticket later or now. + # We will assume Ticket links to System. + tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="system") + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/models/tenant.py b/backend/app/models/tenant.py new file mode 100644 index 0000000..e37f110 --- /dev/null +++ b/backend/app/models/tenant.py @@ -0,0 +1,68 @@ +""" +Tenant Model - ServiceManagerWeb + +Modelo para organizaciones cliente (multi-tenancy) +""" + +from sqlalchemy import String, Integer, Text, Boolean, ARRAY +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID, ENUM +from typing import List, Optional +import enum +import uuid + +from app.core.database import Base + + +class TenantStatus(str, enum.Enum): + """Estados de un tenant.""" + ACTIVE = "active" + SUSPENDED = "suspended" + INACTIVE = "inactive" + + +class Tenant(Base): + """Modelo de Tenant (Organización cliente).""" + + __tablename__ = "tenants" + + # Información básica + name: Mapped[str] = mapped_column(String(255), nullable=False) + slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + domain: Mapped[Optional[str]] = mapped_column(String(255)) + logo_url: Mapped[Optional[str]] = mapped_column(String(500)) + + # Contacto + contact_email: Mapped[Optional[str]] = mapped_column(String(320)) + contact_phone: Mapped[Optional[str]] = mapped_column(String(20)) + address: Mapped[Optional[str]] = mapped_column(Text) + + # Configuración regional + timezone: Mapped[str] = mapped_column(String(50), default="UTC") + locale: Mapped[str] = mapped_column(String(10), default="es-ES") + + # Límites y configuración + max_users: Mapped[int] = mapped_column(Integer, default=50) + max_storage_mb: Mapped[int] = mapped_column(Integer, default=1024) + allowed_file_types: Mapped[List[str]] = mapped_column( + ARRAY(String), + default=["pdf", "jpg", "jpeg", "png", "doc", "docx", "xls", "xlsx", "txt"] + ) + + # Estado + status: Mapped[TenantStatus] = mapped_column( + String(20), + default=TenantStatus.ACTIVE + ) + + # Relaciones + users: Mapped[List["User"]] = relationship("User", back_populates="tenant") + tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="tenant") + + def __repr__(self) -> str: + return f"" + + @property + def is_active(self) -> bool: + """Check if tenant is active.""" + return self.status == TenantStatus.ACTIVE \ No newline at end of file diff --git a/backend/app/models/ticket.py b/backend/app/models/ticket.py new file mode 100644 index 0000000..a78aadb --- /dev/null +++ b/backend/app/models/ticket.py @@ -0,0 +1,65 @@ +""" +Ticket Model - ServiceManagerWeb +""" +from sqlalchemy import String, ForeignKey, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID, ENUM +from typing import Optional +import enum +import uuid + +from app.core.database import Base + +class TicketStatus(str, enum.Enum): + NEW = "NEW" + TRIAGE = "TRIAGE" + IN_PROGRESS = "IN_PROGRESS" + WAITING_FOR_CLIENT = "WAITING_FOR_CLIENT" + RESOLVED = "RESOLVED" + CLOSED = "CLOSED" + REOPENED = "REOPENED" + +class TicketPriority(str, enum.Enum): + LOW = "LOW" + MEDIUM = "MEDIUM" + HIGH = "HIGH" + URGENT = "URGENT" + +class Ticket(Base): + __tablename__ = "tickets" + + # Note: id, created_at, updated_at are inherited from Base + + tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False) + + ticket_number: Mapped[str] = mapped_column(String(20), nullable=False) + subject: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False) + + status: Mapped[TicketStatus] = mapped_column(ENUM(TicketStatus, name="ticket_status_enum", create_type=False), default=TicketStatus.NEW) + priority: Mapped[TicketPriority] = mapped_column(ENUM(TicketPriority, name="ticket_priority_enum", create_type=False), default=TicketPriority.MEDIUM) + + # Foreign Keys + created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + assigned_to: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + + system_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("systems.id"), nullable=True) + category_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("categories.id"), nullable=True) + + # Relationships + tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="tickets") + + system: Mapped["System"] = relationship("System", back_populates="tickets") + category: Mapped["Category"] = relationship("Category", back_populates="tickets") + + created_by_user: Mapped["User"] = relationship( + "User", + foreign_keys=[created_by], + back_populates="created_tickets" + ) + + assigned_to_user: Mapped[Optional["User"]] = relationship( + "User", + foreign_keys=[assigned_to], + back_populates="assigned_tickets" + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..c492222 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,135 @@ +""" +User Model - ServiceManagerWeb + +Modelo para usuarios del sistema (internos y clientes) +""" + +from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, ARRAY +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID, ENUM +from typing import Optional, List +import enum +import uuid +from datetime import datetime + +from app.core.database import Base + + +class UserRole(str, enum.Enum): + """Roles de usuario en el sistema.""" + # Staff interno + ADMIN = "ADMIN" # Control total + SUPPORT_MANAGER = "SUPPORT_MANAGER" # Gestión de equipos y SLAs + AGENT = "AGENT" # Atención de tickets + AUDITOR = "AUDITOR" # Solo lectura para auditoría + + # Clientes + CLIENT_ADMIN = "CLIENT_ADMIN" # Admin de organización cliente + CLIENT_USER = "CLIENT_USER" # Usuario final cliente + + +class User(Base): + """Modelo de Usuario.""" + + __tablename__ = "users" + + # Relación con tenant + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False + ) + + # Información básica + email: Mapped[str] = mapped_column(String(320), nullable=False) + first_name: Mapped[str] = mapped_column(String(100), nullable=False) + last_name: Mapped[str] = mapped_column(String(100), nullable=False) + avatar_url: Mapped[Optional[str]] = mapped_column(String(500)) + + # Autenticación + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[UserRole] = mapped_column(ENUM(UserRole), nullable=False) + + # 2FA (opcional para staff interno) + totp_secret: Mapped[Optional[str]] = mapped_column(String(32)) + totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + backup_codes: Mapped[Optional[List[str]]] = mapped_column(ARRAY(String)) + + # Estado + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + email_verified: Mapped[bool] = mapped_column(Boolean, default=False) + last_login: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + last_activity: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + + # Preferencias + language: Mapped[str] = mapped_column(String(10), default="es") + timezone: Mapped[str] = mapped_column(String(50), default="UTC") + notifications_email: Mapped[bool] = mapped_column(Boolean, default=True) + + # Relaciones + tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="users") + created_tickets: Mapped[List["Ticket"]] = relationship( + "Ticket", + back_populates="created_by_user", + foreign_keys="Ticket.created_by" + ) + assigned_tickets: Mapped[List["Ticket"]] = relationship( + "Ticket", + back_populates="assigned_to_user", + foreign_keys="Ticket.assigned_to" + ) + + # Unique constraint por tenant + __table_args__ = ( + {"postgresql_tablespace": "users"}, + ) + + def __repr__(self) -> str: + return f"" + + @property + def full_name(self) -> str: + """Get user's full name.""" + return f"{self.first_name} {self.last_name}" + + @property + def is_staff(self) -> bool: + """Check if user is internal staff.""" + return self.role in [ + UserRole.ADMIN, + UserRole.SUPPORT_MANAGER, + UserRole.AGENT, + UserRole.AUDITOR + ] + + @property + def is_client(self) -> bool: + """Check if user is a client.""" + return self.role in [ + UserRole.CLIENT_ADMIN, + UserRole.CLIENT_USER + ] + + @property + def can_manage_users(self) -> bool: + """Check if user can manage other users.""" + return self.role in [ + UserRole.ADMIN, + UserRole.SUPPORT_MANAGER, + UserRole.CLIENT_ADMIN + ] + + @property + def can_manage_tickets(self) -> bool: + """Check if user can manage tickets.""" + return self.role in [ + UserRole.ADMIN, + UserRole.SUPPORT_MANAGER, + UserRole.AGENT + ] + + @property + def requires_2fa(self) -> bool: + """Check if 2FA is required for this user.""" + # 2FA opcional para staff interno, no requerido para clientes + return self.is_staff \ No newline at end of file diff --git a/backend/fix_password_script.py b/backend/fix_password_script.py new file mode 100644 index 0000000..81fd583 --- /dev/null +++ b/backend/fix_password_script.py @@ -0,0 +1,41 @@ + +import asyncio +import sys +import os + +# Add parent directory to path so we can import 'app' +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from sqlalchemy import select +from app.core.database import AsyncSessionLocal +from app.models.tenant import Tenant # Import Tenant to register it +from app.models.ticket import Ticket # Import Ticket to register it +from app.models.user import User +from app.core.security import SecurityUtils + +async def fix_password(): + async with AsyncSessionLocal() as session: + # Find the admin user + email = "admin@aduanasoft.com" + result = await session.execute(select(User).where(User.email == email)) + user = result.scalar_one_or_none() + + if user: + print(f"User {email} found.") + # Reset password to 'admin123' + new_password = "admin123" + hashed = SecurityUtils.hash_password(new_password) + user.password_hash = hashed + + try: + await session.commit() + print(f"Password for {email} updated successfully!") + print(f"New password is: {new_password}") + except Exception as e: + await session.rollback() + print(f"Error updating password: {e}") + else: + print(f"User {email} not found!") + +if __name__ == "__main__": + asyncio.run(fix_password()) diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..d6386bb --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,181 @@ +# ServiceManagerWeb Backend - PyProject Configuration + +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "servicemanager-backend" +version = "0.1.0" +description = "ServiceManagerWeb Backend - Mesa de Ayuda B2B" +authors = [ + {name = "Aduanasoft", email = "dev@aduanasoft.com"} +] +readme = "README.md" +requires-python = ">=3.11" +license = {text = "Proprietary"} + +dependencies = [ + "fastapi==0.104.1", + "uvicorn[standard]==0.24.0", + "pydantic==2.5.0", + "pydantic-settings==2.1.0", + "sqlalchemy==2.0.23", + "alembic==1.13.0", + "asyncpg==0.29.0", + "psycopg2-binary==2.9.9", + "python-jose[cryptography]==3.3.0", + "python-multipart==0.0.6", + "passlib[argon2]==1.7.4", + "argon2-cffi==23.1.0", + "pyotp==2.9.0", + "celery==5.3.4", + "redis==5.0.1", + "email-validator==2.1.0", + "jinja2==3.1.2", + "python-magic==0.4.27", + "pillow==10.1.0", + "httpx==0.25.2", + "aiofiles==23.2.1", + "python-dateutil==2.8.2", + "pytz==2023.3", + "slugify==0.0.1", + "structlog==23.2.0", + "prometheus-client==0.19.0" +] + +[project.optional-dependencies] +dev = [ + "pytest==7.4.3", + "pytest-asyncio==0.21.1", + "pytest-cov==4.1.0", + "faker==20.1.0", + "ruff==0.1.7", + "black==23.11.0", + "mypy==1.7.1", + "pre-commit==3.6.0" +] + +production = [ + "gunicorn==21.2.0" +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["app*"] + +# =================================== +# RUFF CONFIGURATION +# =================================== +[tool.ruff] +line-length = 100 +target-version = "py311" +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "N", # pep8-naming +] +ignore = [ + "E501", # line too long, handled by black + "B008", # do not perform function calls in argument defaults + "C901", # too complex +] + +[tool.ruff.per-file-ignores] +"__init__.py" = ["F401"] +"tests/**/*" = ["N802", "N803", "N806"] + +[tool.ruff.isort] +known-first-party = ["app"] + +# =================================== +# BLACK CONFIGURATION +# =================================== +[tool.black] +line-length = 100 +target-version = ['py311'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | build + | dist +)/ +''' + +# =================================== +# MYPY CONFIGURATION +# =================================== +[tool.mypy] +python_version = "3.11" +check_untyped_defs = true +disallow_any_generics = true +disallow_incomplete_defs = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_return_any = true +strict_equality = true +show_error_codes = true + +[[tool.mypy.overrides]] +module = [ + "passlib.*", + "pyotp.*", + "magic.*", +] +ignore_missing_imports = true + +# =================================== +# PYTEST CONFIGURATION +# =================================== +[tool.pytest.ini_options] +minversion = "7.0" +addopts = "-ra -q --strict-markers --strict-config" +testpaths = ["tests"] +asyncio_mode = "auto" +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks tests as integration tests", + "unit: marks tests as unit tests", +] + +# =================================== +# COVERAGE CONFIGURATION +# =================================== +[tool.coverage.run] +source = ["app"] +omit = [ + "*/tests/*", + "*/venv/*", + "*/__pycache__/*", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "if self.debug:", + "if settings.DEBUG", + "raise AssertionError", + "raise NotImplementedError", + "if 0:", + "if __name__ == .__main__.:", + "class .*\\bProtocol\\):", + "@(abc\\.)?abstractmethod", +] \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..c189022 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,86 @@ +# Backend Requirements - ServiceManagerWeb +# FastAPI Backend Dependencies + +# =================================== +# CORE FRAMEWORK +# =================================== +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +pydantic==2.5.0 +pydantic-settings==2.1.0 + +# =================================== +# DATABASE & ORM +# =================================== +sqlalchemy==2.0.23 +alembic==1.13.0 +asyncpg==0.29.0 # PostgreSQL async driver +psycopg2-binary==2.9.9 # PostgreSQL sync driver (for migrations) + +# =================================== +# AUTHENTICATION & SECURITY +# =================================== +python-jose[cryptography]==3.3.0 +python-multipart==0.0.6 +passlib[argon2]==1.7.4 +argon2-cffi==23.1.0 +pyotp==2.9.0 # TOTP/2FA support + +# =================================== +# ASYNC TASKS +# =================================== +celery==5.3.4 +redis==5.0.1 + +# =================================== +# EMAIL +# =================================== +email-validator==2.1.0 +jinja2==3.1.2 # Email templates + +# =================================== +# FILE HANDLING +# =================================== +python-magic==0.4.27 # MIME type detection +pillow==10.1.0 # Image processing + +# =================================== +# HTTP & REQUESTS +# =================================== +httpx==0.25.2 # Async HTTP client +aiofiles==23.2.1 # Async file operations + +# =================================== +# UTILITIES +# =================================== +python-dateutil==2.8.2 +pytz==2023.3 +slugify==0.0.1 + +# =================================== +# MONITORING & LOGGING +# =================================== +structlog==23.2.0 +prometheus-client==0.19.0 + +# =================================== +# DEVELOPMENT & TESTING +# =================================== +pytest==7.4.3 +pytest-asyncio==0.21.1 +pytest-cov==4.1.0 +httpx==0.25.2 # For testing +faker==20.1.0 # Test data generation + +# =================================== +# CODE QUALITY +# =================================== +ruff==0.1.7 # Linter & formatter +black==23.11.0 # Code formatter +mypy==1.7.1 # Type checker +pre-commit==3.6.0 # Git hooks + +# =================================== +# PRODUCTION +# =================================== +gunicorn==21.2.0 \ No newline at end of file diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..c38f58d --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,500 @@ +-- ServiceManagerWeb - Esquema de Base de Datos +-- Sistema multi-tenant de Mesa de Ayuda B2B +-- PostgreSQL 15+ + +-- =================================== +-- DOMINIO: TENANTS (Multi-tenancy) +-- =================================== + +CREATE TABLE tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) UNIQUE NOT NULL, + domain VARCHAR(255), + logo_url VARCHAR(500), + contact_email VARCHAR(320), + contact_phone VARCHAR(20), + address TEXT, + timezone VARCHAR(50) DEFAULT 'UTC', + locale VARCHAR(10) DEFAULT 'es-ES', + + -- Settings + max_users INTEGER DEFAULT 50, + max_storage_mb INTEGER DEFAULT 1024, + allowed_file_types TEXT[] DEFAULT ARRAY['pdf','jpg','jpeg','png','doc','docx','xls','xlsx','txt'], + + -- Status + status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'inactive')), + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + CONSTRAINT tenants_slug_format CHECK (slug ~ '^[a-z0-9-]+$') +); + +-- Índices para tenants +CREATE INDEX idx_tenants_slug ON tenants(slug); +CREATE INDEX idx_tenants_status ON tenants(status); +CREATE INDEX idx_tenants_domain ON tenants(domain); + +-- =================================== +-- DOMINIO: AUTH (Autenticación) +-- =================================== + +CREATE TYPE user_role_enum AS ENUM ( + 'ADMIN', -- Control total (staff interno) + 'SUPPORT_MANAGER', -- Gestión de equipos y SLAs + 'AGENT', -- Atención de tickets + 'AUDITOR', -- Solo lectura para auditoría + 'CLIENT_ADMIN', -- Admin de organización cliente + 'CLIENT_USER' -- Usuario final cliente +); + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + + -- Básicos + email VARCHAR(320) NOT NULL, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + avatar_url VARCHAR(500), + + -- Auth + password_hash VARCHAR(255) NOT NULL, + role user_role_enum NOT NULL, + + -- 2FA (opcional para staff interno) + totp_secret VARCHAR(32), + totp_enabled BOOLEAN DEFAULT FALSE, + backup_codes TEXT[], -- códigos de recuperación + + -- Status + is_active BOOLEAN DEFAULT TRUE, + email_verified BOOLEAN DEFAULT FALSE, + last_login TIMESTAMP WITH TIME ZONE, + last_activity TIMESTAMP WITH TIME ZONE, + + -- Preferences + language VARCHAR(10) DEFAULT 'es', + timezone VARCHAR(50) DEFAULT 'UTC', + notifications_email BOOLEAN DEFAULT TRUE, + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + UNIQUE(tenant_id, email) +); + +-- Índices para users +CREATE INDEX idx_users_tenant_id ON users(tenant_id); +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_role ON users(role); +CREATE INDEX idx_users_active ON users(is_active); +CREATE INDEX idx_users_tenant_email ON users(tenant_id, email); + +-- Tabla para refresh tokens +CREATE TABLE refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) NOT NULL, + device_info VARCHAR(500), + ip_address INET, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + revoked BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Índices para refresh_tokens +CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id); +CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash); +CREATE INDEX idx_refresh_tokens_expires ON refresh_tokens(expires_at); + +-- =================================== +-- DOMINIO: TICKETS (Core del negocio) +-- =================================== + +CREATE TYPE ticket_status_enum AS ENUM ( + 'NEW', + 'TRIAGE', + 'IN_PROGRESS', + 'WAITING_CUSTOMER', + 'RESOLVED', + 'CLOSED', + 'REOPENED' +); + +CREATE TYPE ticket_priority_enum AS ENUM ( + 'LOW', + 'MEDIUM', + 'HIGH', + 'URGENT' +); + +-- Categorías de tickets por tenant +CREATE TABLE ticket_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + description TEXT, + color VARCHAR(7), -- hex color + sla_response_hours INTEGER DEFAULT 24, + sla_resolution_hours INTEGER DEFAULT 72, + auto_assign_to UUID REFERENCES users(id), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + UNIQUE(tenant_id, name) +); + +-- Sistemas afectados por tenant +CREATE TABLE affected_systems ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + description TEXT, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + UNIQUE(tenant_id, name) +); + +-- Tickets principales +CREATE TABLE tickets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + ticket_number VARCHAR(20) NOT NULL, -- formato: TKT-2024-000001 + + -- Básicos + subject VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + + -- Clasificación + category_id UUID REFERENCES ticket_categories(id), + priority ticket_priority_enum DEFAULT 'MEDIUM', + affected_system_id UUID REFERENCES affected_systems(id), + + -- Asignación + created_by UUID NOT NULL REFERENCES users(id), + assigned_to UUID REFERENCES users(id), + + -- Estado + status ticket_status_enum DEFAULT 'NEW', + + -- SLA tracking + sla_response_due TIMESTAMP WITH TIME ZONE, + sla_resolution_due TIMESTAMP WITH TIME ZONE, + first_response_at TIMESTAMP WITH TIME ZONE, + resolved_at TIMESTAMP WITH TIME ZONE, + + -- CSAT (Customer Satisfaction) + rating INTEGER CHECK (rating >= 1 AND rating <= 5), + rating_comment TEXT, + rated_at TIMESTAMP WITH TIME ZONE, + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + UNIQUE(tenant_id, ticket_number) +); + +-- Índices para tickets +CREATE INDEX idx_tickets_tenant_id ON tickets(tenant_id); +CREATE INDEX idx_tickets_number ON tickets(ticket_number); +CREATE INDEX idx_tickets_created_by ON tickets(created_by); +CREATE INDEX idx_tickets_assigned_to ON tickets(assigned_to); +CREATE INDEX idx_tickets_status ON tickets(status); +CREATE INDEX idx_tickets_priority ON tickets(priority); +CREATE INDEX idx_tickets_category ON tickets(category_id); +CREATE INDEX idx_tickets_sla_response ON tickets(sla_response_due); +CREATE INDEX idx_tickets_sla_resolution ON tickets(sla_resolution_due); +CREATE INDEX idx_tickets_created_at ON tickets(created_at); + +-- Comentarios en tickets +CREATE TABLE ticket_comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ticket_id UUID NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, + author_id UUID NOT NULL REFERENCES users(id), + + content TEXT NOT NULL, + is_internal BOOLEAN DEFAULT FALSE, -- solo visible para staff interno + + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Índices para comentarios +CREATE INDEX idx_ticket_comments_ticket_id ON ticket_comments(ticket_id); +CREATE INDEX idx_ticket_comments_author_id ON ticket_comments(author_id); +CREATE INDEX idx_ticket_comments_created_at ON ticket_comments(created_at); + +-- Adjuntos en tickets/comentarios +CREATE TABLE ticket_attachments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ticket_id UUID NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, + comment_id UUID REFERENCES ticket_comments(id) ON DELETE CASCADE, + uploaded_by UUID NOT NULL REFERENCES users(id), + + -- Archivo + filename VARCHAR(255) NOT NULL, + original_filename VARCHAR(255) NOT NULL, + mime_type VARCHAR(100) NOT NULL, + file_size INTEGER NOT NULL, -- bytes + file_path VARCHAR(500) NOT NULL, -- path en storage + + -- Checksums para integridad + md5_hash VARCHAR(32), + sha256_hash VARCHAR(64), + + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Índices para adjuntos +CREATE INDEX idx_ticket_attachments_ticket_id ON ticket_attachments(ticket_id); +CREATE INDEX idx_ticket_attachments_comment_id ON ticket_attachments(comment_id); +CREATE INDEX idx_ticket_attachments_uploaded_by ON ticket_attachments(uploaded_by); + +-- Historial de cambios de estado +CREATE TABLE ticket_status_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ticket_id UUID NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, + changed_by UUID NOT NULL REFERENCES users(id), + + old_status ticket_status_enum, + new_status ticket_status_enum NOT NULL, + old_assigned_to UUID REFERENCES users(id), + new_assigned_to UUID REFERENCES users(id), + + comment TEXT, + + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Índices para historial de estados +CREATE INDEX idx_ticket_status_history_ticket_id ON ticket_status_history(ticket_id); +CREATE INDEX idx_ticket_status_history_changed_by ON ticket_status_history(changed_by); +CREATE INDEX idx_ticket_status_history_created_at ON ticket_status_history(created_at); + +-- =================================== +-- DOMINIO: NOTIFICATIONS (Comunicaciones) +-- =================================== + +-- Templates de email +CREATE TABLE email_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID REFERENCES tenants(id), -- NULL = template global + + name VARCHAR(100) NOT NULL, + subject_template TEXT NOT NULL, + body_template TEXT NOT NULL, + template_type VARCHAR(50) NOT NULL, -- ticket_created, ticket_assigned, etc. + + -- Variables disponibles en formato JSON + available_variables JSONB, + + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Índices para templates +CREATE INDEX idx_email_templates_tenant_id ON email_templates(tenant_id); +CREATE INDEX idx_email_templates_type ON email_templates(template_type); + +-- Log de notificaciones enviadas +CREATE TABLE notification_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id), + + recipient_email VARCHAR(320) NOT NULL, + subject VARCHAR(500) NOT NULL, + template_type VARCHAR(50), + + -- Metadata + ticket_id UUID REFERENCES tickets(id), + user_id UUID REFERENCES users(id), + + -- Estado del envío + status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed', 'bounced')), + error_message TEXT, + + -- Proveedor (ej: sendgrid, ses, smtp) + provider VARCHAR(50), + external_id VARCHAR(255), -- ID del proveedor + + sent_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Índices para logs de notificaciones +CREATE INDEX idx_notification_logs_tenant_id ON notification_logs(tenant_id); +CREATE INDEX idx_notification_logs_recipient ON notification_logs(recipient_email); +CREATE INDEX idx_notification_logs_ticket_id ON notification_logs(ticket_id); +CREATE INDEX idx_notification_logs_status ON notification_logs(status); +CREATE INDEX idx_notification_logs_created_at ON notification_logs(created_at); + +-- =================================== +-- DOMINIO: AUDIT (Bitácora) +-- =================================== + +CREATE TABLE audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id), + user_id UUID REFERENCES users(id), -- NULL para acciones del sistema + + -- Acción + action VARCHAR(100) NOT NULL, -- ej: user.login, ticket.create, ticket.assign + resource_type VARCHAR(50) NOT NULL, -- user, ticket, comment, etc. + resource_id UUID, + + -- Contexto + ip_address INET, + user_agent TEXT, + correlation_id UUID, -- para rastrear requests + + -- Cambios (antes/después en JSON) + old_values JSONB, + new_values JSONB, + + -- Metadata adicional + metadata JSONB, + + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Índices para audit logs +CREATE INDEX idx_audit_logs_tenant_id ON audit_logs(tenant_id); +CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id); +CREATE INDEX idx_audit_logs_action ON audit_logs(action); +CREATE INDEX idx_audit_logs_resource ON audit_logs(resource_type, resource_id); +CREATE INDEX idx_audit_logs_correlation_id ON audit_logs(correlation_id); +CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at); + +-- =================================== +-- FUNCIONES Y TRIGGERS +-- =================================== + +-- Función para actualizar updated_at automáticamente +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Triggers para updated_at +CREATE TRIGGER update_tenants_updated_at BEFORE UPDATE ON tenants + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_tickets_updated_at BEFORE UPDATE ON tickets + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_ticket_comments_updated_at BEFORE UPDATE ON ticket_comments + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_email_templates_updated_at BEFORE UPDATE ON email_templates + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- =================================== +-- DATOS INICIALES (SEEDS) +-- =================================== + +-- Tenant de ejemplo para desarrollo +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$... +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', + 'ADMIN', + true, + true +FROM tenants WHERE slug = 'aduanasoft-demo'; + +-- Categorías por defecto +INSERT INTO ticket_categories (tenant_id, name, description, sla_response_hours, sla_resolution_hours) +SELECT + id, + 'Soporte Técnico', + 'Problemas técnicos generales', + 2, + 24 +FROM tenants WHERE slug = 'aduanasoft-demo' +UNION ALL +SELECT + id, + 'Consulta Comercial', + 'Preguntas sobre productos y servicios', + 4, + 48 +FROM tenants WHERE slug = 'aduanasoft-demo' +UNION ALL +SELECT + id, + 'Incidente Crítico', + 'Problemas que afectan operaciones', + 1, + 8 +FROM tenants WHERE slug = 'aduanasoft-demo'; + +-- Sistemas afectados por defecto +INSERT INTO affected_systems (tenant_id, name, description) +SELECT + id, + 'Plataforma Web', + 'Sistema web principal' +FROM tenants WHERE slug = 'aduanasoft-demo' +UNION ALL +SELECT + id, + 'API', + 'Servicios de API' +FROM tenants WHERE slug = 'aduanasoft-demo' +UNION ALL +SELECT + id, + 'Base de Datos', + 'Sistemas de almacenamiento' +FROM tenants WHERE slug = 'aduanasoft-demo'; + +-- Templates de email básicos +INSERT INTO email_templates (name, subject_template, body_template, template_type, available_variables) +VALUES +( + 'Ticket Creado', + 'Nuevo ticket #{{ticket_number}}: {{subject}}', + 'Hola {{user_name}},\n\nSe ha creado un nuevo ticket:\n\nNúmero: #{{ticket_number}}\nAsunto: {{subject}}\nPrioridad: {{priority}}\n\nPuedes ver los detalles en: {{ticket_url}}\n\nSaludos,\nEquipo de Soporte', + 'ticket_created', + '{"ticket_number": "string", "subject": "string", "priority": "string", "user_name": "string", "ticket_url": "string"}' +), +( + 'Ticket Asignado', + 'Ticket #{{ticket_number}} asignado a ti', + 'Hola {{agent_name}},\n\nSe te ha asignado el ticket:\n\nNúmero: #{{ticket_number}}\nAsunto: {{subject}}\nCliente: {{customer_name}}\nPrioridad: {{priority}}\n\nPuedes verlo en: {{ticket_url}}\n\nSaludos,\nSistema de Tickets', + 'ticket_assigned', + '{"ticket_number": "string", "subject": "string", "agent_name": "string", "customer_name": "string", "priority": "string", "ticket_url": "string"}' +); + +-- =================================== +-- COMENTARIOS Y DOCUMENTACIÓN +-- =================================== + +COMMENT ON TABLE tenants IS 'Organizaciones cliente en el sistema multi-tenant'; +COMMENT ON TABLE users IS 'Usuarios del sistema (internos y clientes)'; +COMMENT ON TABLE tickets IS 'Tickets de soporte - core del negocio'; +COMMENT ON TABLE ticket_comments IS 'Comentarios en tickets'; +COMMENT ON TABLE ticket_attachments IS 'Archivos adjuntos en tickets'; +COMMENT ON TABLE audit_logs IS 'Bitácora de acciones para auditoría y compliance'; \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7a296c7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,294 @@ +version: '3.8' + +services: + # =================================== + # POSTGRES DATABASE + # =================================== + postgres: + image: postgres:15-alpine + container_name: servicemanager-db + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-servicemanager} + POSTGRES_USER: ${POSTGRES_USER:-servicemanager} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-servicemanager123} + 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 + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-servicemanager}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - servicemanager-network + + # =================================== + # REDIS CACHE & BROKER + # =================================== + redis: + image: redis:7-alpine + container_name: servicemanager-redis + restart: unless-stopped + command: redis-server --appendonly yes + volumes: + - redis_data:/data + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + networks: + - servicemanager-network + + # =================================== + # FASTAPI BACKEND + # =================================== + backend: + build: + context: ./backend + dockerfile: ../docker/Dockerfile.backend + container_name: servicemanager-backend + restart: unless-stopped + env_file: + - .env + environment: + - ENVIRONMENT=${ENVIRONMENT:-development} + - DEBUG=${DEBUG:-true} + - SECRET_KEY=${SECRET_KEY} + - DATABASE_URL=${DATABASE_URL} + - REDIS_URL=${REDIS_URL} + - CELERY_BROKER_URL=${CELERY_BROKER_URL} + - CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND} + - JWT_SECRET_KEY=${JWT_SECRET_KEY} + - CORS_ORIGINS=${CORS_ORIGINS} + - SMTP_HOST=${SMTP_HOST} + - SMTP_PORT=${SMTP_PORT} + - DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL} + volumes: + - ./backend:/app + - uploads_data:/app/uploads + - logs_data:/app/logs + ports: + - "8000:8000" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + networks: + - servicemanager-network + + # =================================== + # CELERY WORKER + # =================================== + worker: + build: + context: ./workers + dockerfile: ../docker/Dockerfile.worker + container_name: servicemanager-worker + restart: unless-stopped + env_file: + - .env + environment: + - ENVIRONMENT=${ENVIRONMENT:-development} + - DATABASE_URL=${DATABASE_URL} + - CELERY_BROKER_URL=${CELERY_BROKER_URL} + - CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND} + - SMTP_HOST=${SMTP_HOST} + - SMTP_PORT=${SMTP_PORT} + - DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL} + volumes: + - ./workers:/app + - uploads_data:/app/uploads + - logs_data:/app/logs + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - servicemanager-network + + # =================================== + # CELERY BEAT (SCHEDULER) + # =================================== + beat: + build: + context: ./workers + dockerfile: ../docker/Dockerfile.worker + container_name: servicemanager-beat + restart: unless-stopped + command: celery -A app.celery beat --loglevel=info --schedule=/tmp/celerybeat-schedule + env_file: + - .env + environment: + - ENVIRONMENT=${ENVIRONMENT:-development} + - DATABASE_URL=${DATABASE_URL} + - REDIS_URL=${REDIS_URL} + - CELERY_BROKER_URL=${CELERY_BROKER_URL} + - CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND} + volumes: + - ./workers:/app + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - servicemanager-network + + # =================================== + # FRONTEND CLIENT (CLIENTES) + # =================================== + frontend-client: + build: + context: ./frontend-client + dockerfile: ../docker/Dockerfile.frontend + args: + - FRONTEND_TYPE=client + container_name: servicemanager-client-frontend + restart: unless-stopped + environment: + - NODE_ENV=${ENVIRONMENT:-development} + - PUBLIC_API_URL=${API_BASE_URL:-http://localhost:8000} + - PUBLIC_APP_NAME=ServiceManager Cliente + volumes: + - ./frontend-client:/app + - /app/node_modules + ports: + - "3000:3000" + depends_on: + - backend + networks: + - servicemanager-network + + # =================================== + # FRONTEND INTERNAL (STAFF INTERNO) + # =================================== + frontend-internal: + build: + context: ./frontend-internal + dockerfile: ../docker/Dockerfile.frontend + args: + - FRONTEND_TYPE=internal + container_name: servicemanager-internal-frontend + restart: unless-stopped + environment: + - NODE_ENV=${ENVIRONMENT:-development} + - PUBLIC_API_URL=${API_BASE_URL:-http://localhost:8000} + - PUBLIC_APP_NAME=ServiceManager Admin + volumes: + - ./frontend-internal:/app + - /app/node_modules + ports: + - "3001:3000" + depends_on: + - backend + networks: + - servicemanager-network + + # =================================== + # NGINX REVERSE PROXY + # =================================== + nginx: + image: nginx:alpine + container_name: servicemanager-nginx + restart: unless-stopped + volumes: + - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + - uploads_data:/var/www/uploads:ro + ports: + - "80:80" + depends_on: + - backend + - frontend-client + - frontend-internal + networks: + - servicemanager-network + + # =================================== + # DEVELOPMENT TOOLS + # =================================== + + # Adminer - Database Admin Interface + adminer: + image: adminer:4-standalone + container_name: servicemanager-adminer + restart: unless-stopped + environment: + ADMINER_DEFAULT_SERVER: postgres + ADMINER_DESIGN: galkaev + ports: + - "8080:8080" + depends_on: + - postgres + networks: + - servicemanager-network + profiles: + - dev + + # MailHog - Email Testing + mailhog: + image: mailhog/mailhog:latest + container_name: servicemanager-mailhog + restart: unless-stopped + ports: + - "1025:1025" # SMTP server + - "8025:8025" # Web UI + networks: + - servicemanager-network + profiles: + - dev + + # Redis Commander - Redis GUI + redis-commander: + image: rediscommander/redis-commander:latest + container_name: servicemanager-redis-commander + restart: unless-stopped + environment: + REDIS_HOSTS: local:redis:6379 + ports: + - "8081:8081" + depends_on: + - redis + networks: + - servicemanager-network + profiles: + - dev + +# =================================== +# VOLUMES +# =================================== +volumes: + postgres_data: + name: servicemanager_postgres_data + redis_data: + name: servicemanager_redis_data + uploads_data: + name: servicemanager_uploads_data + logs_data: + name: servicemanager_logs_data + celery_beat_data: + name: servicemanager_celery_beat_data + +# =================================== +# NETWORKS +# =================================== +networks: + servicemanager-network: + name: servicemanager_network + driver: bridge + + diff --git a/docker/Dockerfile.backend b/docker/Dockerfile.backend new file mode 100644 index 0000000..b664083 --- /dev/null +++ b/docker/Dockerfile.backend @@ -0,0 +1,43 @@ +# FastAPI Backend Dockerfile +FROM python:3.11-slim + +# Instalar dependencias del sistema +RUN apt-get update && apt-get install -y \ + gcc \ + libpq-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Configurar directorio de trabajo +WORKDIR /app + +# Copiar requirements y instalar dependencias Python +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Crear usuario no root +RUN useradd --create-home --shell /bin/bash app \ + && chown -R app:app /app + +# Crear directorios necesarios +RUN mkdir -p /app/uploads /app/logs \ + && chown -R app:app /app/uploads /app/logs + +# Copiar código de la aplicación +COPY . . + +# Cambiar permisos +RUN chown -R app:app /app + +# Cambiar a usuario no root +USER app + +# Exponer puerto +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# Comando por defecto +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] \ No newline at end of file diff --git a/docker/Dockerfile.frontend b/docker/Dockerfile.frontend new file mode 100644 index 0000000..3d74257 --- /dev/null +++ b/docker/Dockerfile.frontend @@ -0,0 +1,42 @@ +# Frontend Dockerfile (reutilizable para client e internal) +FROM node:18-alpine + +# Instalar dependencias del sistema +RUN apk add --no-cache \ + git \ + curl + +# Configurar directorio de trabajo +WORKDIR /app + +# Crear usuario no root +RUN addgroup -g 1001 -S nodejs \ + && adduser -S app -u 1001 -G nodejs + +# Copiar package files +COPY package*.json ./ + +# Configurar npm para ignorar certificados SSL (solo para desarrollo) +RUN npm config set strict-ssl false + +# Instalar dependencias +RUN npm install --legacy-peer-deps && npm cache clean --force + +# Copiar código de la aplicación +COPY . . + +# Cambiar permisos +RUN chown -R app:nodejs /app + +# Cambiar a usuario no root +USER app + +# Exponer puerto +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:3000 || exit 1 + +# Comando por defecto +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/docker/Dockerfile.worker b/docker/Dockerfile.worker new file mode 100644 index 0000000..85d52d1 --- /dev/null +++ b/docker/Dockerfile.worker @@ -0,0 +1,35 @@ +# Celery Worker Dockerfile +FROM python:3.11-slim + +# Instalar dependencias del sistema +RUN apt-get update && apt-get install -y \ + gcc \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Configurar directorio de trabajo +WORKDIR /app + +# Copiar requirements y instalar dependencias Python +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Crear usuario no root +RUN useradd --create-home --shell /bin/bash app \ + && chown -R app:app /app + +# Crear directorios necesarios +RUN mkdir -p /app/uploads /app/logs \ + && chown -R app:app /app/uploads /app/logs + +# Copiar código de la aplicación +COPY . . + +# Cambiar permisos +RUN chown -R app:app /app + +# Cambiar a usuario no root +USER app + +# Comando por defecto +CMD ["celery", "-A", "app.celery", "worker", "--loglevel=info"] \ No newline at end of file diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf new file mode 100644 index 0000000..a57aa24 --- /dev/null +++ b/docker/nginx/default.conf @@ -0,0 +1,144 @@ +# ServiceManager Nginx Configuration + +upstream backend_api { + server backend:8000; + keepalive 32; +} + +upstream client_frontend { + server frontend-client:3000; + keepalive 32; +} + +upstream internal_frontend { + server frontend-internal:3000; + keepalive 32; +} + +# Client Portal (clientes.servicemanager.local o puerto 3000) +server { + listen 80; + server_name clientes.servicemanager.local; + + location / { + proxy_pass http://client_frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 86400; + } +} + +# Internal Portal (admin.servicemanager.local o puerto 3001) +server { + listen 80; + server_name admin.servicemanager.local; + + location / { + proxy_pass http://internal_frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 86400; + } +} + +# API Backend (api.servicemanager.local o puerto 8000) +server { + listen 80; + server_name api.servicemanager.local; + + # API endpoints con rate limiting + location /v1/ { + limit_req zone=api burst=20 nodelay; + + proxy_pass http://backend_api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300; + proxy_connect_timeout 30; + proxy_send_timeout 300; + } + + # Auth endpoints con rate limiting más estricto + location /v1/auth/ { + limit_req zone=login burst=10 nodelay; + + proxy_pass http://backend_api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Health check + location /health { + proxy_pass http://backend_api; + access_log off; + } + + # OpenAPI docs + location ~ ^/(docs|openapi\.json|redoc) { + proxy_pass http://backend_api; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Static files (uploads) + location /uploads/ { + alias /var/www/uploads/; + expires 1h; + add_header Cache-Control "public, immutable"; + + # Validar que el usuario tenga permisos (implementar en backend) + auth_request /auth; + } + + # Auth subrequest para archivos protegidos + location = /auth { + internal; + proxy_pass http://backend_api/v1/auth/validate; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URI $request_uri; + } +} + +# Default server (localhost - desarrollo) +server { + listen 80 default_server; + server_name _; + + # Redirect to appropriate frontend based on path + location /admin { + return 301 http://localhost:3001/; + } + + location /api/ { + proxy_pass http://backend_api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + return 301 http://localhost:3000/; + } +} \ No newline at end of file diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf new file mode 100644 index 0000000..589692b --- /dev/null +++ b/docker/nginx/nginx.conf @@ -0,0 +1,65 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; + use epoll; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Logging + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for" ' + 'rt=$request_time uct="$upstream_connect_time" ' + 'uht="$upstream_header_time" urt="$upstream_response_time"'; + + access_log /var/log/nginx/access.log main; + + # Performance + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 10M; + + # Compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/json + application/javascript + application/xml+rss + application/atom+xml + image/svg+xml; + + # Rate limiting + limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; + limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m; + + # Security headers + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + + # Hide server version + server_tokens off; + + # Include server configs + include /etc/nginx/conf.d/*.conf; +} \ No newline at end of file diff --git a/docs/api-contract.md b/docs/api-contract.md new file mode 100644 index 0000000..270f3ba --- /dev/null +++ b/docs/api-contract.md @@ -0,0 +1,547 @@ +# API Contract - ServiceManagerWeb +# Mesa de Ayuda B2B - Especificación de Endpoints + +## Base URL +- Desarrollo: `http://localhost:8000` +- Producción: `https://api.servicemanager.aduanasoft.com` + +## Versionado +- Todos los endpoints tienen prefijo `/v1/` +- Versionado en URL path (no headers) + +## Autenticación +- JWT Bearer Token en header `Authorization: Bearer ` +- Refresh token para renovación automática + +## Headers Estándar +``` +Authorization: Bearer +Content-Type: application/json +X-Tenant-ID: # Requerido para endpoints multi-tenant +X-Correlation-ID: # Opcional para tracking +Accept-Language: es-ES # Para internacionalización +``` + +## Responses Estándar + +### Éxito (2xx) +```json +{ + "success": true, + "data": { ... }, + "message": "Operación exitosa", + "metadata": { + "page": 1, + "per_page": 20, + "total": 150, + "total_pages": 8 + } +} +``` + +### Error (4xx/5xx) +```json +{ + "success": false, + "error": { + "code": "VALIDATION_ERROR", + "message": "Datos inválidos", + "details": [ + { + "field": "email", + "message": "Email inválido" + } + ] + }, + "correlation_id": "uuid" +} +``` + +--- + +## DOMINIO: AUTH + +### POST /v1/auth/login +**Descripción**: Autenticación de usuario +**Público**: Sí + +**Request Body**: +```json +{ + "email": "user@example.com", + "password": "password123", + "tenant_slug": "aduanasoft-demo", + "totp_code": "123456" // opcional, solo si 2FA activado +} +``` + +**Response 200**: +```json +{ + "success": true, + "data": { + "access_token": "jwt_token", + "refresh_token": "refresh_token", + "expires_in": 3600, + "user": { + "id": "uuid", + "email": "user@example.com", + "first_name": "Juan", + "last_name": "Pérez", + "role": "AGENT", + "tenant": { + "id": "uuid", + "name": "Aduanasoft Demo", + "slug": "aduanasoft-demo" + } + } + } +} +``` + +### POST /v1/auth/refresh +**Descripción**: Renovar access token +**Público**: Sí + +**Request Body**: +```json +{ + "refresh_token": "refresh_token" +} +``` + +### POST /v1/auth/logout +**Descripción**: Cerrar sesión (revoca refresh token) +**Autenticado**: Sí + +### GET /v1/auth/me +**Descripción**: Información del usuario actual +**Autenticado**: Sí + +### PUT /v1/auth/me +**Descripción**: Actualizar perfil propio +**Autenticado**: Sí + +**Request Body**: +```json +{ + "first_name": "Juan", + "last_name": "Pérez", + "language": "es", + "timezone": "America/Mexico_City", + "notifications_email": true +} +``` + +### POST /v1/auth/change-password +**Descripción**: Cambiar contraseña +**Autenticado**: Sí + +### POST /v1/auth/2fa/setup +**Descripción**: Configurar 2FA (solo roles internos) +**Autenticado**: Sí, Roles: ADMIN, SUPPORT_MANAGER, AGENT, AUDITOR + +### POST /v1/auth/2fa/verify +**Descripción**: Verificar código 2FA durante setup +**Autenticado**: Sí + +--- + +## DOMINIO: TENANTS + +### GET /v1/tenants/current +**Descripción**: Información del tenant actual +**Autenticado**: Sí + +### PUT /v1/tenants/current +**Descripción**: Actualizar tenant (solo CLIENT_ADMIN/ADMIN) +**Autenticado**: Sí, Roles: CLIENT_ADMIN, ADMIN + +### GET /v1/tenants (solo ADMIN) +**Descripción**: Listar todos los tenants +**Autenticado**: Sí, Roles: ADMIN + +### POST /v1/tenants (solo ADMIN) +**Descripción**: Crear nuevo tenant +**Autenticado**: Sí, Roles: ADMIN + +--- + +## DOMINIO: USERS + +### GET /v1/users +**Descripción**: Listar usuarios del tenant +**Autenticado**: Sí +**Roles**: Todos (filtros por rol) + +**Query Params**: +``` +?page=1&per_page=20&role=AGENT&is_active=true&search=juan +``` + +**Response 200**: +```json +{ + "success": true, + "data": [ + { + "id": "uuid", + "email": "agent@example.com", + "first_name": "Juan", + "last_name": "Agente", + "role": "AGENT", + "is_active": true, + "email_verified": true, + "last_login": "2024-01-15T10:30:00Z", + "created_at": "2024-01-01T00:00:00Z" + } + ], + "metadata": { + "page": 1, + "per_page": 20, + "total": 150, + "total_pages": 8 + } +} +``` + +### POST /v1/users +**Descripción**: Crear usuario +**Autenticado**: Sí, Roles: ADMIN, SUPPORT_MANAGER, CLIENT_ADMIN + +**Request Body**: +```json +{ + "email": "nuevo@example.com", + "first_name": "Nuevo", + "last_name": "Usuario", + "role": "AGENT", + "password": "temporal123", // opcional, se genera automáticamente + "send_welcome_email": true +} +``` + +### GET /v1/users/{user_id} +**Descripción**: Obtener usuario específico +**Autenticado**: Sí + +### PUT /v1/users/{user_id} +**Descripción**: Actualizar usuario +**Autenticado**: Sí, Roles: ADMIN, SUPPORT_MANAGER, CLIENT_ADMIN + +### DELETE /v1/users/{user_id} +**Descripción**: Desactivar usuario (soft delete) +**Autenticado**: Sí, Roles: ADMIN, SUPPORT_MANAGER, CLIENT_ADMIN + +--- + +## DOMINIO: TICKETS + +### GET /v1/tickets +**Descripción**: Listar tickets con filtros +**Autenticado**: Sí + +**Query Params**: +``` +?page=1&per_page=20 +&status=NEW,IN_PROGRESS +&priority=HIGH,URGENT +&assigned_to=uuid +&created_by=uuid +&category_id=uuid +&search=problema+conexion +&sort=created_at_desc +&date_from=2024-01-01 +&date_to=2024-01-31 +``` + +**Response 200**: +```json +{ + "success": true, + "data": [ + { + "id": "uuid", + "ticket_number": "TKT-2024-000001", + "subject": "Problema de conexión", + "status": "IN_PROGRESS", + "priority": "HIGH", + "category": { + "id": "uuid", + "name": "Soporte Técnico" + }, + "created_by": { + "id": "uuid", + "first_name": "Juan", + "last_name": "Cliente" + }, + "assigned_to": { + "id": "uuid", + "first_name": "Ana", + "last_name": "Soporte" + }, + "sla_response_due": "2024-01-15T12:00:00Z", + "sla_resolution_due": "2024-01-16T10:00:00Z", + "created_at": "2024-01-15T10:00:00Z", + "updated_at": "2024-01-15T11:30:00Z" + } + ], + "metadata": { + "page": 1, + "per_page": 20, + "total": 150, + "total_pages": 8 + } +} +``` + +### POST /v1/tickets +**Descripción**: Crear nuevo ticket +**Autenticado**: Sí + +**Request Body**: +```json +{ + "subject": "Problema de conexión con el sistema", + "description": "Descripción detallada del problema...", + "priority": "HIGH", + "category_id": "uuid", + "affected_system_id": "uuid", + "attachments": [ + { + "filename": "screenshot.png", + "content_type": "image/png", + "content_base64": "base64_data" + } + ] +} +``` + +**Response 201**: +```json +{ + "success": true, + "data": { + "id": "uuid", + "ticket_number": "TKT-2024-000001", + "subject": "Problema de conexión con el sistema", + "status": "NEW", + "sla_response_due": "2024-01-15T12:00:00Z", + "created_at": "2024-01-15T10:00:00Z" + } +} +``` + +### GET /v1/tickets/{ticket_id} +**Descripción**: Obtener ticket completo con comentarios +**Autenticado**: Sí + +**Response 200**: +```json +{ + "success": true, + "data": { + "id": "uuid", + "ticket_number": "TKT-2024-000001", + "subject": "Problema de conexión", + "description": "Descripción completa...", + "status": "IN_PROGRESS", + "priority": "HIGH", + "category": {...}, + "affected_system": {...}, + "created_by": {...}, + "assigned_to": {...}, + "attachments": [...], + "comments": [ + { + "id": "uuid", + "content": "Comentario del ticket...", + "author": {...}, + "is_internal": false, + "attachments": [...], + "created_at": "2024-01-15T11:00:00Z" + } + ], + "status_history": [...], + "sla_metrics": { + "response_due": "2024-01-15T12:00:00Z", + "resolution_due": "2024-01-16T10:00:00Z", + "first_response_at": "2024-01-15T11:15:00Z", + "response_sla_met": true, + "resolution_sla_met": null + }, + "created_at": "2024-01-15T10:00:00Z", + "updated_at": "2024-01-15T11:30:00Z" + } +} +``` + +### PUT /v1/tickets/{ticket_id} +**Descripción**: Actualizar ticket (estado, asignación, etc.) +**Autenticado**: Sí + +**Request Body**: +```json +{ + "status": "IN_PROGRESS", + "assigned_to": "uuid", + "priority": "URGENT", + "comment": "Escalando por alta prioridad" +} +``` + +### POST /v1/tickets/{ticket_id}/comments +**Descripción**: Agregar comentario al ticket +**Autenticado**: Sí + +**Request Body**: +```json +{ + "content": "Comentario con solución propuesta...", + "is_internal": false, + "attachments": [ + { + "filename": "solution.pdf", + "content_type": "application/pdf", + "content_base64": "base64_data" + } + ] +} +``` + +### PUT /v1/tickets/{ticket_id}/rating +**Descripción**: Calificar ticket resuelto (solo cliente) +**Autenticado**: Sí, Roles: CLIENT_ADMIN, CLIENT_USER + +**Request Body**: +```json +{ + "rating": 5, + "comment": "Excelente atención y resolución rápida" +} +``` + +--- + +## DOMINIO: CATEGORIES & SYSTEMS + +### GET /v1/categories +**Descripción**: Listar categorías del tenant +**Autenticado**: Sí + +### POST /v1/categories (solo staff interno) +**Descripción**: Crear categoría +**Autenticado**: Sí, Roles: ADMIN, SUPPORT_MANAGER + +### GET /v1/affected-systems +**Descripción**: Listar sistemas afectados +**Autenticado**: Sí + +--- + +## DOMINIO: NOTIFICATIONS + +### GET /v1/notifications/templates (solo staff interno) +**Descripción**: Listar templates de email +**Autenticado**: Sí, Roles: ADMIN, SUPPORT_MANAGER + +### POST /v1/notifications/test-email (solo ADMIN) +**Descripción**: Enviar email de prueba +**Autenticado**: Sí, Roles: ADMIN + +--- + +## DOMINIO: REPORTS & ANALYTICS + +### GET /v1/reports/dashboard +**Descripción**: Métricas del dashboard +**Autenticado**: Sí + +**Response 200**: +```json +{ + "success": true, + "data": { + "tickets": { + "total": 150, + "new": 12, + "in_progress": 45, + "waiting_customer": 8, + "resolved_today": 15 + }, + "sla": { + "response_rate": 95.5, + "resolution_rate": 87.2 + }, + "agents": { + "active": 8, + "avg_load": 5.6 + }, + "csat": { + "average": 4.2, + "total_responses": 89 + } + } +} +``` + +### GET /v1/reports/tickets +**Descripción**: Reporte de tickets con filtros +**Autenticado**: Sí + +--- + +## DOMINIO: AUDIT + +### GET /v1/audit/logs (solo AUDITOR/ADMIN) +**Descripción**: Consultar logs de auditoría +**Autenticado**: Sí, Roles: AUDITOR, ADMIN + +**Query Params**: +``` +?page=1&per_page=50 +&action=ticket.create,ticket.assign +&user_id=uuid +&resource_type=ticket +&date_from=2024-01-01 +&date_to=2024-01-31 +``` + +--- + +## CÓDIGOS DE ERROR ESTÁNDAR + +- `400` - Bad Request (datos inválidos) +- `401` - Unauthorized (no autenticado) +- `403` - Forbidden (sin permisos) +- `404` - Not Found (recurso no encontrado) +- `409` - Conflict (recurso duplicado) +- `422` - Unprocessable Entity (validación fallida) +- `429` - Too Many Requests (rate limit) +- `500` - Internal Server Error + +## RATE LIMITING + +- Auth endpoints: 10 req/min por IP +- API endpoints: 100 req/min por usuario +- File uploads: 5 req/min por usuario + +## PAGINACIÓN + +- Default: `per_page=20`, `max=100` +- Links de navegación en metadata +- Total count incluido cuando sea eficiente + +## ORDENAMIENTO + +Formato: `?sort=field_direction` +- `created_at_desc` (default) +- `updated_at_desc` +- `priority_desc` +- `status_asc` + +## BÚSQUEDA + +- Full-text search en `subject` y `description` +- Búsqueda por número de ticket exacto +- Filtros combinables con AND lógico \ No newline at end of file diff --git a/docs/database-schema.md b/docs/database-schema.md new file mode 100644 index 0000000..377adf6 --- /dev/null +++ b/docs/database-schema.md @@ -0,0 +1,96 @@ +# Modelo de Datos - ServiceManagerWeb + +## Resumen del Esquema + +El sistema utiliza PostgreSQL con un diseño multi-tenant donde cada cliente (tenant) tiene sus datos aislados pero comparte la misma estructura de base de datos. + +## Dominios Principales + +### 1. **TENANTS** - Multi-tenancy +- `tenants`: Organizaciones cliente +- Cada tenant tiene configuraciones propias (usuarios max, storage, tipos de archivo) + +### 2. **AUTH** - Autenticación y Autorización +- `users`: Usuarios del sistema (internos y clientes) +- `refresh_tokens`: Tokens de refresco para JWT +- Roles: ADMIN, SUPPORT_MANAGER, AGENT, AUDITOR, CLIENT_ADMIN, CLIENT_USER +- Soporte para 2FA (TOTP) opcional para staff interno + +### 3. **TICKETS** - Core del Negocio +- `tickets`: Tickets de soporte principales +- `ticket_categories`: Categorías personalizables por tenant +- `affected_systems`: Sistemas afectados por tenant +- `ticket_comments`: Conversación en tickets +- `ticket_attachments`: Archivos adjuntos +- `ticket_status_history`: Historial de cambios de estado + +Estados de ticket: NEW → TRIAGE → IN_PROGRESS → WAITING_CUSTOMER → RESOLVED → CLOSED +Prioridades: LOW, MEDIUM, HIGH, URGENT + +### 4. **NOTIFICATIONS** - Comunicaciones +- `email_templates`: Templates personalizables por tenant +- `notification_logs`: Historial de emails enviados +- Soporte para variables dinámicas en templates + +### 5. **AUDIT** - Bitácora y Compliance +- `audit_logs`: Registro completo de acciones +- Tracking con correlation_id para requests +- Almacena cambios antes/después en JSON + +## Características Técnicas + +### Índices Estratégicos +- Optimizados para queries por tenant +- Indices compuestos para búsquedas frecuentes +- Índices en campos de fecha para reportes + +### Constraints y Validación +- CHECK constraints para valores enum +- Foreign keys con CASCADE apropiados +- UNIQUE constraints compuestos (tenant_id + campo) + +### Triggers Automáticos +- `updated_at` se actualiza automáticamente +- Preparado para audit logging automático + +### Multi-tenancy +- Todos los datos principales tienen `tenant_id` +- Aislamiento a nivel de aplicación +- Configuraciones por tenant (SLA, categorías, etc.) + +## Numeración de Tickets + +Formato: `TKT-YYYY-NNNNNN` (ej: TKT-2026-000001) +- Único por tenant +- Año incluido para fácil organización +- 6 dígitos con ceros a la izquierda + +## SLA Tracking + +- `sla_response_due`: Tiempo límite para primera respuesta +- `sla_resolution_due`: Tiempo límite para resolución +- `first_response_at`: Timestamp de primera respuesta +- Configurables por categoría + +## Almacenamiento de Archivos + +- Metadata en BD, archivos en filesystem/S3 +- Checksums MD5 y SHA256 para integridad +- Validación de tipos MIME +- Límites de tamaño por tenant + +## Datos Iniciales + +El schema incluye: +- Tenant demo para desarrollo +- Usuario admin por defecto +- Categorías base (Soporte Técnico, Consulta Comercial, Incidente Crítico) +- Sistemas base (Plataforma Web, API, Base de Datos) +- Templates de email básicos + +## Escalabilidad + +- Preparado para sharding por tenant_id +- Partitioning por fecha en audit_logs +- Índices optimizados para paginación +- Soft deletes donde aplique \ No newline at end of file diff --git a/frontend-client/.eslintrc.json b/frontend-client/.eslintrc.json new file mode 100644 index 0000000..9062750 --- /dev/null +++ b/frontend-client/.eslintrc.json @@ -0,0 +1,42 @@ +{ + "extends": [ + "eslint:recommended", + "@typescript-eslint/recommended", + "prettier" + ], + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint", "svelte3"], + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + }, + "env": { + "browser": true, + "es2017": true, + "node": true + }, + "overrides": [ + { + "files": ["*.svelte"], + "processor": "svelte3/svelte3" + } + ], + "rules": { + "@typescript-eslint/no-unused-vars": [ + "error", + { "argsIgnorePattern": "^_" } + ], + "@typescript-eslint/no-explicit-any": "warn", + "no-console": ["warn", { "allow": ["warn", "error"] }], + "prefer-const": "error" + }, + "settings": { + "svelte3/typescript": true + }, + "ignorePatterns": [ + ".svelte-kit/**", + "build/**", + "dist/**", + "node_modules/**" + ] +} \ No newline at end of file diff --git a/frontend-client/.prettierignore b/frontend-client/.prettierignore new file mode 100644 index 0000000..aafd4e9 --- /dev/null +++ b/frontend-client/.prettierignore @@ -0,0 +1,11 @@ +.svelte-kit +build +dist +.env +.env.* +!.env.example +node_modules +.DS_Store +*.log +coverage +.nyc_output \ No newline at end of file diff --git a/frontend-client/.prettierrc b/frontend-client/.prettierrc new file mode 100644 index 0000000..986d497 --- /dev/null +++ b/frontend-client/.prettierrc @@ -0,0 +1,22 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "none", + "printWidth": 100, + "useTabs": false, + "quoteProps": "as-needed", + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "avoid", + "endOfLine": "lf", + "plugins": ["prettier-plugin-svelte"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ] +} \ No newline at end of file diff --git a/frontend-client/package.json b/frontend-client/package.json new file mode 100644 index 0000000..51f0ca9 --- /dev/null +++ b/frontend-client/package.json @@ -0,0 +1,47 @@ +{ + "name": "@servicemanager/client-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --plugin-search-dir . --check . && eslint .", + "format": "prettier --plugin-search-dir . --write .", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^1.3.1", + "@sveltejs/kit": "^1.20.4", + "@types/cookie": "^0.5.1", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "autoprefixer": "^10.4.14", + "eslint": "^8.28.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-svelte": "^2.30.0", + "postcss": "^8.4.24", + "prettier": "^2.8.0", + "prettier-plugin-svelte": "^2.10.1", + "svelte": "^4.0.5", + "svelte-check": "^3.4.3", + "tailwindcss": "^3.3.0", + "tslib": "^2.4.1", + "typescript": "^5.0.0", + "vite": "^4.4.2", + "vitest": "^0.34.0" + }, + "dependencies": { + "@tailwindcss/forms": "^0.5.4", + "@tailwindcss/typography": "^0.5.9", + "@heroicons/react": "^2.0.18", + "heroicons": "^2.0.18", + "zod": "^3.22.2", + "date-fns": "^2.30.0" + } +} \ No newline at end of file diff --git a/frontend-client/postcss.config.js b/frontend-client/postcss.config.js new file mode 100644 index 0000000..e99ebc2 --- /dev/null +++ b/frontend-client/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/frontend-client/src/app.css b/frontend-client/src/app.css new file mode 100644 index 0000000..a812f2f --- /dev/null +++ b/frontend-client/src/app.css @@ -0,0 +1,183 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Custom base styles */ +@layer base { + html { + font-family: 'Inter', system-ui, sans-serif; + } + + body { + @apply text-gray-900 bg-gray-50; + } + + /* Focus styles */ + *:focus { + @apply outline-none ring-2 ring-primary-500 ring-offset-2; + } + + /* Selection styles */ + ::selection { + @apply bg-primary-100 text-primary-900; + } +} + +/* Custom component styles */ +@layer components { + /* Card component */ + .card { + @apply bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden; + } + + .card-content { + @apply p-6; + } + + /* Button variants */ + .btn { + @apply inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none px-4 py-2; + } + + .btn-primary { + @apply bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800; + } + + .btn-secondary { + @apply bg-secondary-100 text-secondary-900 hover:bg-secondary-200 active:bg-secondary-300; + } + + .btn-success { + @apply bg-success-600 text-white hover:bg-success-700 active:bg-success-800; + } + + .btn-warning { + @apply bg-warning-600 text-white hover:bg-warning-700 active:bg-warning-800; + } + + .btn-error { + @apply bg-error-600 text-white hover:bg-error-700 active:bg-error-800; + } + + .btn-ghost { + @apply bg-transparent hover:bg-secondary-100 active:bg-secondary-200; + } + + /* Card styles */ + .card { + @apply bg-white rounded-lg shadow-sm border border-gray-200; + } + + .card-header { + @apply p-6 border-b border-gray-200; + } + + .card-content { + @apply p-6; + } + + .card-footer { + @apply p-6 border-t border-gray-200 bg-gray-50 rounded-b-lg; + } + + /* Form styles */ + .form-input { + @apply block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm; + } + + .form-label { + @apply block text-sm font-medium text-gray-700 mb-1; + } + + .form-error { + @apply text-sm text-error-600 mt-1; + } + + /* Status badges */ + .badge { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; + } + + .badge-new { + @apply badge bg-blue-100 text-blue-800; + } + + .badge-in-progress { + @apply badge bg-yellow-100 text-yellow-800; + } + + .badge-waiting { + @apply badge bg-orange-100 text-orange-800; + } + + .badge-resolved { + @apply badge bg-green-100 text-green-800; + } + + .badge-closed { + @apply badge bg-gray-100 text-gray-800; + } + + .badge-reopened { + @apply badge bg-red-100 text-red-800; + } + + /* Priority badges */ + .badge-priority-low { + @apply badge bg-gray-100 text-gray-600; + } + + .badge-priority-medium { + @apply badge bg-blue-100 text-blue-700; + } + + .badge-priority-high { + @apply badge bg-orange-100 text-orange-700; + } + + .badge-priority-urgent { + @apply badge bg-red-100 text-red-700; + } +} + +/* Custom utility classes */ +@layer utilities { + .text-balance { + text-wrap: balance; + } + + /* Loading spinner */ + .spinner { + @apply animate-spin rounded-full border-2 border-gray-300 border-t-primary-600; + } + + /* Animations */ + .animate-fade-in { + animation: fadeIn 0.3s ease-in-out; + } + + .animate-slide-up { + animation: slideUp 0.3s ease-out; + } +} + +/* Custom keyframes */ +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} \ No newline at end of file diff --git a/frontend-client/src/app.html b/frontend-client/src/app.html new file mode 100644 index 0000000..37f6474 --- /dev/null +++ b/frontend-client/src/app.html @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + \ No newline at end of file diff --git a/frontend-client/src/lib/components/Header.svelte b/frontend-client/src/lib/components/Header.svelte new file mode 100644 index 0000000..8b39789 --- /dev/null +++ b/frontend-client/src/lib/components/Header.svelte @@ -0,0 +1,130 @@ + + +
+
+
+ + {#if showLogo} + + {/if} + + + {#if showNavigation && $auth.isAuthenticated} + + {/if} + + +
+ {#if $auth.isAuthenticated} +
+ + + {#if isMenuOpen} +
+
+
+ {$auth.user?.email} +
+ isMenuOpen = false} + > + Mi Perfil + + +
+
+ {/if} +
+ {:else} + + Iniciar Sesión + + {/if} +
+
+ + + {#if showNavigation && $auth.isAuthenticated} + + {/if} +
+
+ + +{#if isMenuOpen} +
isMenuOpen = false} + >
+{/if} \ No newline at end of file diff --git a/frontend-client/src/lib/components/Icon.svelte b/frontend-client/src/lib/components/Icon.svelte new file mode 100644 index 0000000..b9a46c0 --- /dev/null +++ b/frontend-client/src/lib/components/Icon.svelte @@ -0,0 +1,39 @@ + + + + + \ No newline at end of file diff --git a/frontend-client/src/lib/components/TicketCard.svelte b/frontend-client/src/lib/components/TicketCard.svelte new file mode 100644 index 0000000..ef08738 --- /dev/null +++ b/frontend-client/src/lib/components/TicketCard.svelte @@ -0,0 +1,124 @@ + + +
+
+
+

+ + {ticket.title} + +

+
+ + {statusConfig[ticket.status].label} + + + {priorityConfig[ticket.priority].label} + +
+
+ +

+ {ticket.description} +

+ +
+
+ #{ticket.id.substring(0, 8)} + {#if ticket.category_name} + + {ticket.category_name} + + {/if} + {#if ticket.assigned_to_name} + + + + + {ticket.assigned_to_name} + + {/if} +
+ +
+ {#if ticket.due_date} + + + + + Vence {formatRelativeTime(ticket.due_date)} + + {/if} + + + Actualizado {formatRelativeTime(ticket.updated_at)} + +
+
+
+
+ + \ No newline at end of file diff --git a/frontend-client/src/lib/components/Toast.svelte b/frontend-client/src/lib/components/Toast.svelte new file mode 100644 index 0000000..779cafc --- /dev/null +++ b/frontend-client/src/lib/components/Toast.svelte @@ -0,0 +1,117 @@ + + +{#if visible} +
+
+
+
+ + + +
+ +
+

+ {message} +

+
+ + {#if dismissible} +
+ +
+ {/if} +
+
+
+{/if} + + \ No newline at end of file diff --git a/frontend-client/src/lib/stores/app.ts b/frontend-client/src/lib/stores/app.ts new file mode 100644 index 0000000..300ea5f --- /dev/null +++ b/frontend-client/src/lib/stores/app.ts @@ -0,0 +1,80 @@ +import { writable } from 'svelte/store'; +import { auth } from './auth.js'; +import { get } from 'svelte/store'; +import type { Writable } from 'svelte/store'; + +// Types +export interface Category { + id: string; + name: string; + description: string; + is_active: boolean; + tenant_id: string; + created_at: string; +} + +export interface AppState { + categories: Category[]; + isLoading: boolean; + error: string | null; +} + +// Initial state +const initialState: AppState = { + categories: [], + isLoading: false, + error: null +}; + +// API helper function +async function apiCall(endpoint: string, options: RequestInit = {}) { + const authState = get(auth); + + const response = await fetch(`/api/v1${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authState.token}`, + ...options.headers + } + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Request failed'); + } + + return response.json(); +} + +// Create app store +function createAppStore() { + const { subscribe, set, update }: Writable = writable(initialState); + + return { + subscribe, + + // Load categories + loadCategories: async () => { + update(state => ({ ...state, isLoading: true, error: null })); + + try { + const categories = await apiCall('/categories/'); + update(state => ({ ...state, categories, isLoading: false })); + } catch (error) { + update(state => ({ + ...state, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to load categories' + })); + } + }, + + // Clear error + clearError: () => { + update(state => ({ ...state, error: null })); + } + }; +} + +export const app = createAppStore(); \ No newline at end of file diff --git a/frontend-client/src/lib/stores/auth.ts b/frontend-client/src/lib/stores/auth.ts new file mode 100644 index 0000000..0823712 --- /dev/null +++ b/frontend-client/src/lib/stores/auth.ts @@ -0,0 +1,139 @@ +import { writable } from 'svelte/store'; +import type { Writable } from 'svelte/store'; + +// Types +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; +} + +// Initial state +const initialState: AuthState = { + user: null, + token: null, + isAuthenticated: false, + isLoading: false +}; + +// Create auth store +function createAuthStore() { + const { subscribe, set, update }: Writable = writable(initialState); + + 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'); + } + } + } + }, + + // Login + login: async (credentials: LoginRequest): Promise => { + update(state => ({ ...state, isLoading: true })); + + try { + const response = await fetch('/api/v1/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + 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 + }); + } catch (error) { + update(state => ({ ...state, isLoading: false })); + throw error; + } + }, + + // Logout + logout: () => { + if (typeof window !== 'undefined') { + localStorage.removeItem('auth_token'); + localStorage.removeItem('auth_user'); + } + 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 })); + } + }; +} + +export const auth = createAuthStore(); \ No newline at end of file diff --git a/frontend-client/src/lib/stores/tickets.ts b/frontend-client/src/lib/stores/tickets.ts new file mode 100644 index 0000000..9442c95 --- /dev/null +++ b/frontend-client/src/lib/stores/tickets.ts @@ -0,0 +1,272 @@ +import { writable } from 'svelte/store'; +import { auth } from './auth.js'; +import { get } from 'svelte/store'; +import type { Writable } from 'svelte/store'; + +// Types +export interface Ticket { + id: string; + title: string; + description: string; + status: 'NEW' | 'IN_PROGRESS' | 'WAITING_FOR_CLIENT' | 'RESOLVED' | 'CLOSED' | 'REOPENED'; + priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT'; + category_id: string; + category_name?: string; + client_id: string; + assigned_to_id: string | null; + assigned_to_name?: string; + created_at: string; + updated_at: string; + due_date: string | null; + resolution: string | null; +} + +export interface TicketComment { + id: string; + ticket_id: string; + user_id: string; + user_name: string; + user_role: string; + content: string; + is_internal: boolean; + created_at: string; +} + +export interface TicketAttachment { + id: string; + ticket_id: string; + filename: string; + original_filename: string; + mime_type: string; + size_bytes: number; + uploaded_by_id: string; + uploaded_by_name: string; + uploaded_at: string; +} + +export interface CreateTicketRequest { + title: string; + description: string; + category_id: string; + priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT'; +} + +export interface TicketsState { + tickets: Ticket[]; + currentTicket: Ticket | null; + comments: TicketComment[]; + attachments: TicketAttachment[]; + isLoading: boolean; + error: string | null; +} + +// Initial state +const initialState: TicketsState = { + tickets: [], + currentTicket: null, + comments: [], + attachments: [], + isLoading: false, + error: null +}; + +// API helper function +async function apiCall(endpoint: string, options: RequestInit = {}) { + const authState = get(auth); + + const response = await fetch(`/api/v1${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authState.token}`, + ...options.headers + } + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Request failed'); + } + + return response.json(); +} + +// Create tickets store +function createTicketsStore() { + const { subscribe, set, update }: Writable = writable(initialState); + + return { + subscribe, + + // Load user's tickets + loadTickets: async () => { + update(state => ({ ...state, isLoading: true, error: null })); + + try { + const tickets = await apiCall('/tickets/'); + update(state => ({ ...state, tickets, isLoading: false })); + } catch (error) { + update(state => ({ + ...state, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to load tickets' + })); + } + }, + + // Load specific ticket with details + loadTicket: async (ticketId: string) => { + update(state => ({ ...state, isLoading: true, error: null })); + + try { + const [ticket, comments, attachments] = await Promise.all([ + apiCall(`/tickets/${ticketId}`), + apiCall(`/tickets/${ticketId}/comments`), + apiCall(`/tickets/${ticketId}/attachments`) + ]); + + update(state => ({ + ...state, + currentTicket: ticket, + comments, + attachments, + isLoading: false + })); + } catch (error) { + update(state => ({ + ...state, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to load ticket' + })); + } + }, + + // Create new ticket + createTicket: async (ticket: CreateTicketRequest) => { + update(state => ({ ...state, isLoading: true, error: null })); + + try { + const newTicket = await apiCall('/tickets/', { + method: 'POST', + body: JSON.stringify(ticket) + }); + + update(state => ({ + ...state, + tickets: [newTicket, ...state.tickets], + isLoading: false + })); + + return newTicket; + } catch (error) { + update(state => ({ + ...state, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to create ticket' + })); + throw error; + } + }, + + // Add comment to ticket + addComment: async (ticketId: string, content: string) => { + try { + const comment = await apiCall(`/tickets/${ticketId}/comments`, { + method: 'POST', + body: JSON.stringify({ content }) + }); + + update(state => ({ + ...state, + comments: [...state.comments, comment] + })); + + return comment; + } catch (error) { + update(state => ({ + ...state, + error: error instanceof Error ? error.message : 'Failed to add comment' + })); + throw error; + } + }, + + // Upload attachment + uploadAttachment: async (ticketId: string, file: File) => { + try { + const formData = new FormData(); + formData.append('file', file); + + const authState = get(auth); + const response = await fetch(`/api/v1/tickets/${ticketId}/attachments`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${authState.token}` + }, + body: formData + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Upload failed'); + } + + const attachment = await response.json(); + + update(state => ({ + ...state, + attachments: [...state.attachments, attachment] + })); + + return attachment; + } catch (error) { + update(state => ({ + ...state, + error: error instanceof Error ? error.message : 'Failed to upload attachment' + })); + throw error; + } + }, + + // 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 }) + }); + + update(state => ({ + ...state, + currentTicket: state.currentTicket?.id === ticketId ? updatedTicket : state.currentTicket, + tickets: state.tickets.map(t => t.id === ticketId ? updatedTicket : t) + })); + + return updatedTicket; + } catch (error) { + update(state => ({ + ...state, + error: error instanceof Error ? error.message : 'Failed to close ticket' + })); + throw error; + } + }, + + // Clear error + clearError: () => { + update(state => ({ ...state, error: null })); + }, + + // Clear current ticket + clearCurrentTicket: () => { + update(state => ({ + ...state, + currentTicket: null, + comments: [], + attachments: [] + })); + } + }; +} + +export const tickets = createTicketsStore(); \ No newline at end of file diff --git a/frontend-client/src/lib/stores/toast.ts b/frontend-client/src/lib/stores/toast.ts new file mode 100644 index 0000000..5f03152 --- /dev/null +++ b/frontend-client/src/lib/stores/toast.ts @@ -0,0 +1,75 @@ +// Toast notification store +import { writable } from 'svelte/store'; +import type { Writable } from 'svelte/store'; + +export interface ToastMessage { + id: string; + type: 'success' | 'error' | 'warning' | 'info'; + message: string; + duration?: number; +} + +interface ToastState { + toasts: ToastMessage[]; +} + +const initialState: ToastState = { + toasts: [] +}; + +function createToastStore() { + const { subscribe, update }: Writable = writable(initialState); + + return { + subscribe, + + show: (type: ToastMessage['type'], message: string, duration = 5000) => { + const id = Math.random().toString(36).substring(2, 9); + const toast: ToastMessage = { id, type, message, duration }; + + update(state => ({ + toasts: [...state.toasts, toast] + })); + + // Auto-remove after duration + if (duration > 0) { + setTimeout(() => { + update(state => ({ + toasts: state.toasts.filter(t => t.id !== id) + })); + }, duration); + } + + return id; + }, + + dismiss: (id: string) => { + update(state => ({ + toasts: state.toasts.filter(t => t.id !== id) + })); + }, + + clear: () => { + update(() => initialState); + }, + + // Convenience methods + success: (message: string, duration?: number) => { + return createToastStore().show('success', message, duration); + }, + + error: (message: string, duration?: number) => { + return createToastStore().show('error', message, duration); + }, + + warning: (message: string, duration?: number) => { + return createToastStore().show('warning', message, duration); + }, + + info: (message: string, duration?: number) => { + return createToastStore().show('info', message, duration); + } + }; +} + +export const toast = createToastStore(); \ No newline at end of file diff --git a/frontend-client/src/routes/+layout.svelte b/frontend-client/src/routes/+layout.svelte new file mode 100644 index 0000000..8fbff0a --- /dev/null +++ b/frontend-client/src/routes/+layout.svelte @@ -0,0 +1,35 @@ + + +
+ {#if showHeader} +
+ {/if} + +
+ +
+ + + {#each $toast.toasts as toastMessage (toastMessage.id)} + toast.dismiss(toastMessage.id)} + /> + {/each} +
\ No newline at end of file diff --git a/frontend-client/src/routes/+page.svelte b/frontend-client/src/routes/+page.svelte new file mode 100644 index 0000000..c533875 --- /dev/null +++ b/frontend-client/src/routes/+page.svelte @@ -0,0 +1,178 @@ + + + + ServiceManager - Mesa de Ayuda + + +
+ +
+
+

+ Bienvenido, {$auth.user?.first_name} {$auth.user?.last_name} +

+

+ Gestiona tus tickets de soporte de manera eficiente. Crea nuevos tickets, + da seguimiento a los existentes y mantente actualizado con el estado de tus solicitudes. +

+
+
+ + + + + +
+
+

Tickets Recientes

+

Últimos tickets que has creado o actualizado

+
+ +
+ {#if $tickets.isLoading} +
+
+

Cargando tickets...

+
+ {:else if $tickets.error} +
+
+ + + +
+

Error al cargar los tickets

+ +
+ {:else if $tickets.tickets.length === 0} +
+
+ + + +
+

No tienes tickets creados

+ + Crear tu primer ticket + +
+ {:else} +
+ {#each $tickets.tickets.slice(0, 5) as ticket (ticket.id)} +
+
+
+

+ + {ticket.title} + +

+

+ {ticket.description} +

+
+ #{ticket.id.substring(0, 8)} + {new Date(ticket.created_at).toLocaleDateString('es-ES')} +
+
+
+ + {ticket.status === 'NEW' ? 'Nuevo' : + ticket.status === 'IN_PROGRESS' ? 'En Progreso' : + ticket.status === 'WAITING_FOR_CLIENT' ? 'Esperando Cliente' : + ticket.status === 'RESOLVED' ? 'Resuelto' : + ticket.status === 'CLOSED' ? 'Cerrado' : 'Reabierto'} + +
+
+
+ {/each} + + {#if $tickets.tickets.length > 5} + + {/if} +
+ {/if} +
+
+
+ + \ No newline at end of file diff --git a/frontend-client/src/routes/login/+page.svelte b/frontend-client/src/routes/login/+page.svelte new file mode 100644 index 0000000..33b9880 --- /dev/null +++ b/frontend-client/src/routes/login/+page.svelte @@ -0,0 +1,244 @@ + + + +
+ + + + + +
+ +
+ +
+

Bienvenido

+

Ingrese a su cuenta corporativa

+
+ + +
+ {#if errorMessage} +
+ + {errorMessage} +
+ {/if} + + {#if !showTwoFactor} +
+ +
+ +
+
+ +
+ +
+
+ + +
+ +
+
+ +
+ {#if showPassword} + + {:else} + + {/if} + +
+
+ +
+
+ + +
+ + Olvide mi clave + +
+
+ + {:else} + +
+ +

Ingrese el código de 6 dígitos

+ +
+
+ +
+ +
+
+ {/if} + +
+ +
+ +
+ © 2026 Aduanasoft. Acceso exclusivo autorizado. +
+
+
+
+
\ No newline at end of file diff --git a/frontend-client/src/routes/profile/+page.svelte b/frontend-client/src/routes/profile/+page.svelte new file mode 100644 index 0000000..04024e0 --- /dev/null +++ b/frontend-client/src/routes/profile/+page.svelte @@ -0,0 +1,392 @@ + + + + Mi Perfil - ServiceManager + + +
+ +
+

Mi Perfil

+

+ Gestiona tu información personal y configuración de seguridad +

+
+ +
+ +
+
+

Información Personal

+

Actualiza tu información básica

+
+ +
+
+
+
+ + + {#if profileErrors.firstName} +

{profileErrors.firstName}

+ {/if} +
+ +
+ + + {#if profileErrors.lastName} +

{profileErrors.lastName}

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

+ El correo electrónico no se puede cambiar. Contacta con soporte si necesitas actualizarlo. +

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

Seguridad de la Cuenta

+

Gestiona tu contraseña y configuración de seguridad

+
+ +
+ +
+
+

Autenticación de dos factores (2FA)

+

+ {$auth.user?.is_two_factor_enabled + ? 'La autenticación de dos factores está habilitada' + : 'Mejora la seguridad habilitando 2FA'} +

+
+
+ {#if $auth.user?.is_two_factor_enabled} + + + + + Habilitado + + {:else} + + + + + Deshabilitado + + {/if} +
+
+ + +
+

Cambiar Contraseña

+ +
+ + + {#if passwordErrors.currentPassword} +

{passwordErrors.currentPassword}

+ {/if} +
+ +
+
+ + + {#if passwordErrors.newPassword} +

{passwordErrors.newPassword}

+ {/if} +

+ Mínimo 8 caracteres +

+
+ +
+ + + {#if passwordErrors.confirmPassword} +

{passwordErrors.confirmPassword}

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

Información de la Cuenta

+

Detalles sobre tu cuenta y organización

+
+ +
+
+
+
ID de Usuario
+
#{$auth.user?.id.substring(0, 8)}
+
+ +
+
Rol
+
+ {$auth.user?.role === 'CLIENT_ADMIN' ? 'Administrador de Cliente' : 'Usuario de Cliente'} +
+
+ +
+
Estado de la Cuenta
+
+ {#if $auth.user?.is_active} + + Activa + + {:else} + + Inactiva + + {/if} +
+
+ +
+
Miembro desde
+
+ {$auth.user?.created_at ? new Date($auth.user.created_at).toLocaleDateString('es-ES', { + day: '2-digit', + month: 'long', + year: 'numeric' + }) : 'N/A'} +
+
+
+
+
+
+
\ No newline at end of file diff --git a/frontend-client/src/routes/tickets/+page.svelte b/frontend-client/src/routes/tickets/+page.svelte new file mode 100644 index 0000000..e5b5885 --- /dev/null +++ b/frontend-client/src/routes/tickets/+page.svelte @@ -0,0 +1,270 @@ + + + + Mis Tickets - ServiceManager + + +
+ +
+
+

Mis Tickets

+

+ Gestiona y da seguimiento a todos tus tickets de soporte +

+
+ + + + + Crear Ticket + +
+ + +
+
+
+
+
+ + + +
+
+

Total

+

{$tickets.tickets.length}

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

En Progreso

+

+ {statusCounts['IN_PROGRESS'] || 0} +

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

Esperando

+

+ {statusCounts['WAITING_FOR_CLIENT'] || 0} +

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

Resueltos

+

+ {(statusCounts['RESOLVED'] || 0) + (statusCounts['CLOSED'] || 0)} +

+
+
+
+
+
+ + +
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+ + +
+ {#if $tickets.isLoading} +
+
+

Cargando tickets...

+
+ {:else if $tickets.error} +
+
+ + + +
+

Error al cargar tickets

+

{$tickets.error}

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

+ {$tickets.tickets.length === 0 ? 'No tienes tickets' : 'No se encontraron tickets'} +

+

+ {$tickets.tickets.length === 0 + ? 'Crea tu primer ticket para comenzar' + : 'Intenta ajustar los filtros de búsqueda'} +

+ {#if $tickets.tickets.length === 0} + + Crear Ticket + + {:else} + + {/if} +
+ {:else} +
+ {#each filteredTickets as ticket (ticket.id)} + + {/each} +
+ + {#if filteredTickets.length !== $tickets.tickets.length} +
+ Mostrando {filteredTickets.length} de {$tickets.tickets.length} tickets +
+ {/if} + {/if} +
+
\ No newline at end of file diff --git a/frontend-client/src/routes/tickets/[id]/+page.svelte b/frontend-client/src/routes/tickets/[id]/+page.svelte new file mode 100644 index 0000000..0a5bb52 --- /dev/null +++ b/frontend-client/src/routes/tickets/[id]/+page.svelte @@ -0,0 +1,501 @@ + + + + + {$tickets.currentTicket ? `Ticket: ${$tickets.currentTicket.title}` : 'Cargando...'} - ServiceManager + + + +
+ {#if $tickets.isLoading} +
+
+

Cargando ticket...

+
+ {:else if $tickets.error} +
+
+ + + +
+

Error al cargar ticket

+

{$tickets.error}

+ +
+ {:else if $tickets.currentTicket} + +
+ Mis Tickets + + + + #{$tickets.currentTicket.id.substring(0, 8)} +
+ +
+ +
+ +
+
+
+
+

+ {$tickets.currentTicket.title} +

+
+ + {statusConfig[$tickets.currentTicket.status].label} + + + {priorityConfig[$tickets.currentTicket.priority].label} + + + Creado {formatDate($tickets.currentTicket.created_at)} + +
+
+ + {#if canClose} + + {/if} +
+
+ +
+
+

+ {$tickets.currentTicket.description} +

+
+ + {#if $tickets.currentTicket.resolution} +
+

Resolución:

+

+ {$tickets.currentTicket.resolution} +

+
+ {/if} +
+
+ + + {#if $tickets.attachments.length > 0} +
+
+

Archivos Adjuntos

+
+
+
+ {#each $tickets.attachments as attachment} +
+
+
+ + + +
+
+

+ {attachment.original_filename} +

+

+ {Math.round(attachment.size_bytes / 1024)} KB • + Subido por {attachment.uploaded_by_name} • + {formatDate(attachment.uploaded_at)} +

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

Conversación

+
+
+ {#if $tickets.comments.length === 0} +

+ No hay comentarios aún. ¡Sé el primero en comentar! +

+ {:else} +
+ {#each $tickets.comments as comment} +
+
+ + {comment.user_name.split(' ').map(n => n[0]).join('')} + +
+
+
+ + {comment.user_name} + + + {formatDate(comment.created_at)} + + {#if comment.is_internal} + + Interno + + {/if} +
+

+ {comment.content} +

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

Información

+
+
+
+
ID del Ticket
+
#{$tickets.currentTicket.id.substring(0, 8)}
+
+ +
+
Categoría
+
{$tickets.currentTicket.category_name || 'Sin categoría'}
+
+ + {#if $tickets.currentTicket.assigned_to_name} +
+
Asignado a
+
{$tickets.currentTicket.assigned_to_name}
+
+ {/if} + +
+
Creado
+
{formatDate($tickets.currentTicket.created_at)}
+
+ +
+
Última actualización
+
{formatDate($tickets.currentTicket.updated_at)}
+
+ + {#if $tickets.currentTicket.due_date} +
+
Fecha límite
+
+ {formatDate($tickets.currentTicket.due_date)} + {#if new Date($tickets.currentTicket.due_date) < new Date()} + ¡Vencido! + {/if} +
+
+ {/if} +
+
+
+
+ {/if} +
+ + +{#if showCloseDialog} +
+
+
+
+
+ +
+
+
+
+ + + +
+
+

+ Cerrar Ticket +

+
+

+ ¿Estás seguro de que quieres cerrar este ticket? Esta acción indica que el problema ha sido resuelto satisfactoriamente. +

+
+ +
+ + +
+
+
+
+
+ + +
+
+
+
+{/if} \ No newline at end of file diff --git a/frontend-client/src/routes/tickets/new/+page.svelte b/frontend-client/src/routes/tickets/new/+page.svelte new file mode 100644 index 0000000..3e23a94 --- /dev/null +++ b/frontend-client/src/routes/tickets/new/+page.svelte @@ -0,0 +1,241 @@ + + + + Crear Ticket - ServiceManager + + +
+ +
+
+ Mis Tickets + + + + Crear Ticket +
+ +

Crear Nuevo Ticket

+

+ Describe tu problema o solicitud de soporte con el mayor detalle posible +

+
+ + +
+
+
+ +
+ + + {#if errors.title} +

{errors.title}

+ {/if} +

+ {title.length}/200 caracteres +

+
+ + +
+ + + {#if errors.categoryId} +

{errors.categoryId}

+ {/if} +
+ + +
+ + +
+ + +
+ + + {#if errors.description} +

{errors.description}

+ {/if} +

+ {description.length}/2000 caracteres +

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

+ Tips para un mejor soporte +

+
+
    +
  • Sé específico y detallado en tu descripción
  • +
  • Incluye capturas de pantalla si es posible (puedes adjuntarlas después)
  • +
  • Menciona qué navegador/sistema operativo estás usando
  • +
  • Indica si el problema es recurrente o fue la primera vez
  • +
  • Si hay mensajes de error, cópialos exactamente
  • +
+
+
+
+
+
+ + +
+ + Cancelar + + + +
+
+
\ No newline at end of file diff --git a/frontend-client/static/images/Icono AS(1).png b/frontend-client/static/images/Icono AS(1).png new file mode 100644 index 0000000000000000000000000000000000000000..7b4a555aeb1ac47b667e27045009aa828851f0dd GIT binary patch literal 16944 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 literal 0 HcmV?d00001 diff --git a/frontend-client/static/images/Logo AS 192px -192px(1).png b/frontend-client/static/images/Logo AS 192px -192px(1).png new file mode 100644 index 0000000000000000000000000000000000000000..853d11762749cd64a7ff0365738cbc3ad3352cd4 GIT binary patch literal 17761 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 o`#Aphar_Sp694~u-zHl0sOA#O#lD@ literal 0 HcmV?d00001 diff --git a/frontend-client/static/images/Logo AS blanco(1).png b/frontend-client/static/images/Logo AS blanco(1).png new file mode 100644 index 0000000000000000000000000000000000000000..65cfbe130074ff950c399c434be8920eb5896a79 GIT binary patch literal 48315 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

&^~n zh|7iG-2K~}GoY`dJL&;XHSHn2b3D6O>ZyWx00rU$4&>ZHeS=v;Xq|4jsmin}2Hu~O zpYIEF?YaCdOQQGiAG_=+@bo3^a!ilaNH?BIycol!r zJw{?gFwjKt`(>-%cBLnBp7?60%HPb^E4O?sq6Lqg{ou#MbJjSv>ON z5p6W6gh)O$ypMp!(TY4XSB7ulfz&XgJ-5X8SP}T$Tq~XlL4APM`fG2dq`o92O%I>9 zAi)l-0@>eZ=MguwMk~YhO>tYJ$4^RtD;&&#_Pj1_@BOOH$;1b!{7&;N+iUliQsVCW zF5F5=Gp%U)qKP&A$5fS#`?d)X2u2x7LN7k)NfH zuNh!~+7H3^!2Bu+Ae)zMEi!+8DPl70WZXG=q~$_8wO4NaFY%c4bkh1Ls|%Xr;M)sM zF@SMImU2b|9`NIq8?POyj%&R@Sx45>&ws}m{anqzxsgwL3TP#QBal+T<>-1?qIM0; z2pa#KxQ8EZ-xb~$4%$~gZL8Or+XU|~ijXH9T_U^dhVVeRte8o8)oqmTtj;Lre!WpgSNYKwI%C zI92x~PBsHo@8~b-#zy9rnK=SND_n8O4IZxjV6dC&c;y)cEdv{u1TFtyZnpjf@&vEf zpWTVZqbsA@deRGx;l{4uxB10)w!cV4>}GhWj27(Dph2OOrgEN`XTgBSWEzj~!+0ug zZ-*njaD6(AHpO(R1G2!y_H?{s-a@dpS4VGIP9B;Y&ErCJt*CPV+%Qq#;2{Air3bf~ zEuP?RxfQf5xZ$-60`l)a$58&~)kR-)LQz{IJtXCc+E8rN3uz+4?42RYC%87pok2ot zGV_A!aR6ZiBsJjR*yHL0UjF*brU(TdJOpYEV%!#)2OJuP68SD_E6l)EZtFn~U~!tL z#iilB4q3Sto4@8QHSzF4hkSt_^(RLCo-UuSic|l1J|Dw-<%7|ja0mi#Uoq3|Y;D4| zWJAlfp|5rJnwN>+=`Ix5Oso?sACF_FH0m{lg()=;d{q*lM9XF0vtX~jk2&E5`Wf3K zzDQsWM=tEyCzclxUo;A#Ky1J_j>~N@{0q3U%e>r&`1#u6yS!JMJP&I9_;KF=I0h6@ z9)LGAdie>MFlrs*K#G*Y$LU@7WicSJXE0*!$((z7&tO51e*Yrx1q~)@3x6zjr-OD1 zutTMCJ@3P45Ks^{APFqnrKLid?yE;#QBHW?9$2P5i=gwE8&m|R{27!acDJ5QG2e>N zC6n!MUGsy*v|DuwgWTcXs$q;*o8NFIRtI%>!)?$Z>R1GWVX#C)o~~w>8?l5S=m%i~ zUl=GaMpe(2#=0phb(1v134-v;DPg+xbuMK$VRnCj#rBmGC?zyYXl%xE*eO_ z4?Y5$&SkdtIuake#NR2bX!_=ccRlhytI-knYh5Z6At#BO11}l_8pE`iMV>IT^=JnG zogl}}{ft`r2fQ@h2Nh310z)e%wArJA76e)^qXuKk*)N2L zRt2F>@OC*T#r}L2*%Ua@0(s#%S>FI-t$y%}2k^tajV`=}qtP&-<`~MA0@;n-+ zE&T<3^%VwWLS-sXGynH&o;_0vv7R~?ot{?y5Z&LsigPl*u&@?-Ns6O2$BFAD>0T}X$UZ4#Wb`; z4FS%Y`^#nVc{1uXWcyDz4xYWs=Qp*g`_ELav{E1=hH4nZr+4K-)C_IiN5-$8^2GSCGFv8u;C=h3i z0?vtVAJ=-hB~pbsPV+;Z8$hMl)`0$AwC6S4XRLH`*AD%-U2Mdb_U1g>3&g@$ff$6^ zhAwcPL*%BK-_+#9byh+nOO>qq!m#Ihv$nXJeAX)vK&K>2U& zI{k88KF~S%NcpRs4_?`$!57v^wPK3|i#5FOtL9oh`yRZZ@#qq7$O9uF|H}Sud>m;k z7ZC1L!4h;qrk!G_?rFS6rQz8SKt`;Sl$y|Y$Pw;6dvrdA+Qakr8;=!62S!T)zBgqd zkQoAEzI0FRlrRWs#2^BMRoCQgT6p;;1miq=nT-fPYYeAyUT#U{Rdn?93Sq$(#pPtM zbg+O|uRA$ly#Z9;h5$g6e;e*Xrqx<<(7uTV;o<@fSL)vpyj`YCp54iSFC8YUE5}6g z=X4{blfip$y?e@wPEmP#X86AL7Hf&;(`9i{8i>?2~4&>Vk*@z)kJREQu{GJdl6YSc>fuMCjZ~Wi|0=)Ea4=o#3Oz=KrYYw zeKn1n`1;`Ghr6&_?hRgkV+YfKA)((@PhOz+Azgc!wdCv9`ICaSc2fxYPG64OpqM6t zX6hlmz9hQS7^`%z-_0QE1t>x=VTQ5j(Z-j9yyAZI-x66o={Oh2X@{_$xFQ|m zkjKTeBX7`HmVeTiQ>!j>6 zGl?{hPzLMpva?5}8)JV^^sL_IJ%DNd12=X~KM-E6RK+#nvp+t=fPUJ7v4wab?Wctw z}yU6n0c`I?o+5{>s|lc$@M?|LQE-qyB~z1I-;K} z7t)L9uR)?-C3zSb?wy(0$yno z;eRllo9#0`Gg*t_t&dK9mDcd4lS%`6rcl-;@<I?fVzt9~lLCSQ-RsAn#$yWxIEYor;)E;?EE`cxxitW=^0B+y z3T^<*lN`rd^cKjqPt#Brg71xtBqZgd`9&5)uaFUC^#1asP;owBcH!(=1p$Q5_Ve9F zFQnz8b#1bZ7muk6^p48!x&uh%E|{zMz(s2WdYyFWB(Lwm#6SqD0>X%;ef|1~fz$J( z|2Z9cv@4{_d-mBp#2a{W-4KCjT;T44WWi|7DwtgRYw@M0S{HROYTMgGVtGJlEu0v) z$wE8LL_p~8ssx3^(Yi6Mi-B1<&9Vb7o#j7 zrsXR)TsBSQwM}%$S4jL;uk!yb+~;c6>D53ijRk>M9dhKo*~6cHMz`J;dJl&pVTR&Q zbcaN5eSCZ)pwj?)*u>~gl@r}Y_d}hQIrH;nQu(8N7FyS~-KPvCfVu)+4J|MN2Qy`) zA%bwluQ)+Hp#O?`gn;)sVq0?<0v)fT{xWN0EDHZ8cmM))J#EK8Jc`sQ@qKsD_qQUdt*WjXM)sT@hq4zoXM zualiE&42ay>sCJ7VQ$Wgq^B6K_skr`1gd`mw>!wb#i{CuK$O(=qvz8~c zZ?dcdY^D-C0ZdT!O16H;dH$n`8|${yxl;r7oyX$gFO+987t@@5+T2k|nyAN@J2h;K zHFf;-)?Lo`4^)S_e>O=R0Jgc_l$_SGT-pYs1|KK#ve%&QMwg%O^hS zc-2E8-4e)B4jvIo?oXbIvrv42Hhii?9n(T<#_H3l`(cd5I4vl3c?g81WiGo;eI{;yo4x2|SGuEe zv#}+Og72I`PC-#joR`rk4O0%Nj^#dro&W|RxhMx67Ee%^q&PeG`>WDZI%EcwV9r3r zW>56g(Ma7~rLuI^gS{5DrMr)FGb5UMKrdae;nUvCu3G~rf|1?cdn0H=4mId6__)4| z#cl`EfPEF$bUk6i+HRKE$s%7(XgB_|@pd3uakcd7xAy$6 z{jauWNWp9PSMW!$*r3n>#K5txwfgUs-V1r{r7I78=7YN_$lR=#X z`*w+N3dXQWCZ2l}Y@)d?ZU5c*zJVC1XuKF6uavO_myG^z{0d-*QDMKnub|xj1_&#_ z#!C8(gJx~{7Bt_bztp0vbq`F-+{kJiIzow~|%u8Qqv z4JY8!fA_eUUG{lVy8nZ6&u-}{lE%^a8%l(a1sQ*0i|UipZdLR3cTJ8I=F_;a`A#Zo z9F@z6{l2eFFpDK-8s?$NQ0`=L;qr0i%3UeuM)KR&d#0jA6$VQ6QwOc+^U|~lmMQ{Y z3%Xpa&>Ro&jh$k?%d>90eoeFIwU{^Z+e$SYi$7|_tCj;9djN~zpjffxL(l7aw3n^J0XA*IY z#jX#hHi3>-lbn_7M~QJ8N2}jNxGL5SQ$wgx1O}89uWz^fedTCBdUI7JY z&j4U9EhFqVN<;>(_*1N5{(pfP(Ig{H;=d2QQLx^)`PNVd3y@vcx0h`hy4U{|lcX?? z%2{#W{!$`dG6XeY5?K%jr$k%w)@mK4e5RO|BD2I$iyHwDFaSuvn?z-zL7=U=b`K%e zR2V4kybg9pqy5UFgqBQV8kTzg((>8S_j~mEd$mPL?$*dwDTVys(NCtk zgot)5H6<2?2sg4RrR88uFa7uYnz&>WK-EVr?(%+@9_{2mV#?I(%DacP(tMSJyR>*S`{By{1G%0R14~TbTOFUTR~MdMNYKd5$iRZy7`3l) z&_<5K(Zw|Gk}H01Jr`W7f7?d>G&~?X^srm}L>vK69*}`1UG)K_xEy(0*zOnm zY}X-ffq>0dEHQ7KhOg=?K<1?$-LkEDc%0XowI)=W#lwAn;>Qe`wLRe%$>ozMV}XEg^o|Nlrnx^6aKr*K63 z+XE0SD*44KCn{1h_}^U1vnUVQAUXvSrjC>I(AL@k^`YJH{#Hks3?7*nz0$s@HsciN z?ctxZ194dDJK+lZgSgs(o3qg84RZTNv^zK(5B4Si{wmngSd5U`GJr72rypr)GE@O{>R=nzsxw>h)6`C1X^aM@w#vxx^7-7KY@J75hO_PBE_z ze|;&)r!GyCd^f)C8xsoHwtPLN+==*|9ObV{tjOb^2*CKcNMW)6sGk#?)^SL1#wZ_! z$`2)dKjosvKpmnX)HoZEDCno0PnrzHy4-2^Z+v>=z8QEu)NSnY!o)8s%VurX0}c8d z&wAEYc5&#LcCt$zsP=VWn)Sr1{mV#U$z+j?mV$PqH@}Tc$yoGguXoL=b*Y; zEhYd#C=3;ZZmwTdwR_bnqi!y$kMZR7_U-CI!WiOO0?%0PV^t+IWavE6c)s!rKI09g z_J(L$gVM=-<>(*E=QH&u7PTG?ax#`2k&NOcTlMcAOIu-;YTs*31$6moah_oO9RW5{ z*jqL!I7MMp4HrO%J$V{EH(ZvoC#kJb*rpH5x!d0IBAvq_Yx29Zv}FM%2sgs)H2$^I zTn}Yz{ajV|L+w8or`)!u;1xwSylni|LWNLAbCh@+19xe41Ft>d(yd=tQb?h9E56aj zLyMM=ip}ZqS)&%?(^PnO8wnvP^S4Qti~VU1&S`s*1Qyqoccb<#yjNGfwXesCmcRf^ z#ocBqBfBf9Li8mI4J~Rz1Mi7);EfLB~Gn<}B>m|Pu%g#p` z=up>3*D|?^xpPfz-yMWdgx6ltsJgFa5y#7y9e0i}z{C<5#_0J=d)N8vEWhluQ(BCM(GL?@n zPWT(UWL0{iY$jN2TU2rATjlfiRekjq{r%0rJZt-U7K*rD{ICz%^;~dDRF2U-Gk1Qx zWADe;AhRKd#XzJ=rt)n*wFt~fLx?^eI9}DaRuh5U`ddDzVV-Yx_0#F$Ano;O8qhd|8?Fsr@WfzX~%~|_C@@BWo__OoO`BTv_u}oI8 zKxRlbqO3CX?en|Y2~Csf?phI6;N}X7X)LXZynPI;MK6Q#^^->cVZwduEeE5)Q+IyQ zRsbfK<>Sd;eD%^{%_#Es_RL&5+>ii{^9)crSP0Y9%}R>7lsyIZUnzdP)`KrC zu3bIk<7X#fzd?6WBF68<;~0L`ldsaJ?XSsoxk?UtH=QXfSJKX9)BfEbWb&u|oPlz2 zNxlTrkdxre52)km?-3kyXgw4B%8E@QzFY0{L6-MD?g-t0K0FCoq|n3#YSQ6In{8Fq z-NS3%wC$r&^#a||ewv1~l4T(b&|?)Us1rdGXR8l8y-m8cT!&}xaGOdybe}XT3#Sl% zSFVTX>1Z43pL)Jlg@FnvINa~yoLez15cQl#eZc&l{_ad-U4jfUu~k;k&p2c?TKMbH zLm5gcSZ(MI5|rH-q`*?E3^T=G4*1ddM!k``yX(c~Q671qW8Vh=gF?9BXiJ z6Bz`7Zpjt#jNeL55}vHl;@@k6*?D(^j}rFnCS;Kt=!2C6Qk&#z1ZVXFWg1QoA0oYY zbI|w5joCcaEvqDddQd5Xd~qKWykX(nfM4Q3U7h_ZAJ1cl6Th?#z2vCWO9`ToV>~b+ zl=S@1Y2H8>ZR=rpJ6IN>8JBiI31BA@ZD^KJn(ZL-w0{>>s~3x4x@(;LhB1FHLc{z~tM3DM4iYtz7o86@N4bie1$Hyl$W zdsacOMDD!F^{W6Noq9fAT3?JAY`JH64KFR%^n$Y!U>?kxKE4iy8O5KlETvrktJoiL zW@ie>{Fd<wxFObQ2s2m_ zQx00&t6DbuMUPPx&|sUK=I&_t*jVA;l0SR(GTziaoShVZc=di3`9Z!!ntQ2+V)4DH z6)Q>$&C!B6K4^$hXtBOS6isXOcA68z8p@d+6xMpdKN)z)A^XTCX7#G4(i4tK+#Rsk zucq5XrF?;U`U~d`var?$XH+KQr4j7r?kZwZTgWmGyxhLau07A3sujpR3PJ6{oXF^y zC1b6#F=tNw$JOZhl(|3wq?rea zAn{(%uqTW`R~iYzh7e#V??l-D=CU_Oxp4dxAp_{bMy!X|X15CuxzGj6@<6LgT6?E4 zk|WbVj*rj7jud}-k+1WeS%Vx|_j5bJ{J}u8Sqpk7P+>C>>B-&h^vB+Q!RCEJ7Q*ae zm%`#@_Cuz+GchC&dF~4-@HR0bY(GB9_@0K`aLO06w^%h*InaW+=t=eMBxebhwdWah zeKDX;<%fJz`#s#@jY17}#V=XPhkJKhb=R+Y%(V+4ha2#92`N~Q#hyTvqd&nTuq2a@ z3C755h`Ov%g^~Vd0VkJtngItohz>2#74|o#hQ0l2n3$v_c;MbpC0RMRoxRJ?i5`YP z*U-F_j&;1$zpNQ#;q6g3^IqlB{^J8JQcbQ8(5RB))tgCurVxPwml$gW5ei>hVnKx8 zpe~e_Geq+*XDo+=l8X|09+|f+#|VG)WzhFE*3Xp1Ht_Tn512SWxw0C&C znz!4tB+>v<*w@s-Od5+RS`rV)6cDRmc?E_K9#1Q8{@`*KTi8$Vjz%J{ZoVKnqfg2Nd!$=6@2wWgseYQjF<)f5WfwRKg9C!|G)e1XA!qgPQQ^5w?D7R|4<&{+^j$C zxAXjm_Xo!Yzn&p(tysfXWchvw|><^Jt<7jm;JqU`UEs#Q(B$7k<+*z#gNadmip zyY{5DL>$IS&15|3c&1!sVZW??Z=Z^p_{6p?@;R8HuQqZ=Pr0GG_r*g1efyrQXZ`Th zqzkT^j{v71|GPH(Mztq+K>Oy7=K;Q^N;gf!1t7AytfW5I?-}QxOHZfAO56^hO2tOI zL-JG`3cNYn%^*LeANMBp29Heu%oY)sN(Q#QAs>%p9FGe<;pg4KQr;?aqqwz@apm}Q>NH(MXQeWp}(5&f6<9beo`1ob!*zNT0aqNY|yRM5-%EVY*YId&UD4Eul=OaC4yR6G zpSX;aQTB-)uO#e2CcgV%x?&5}D_C(yZ|*Tv>y0bjrY{*T4^P&$ifeT$GiI2_OK#?h zuRq-k%i>CjFKZpS`EZYlq|XfWUMcvIt#1fD;N`DO82Le)gjev6@J#ru5>OOHO)#*4l4wr)sV6aVeEQPn~z zN+cO;-P(lbv@kkAUTSa+5Bww91)-Yno$3Fm6V;Jbu+l!*-RJEo+#4&+cS;)9f{tfxwskuQcNWib=MYAIT~bE} zgQ#5Rm1{lTIpmP^K$iaN6M-j(7KtCHM6C3Fg5!#a7*yi%{viY5@$bFT!QxncvEjSb zGUwwqg5~#XXF@R3M*aSDE-DF+EsmIB{oTm-qI4w@Zae-9`$CHOMHaFeRwmGHu8Wk9 zWFoo%|H;L*nP)Hb#gQxQ{T2 zZ1bK>_q+OT)dN@Gdxtzy*DU;%4$D(Pz+d72CBk6wKvu4!7uK42Y~S;zu) zG!1#3p~qyk3l4J+R@}zVkmtbN5u15|49J;<8J@Ob*%T3?MoS{_MPv+F1rH3r`H97x z6uF&h)cDzUH2cgGx|yf$cCLx1)lNI9CkuN@Wj=p9Y6;otB>UOX{FC73N_y3sXTP{U&+>NY#(|qW_%9Pds+*coJH4<$8i#(xm-QAJT zNV+mo8qnu`^^r@tM&4}QRMrP^Y5Ksu#w6ddM);RwsDbsi{J_3AABc{Ehnm$$6|9-v zddi(p;QPxi0FHm?at4-&VW>GADqBU%=HRTS6Y2Xi0~^3u)c$7v(#qA$*917%3M+$R zyb*#u0)sOe!Ud83FABftgO}jFKPX~hhW=`<@a1iCN3)xHw?NF5`#N(d^N~jQ#Hg1B z^lOIcRfVMbzVi(OS-vqCT+pgd0V%E=5@OWxW! zlG-I1BS)k(&|?F2(w%@vj3~_fsUrU<2kT|M83Ba-QWqY{3h>B{Wb7~f$%`$af7h*6 zK$b{D$*-&Y!K38yI)#kA*|0ED&IM^pZz?mcU%HTnG9Fs%aQu&RRTZe~Q z3d)%5&+lt>T!}_}H8=<9Eg`R8B54S&bjzb&HXnv1b7*;7Pbo$k0B_j2QEM5j;I=4A zTr*%?Gt}&?@4B?HPe&I$YF5D2B zLWveYCeCI^F0N$zyGnohT)H+F(9Q{k^21AaK5BbABSZJ!tqoY06~za@&56xrix2fz zpk9~ng(UMEhaGa*#O&y_hUCyE%Xx?Ew^~`}N(=b~vrPq6Mb$dSJLKXJuc8?5>4l}c zXbSF68v#l5adkDL{5XHL!O~sIdRf7T-R?r~xLsR6NAe^@fL?$)^+;#w^@WcjQ$D^5 zuUiJBt1E#9or}bKwsNEC210@KctR2gsZo8|VDiuRIR71o0m(Fga@i1QGygcfvkjE| zv%btvq5aj4Pz#CVooVGjJykbyhW^bEKaOIjZN&6#h(s59)_ou#`Vh3_s zc1zD46?z;PB{LoeaJ?Kn$A7X^IJ77ZI5`ew-t+LddnLD{;e!U9zJKu@L2u9wso0%l z6tMz#LB8B__J^hvPMK048sw0P;oB10sFeB9AyN3i!%fE(o@)8j!AfR8V>$B?DJjm2 zeofXwFUF_~gk$hG5dC50o?Yc{L0s%>Jt4_skyrGRxWAaaeN$ypl?3))3djRC`jDY^ z#jv)EKJZy|*0;YdWd>H-sN0JJp`SCkRQw{ z*V9`#*V9k+sj3qf>!}p#;-P^Y<2SZ8mX*E5=udgGDw2w0zqc!T`N}@!Y~n$^^^$62We1b)pSz53ljP+&Z^9 zl?S3j`AAFCs>0V^d^+8=j0w2#tt1@$2~pAMqBxcteeK_1q;SD*cTjGT`yj; z>t;cP^tArIrBuFw-y|c8aA9@)3jI8?-#UYpqCv`wLW1F|-@IUh3x;Xv(g4&@`KkdM zkv#=dwCyXskZ)aM8Dt*uW7IfV5Pmlr7QaGvqyx?!G$R}*Cr z^a(NF482r{$D?aSHe>N~*MCKGHb;f3P4m49W7>oDqd5$&+xzt~sycqzzN4VQ158@d zUL&is4)+836ChlzF*%SKdF7t=v|&v&0=AUzO;B8WJ?v!dpjnky)9h-nLn>=ZqKMU5 zh&VQvAt527e%s`x{TZ!Vzj?)yf^5f(+abXV<h=5e+_Ppdkuebli3*<9H;2o)yeQY>3?IN<{BS;4$LWI2 zLCY8EPJR~fsRE1lDhYP{38JGBYN8DLgcb2gc;Ksz!9Pj%la_n2u8+#k&j;lz7pm|0 z>?%9;8`2uMOp&grHN?`RgQi?rdY?0e)=PD)!CoC`k+7^8%qL}iQG%x4J-7w$U0FFj z!h-lrLx+&b2^`n^AhY6BjOS_aAx5QN))JV%H0Il_(P&-$W@X#_6vWD){(x=w=yP=+ zJ*_0*`J{IXZ&t(Barovj|3$GT^chL)C$wG2SI0Bc%NN3%7M4zBw-j4jW47S{5IH;5 zU%X2u2I|$9(q??U_1Ljdug)9YIIZbS&%|P?#`?Fk29}>h)l}(wF(*oUF#)Q?qrsYF zd04P*_jm_*l==?faTf?L1oY(p$1-3s7v1$6Qi(w!QjEDsB)jQujHEhs&!LZYh>?{10h*(n^#%`EeXS}@H2KE zwgHy99E^#cF@6W!2CFsD_~}3GwDIg!S#J9Orz99WN_phMtQB+CaqZ;)nLViPp zRh!7-ugLC1Q&d*#rjh%SNi@4hE6gjMkqFn*Y66yUs_%d28guFYPWld?mikEt4;uaq z?yJUng7ckqybV2;C^4_dQAa1Q&d2?zeVXt0N%t3x1Leygny=7@=ZK$?-48j@Y z9&~k?3tm_fz+fS!vYTkX5VbnNXz0etzV?4Y{as-3!l-d%_wvWc#Px*#USR)bs;&Z~ zWk-0)MjFU7!_Z>V7m&By9rQp|t!j2$eVAahiRx=UPq;uoS%}jcgfbIOBA77dS923G z)24%5*6_8ec9&3SI4t;@zG|`H^HZ9_<8WE(Lbs!pJ-E&q*racK*n(lHb+~) z2Z(6}kK;G6!P=>a!=2QNbJrcZG=Nui%%V7X;phK!^ZmoK?8efJc*gDeJ zzUCvfz`1!JeX^>jEsMjiOZhOJ57G2( z;a^Yfgn2G}I+&-6N$2vn%F7KA|QUOx&c-aTf_Z-+dZ9$g(^JB&SR z)bsEnCGf>)vF;55yinB&0!Unly1?{3xyK{CqY10yCrexTQM*F2c1*3&M4ITzOE_N` zhp5TR2jx0*&%J0GbMqAtofn_C*i~uLb=0r?6iRL)E}*O5)!eskmK9OG_S$RAZ~hFKL8tr>e%mvo~;cK8(+?p*^fJlz_M}m z>}cst%wezHCt8t4e}8a(K&b~*@sEX6vT8NyPSX8fTtaJjxv+{Kuc=tQ24T=;F!KyA z;QOdyh&c$iCdp{!Pvm*7jNXJZok8bIp?e*C1=@@Gf&q24>~wOQl1n_b=N|hI^RhTyknId^BSH>b91fKSnm6r}K6?}xHE*~d zoxIjWUGn5l$9rXLqFdRf1l16Lar!pZ3tex zBL&R+&n<>xy}0uPmD6Xp&|6xN!vr+K&6s)tOTuv}Bou-^QPpGc;QHdV9F;v^1t9IDe_1HzwX)O$6*iajWa$p$JQdd7MW9xIEw@F-&^%5Ult59F`#?7gul7 zU*!uzmh-aP=-0*qKT!yw+fhAP_;MaLX$w7rjMr{qw}_ya+q(LNm`K41K0J}N|4!za z6hbSk@!8a|lve2@Vwngudjnt}W#6KiCrqLP?=L-=FTCdnzde=|lEXGG;c<20LQr=I^_m~7RuSpt zyR;FZb5)ZVk?{FdI%9i4t#RG#H{CNoK=ay^G>1lM2l`3jHDGVkZa%d+Y3r{p-zV#p zJvQpCrdjQPhTbZV*`mOu(R60$6w*&9JV%P^#F@d&=hSPAJYBo!XQ9>}cRx<8wENyD z?X99*N#UrHo(NY~VgTDdoC~A!T>0&dSm{mneV_5HaH+qQ97H_#TZY}L!_+1IMVxD9 z*wWU>rsRS_1y0N^M;02#N|tEHD*Jgs$&j$$V%L%3*(Es?Zg*3a4v0a<##HWDeWyLN z>(UHNH*ZyURc%MECRe}6;OO;k@?P6?@AAjDm#n8#5<2u05XtK@Ka7o-S*ptpsDba> z#iOieo{o0GOrofB)3<2X@jj&oHKswcEXN!+YoX}6k`sl(JDA}GDK2{|_62lDjPzKH zYe}gpxk||oRg<7mcHGR^<5?{j?@K)anCHXF)yCLT(48+S+k&NKs}GM?&qbpg+)40a z>L;`QLTZ%!rdomw?*v2Qmy%F#4_r+;t1OS6Iy=8(}`m`6ry zLNoAi_0ywB#(q%!p+!~Sii=d>#y_2x8iWQY=*${co@`?1ymGa7-(bYdLf6U-stmoI zDnW&Ut~iH+=truq`9K&4+Hb<2ykgoJL+15sFU^?O6|oGCBNL*apD+oXt~b^R{%Uzk zJ%=xXR&K4lzSC;FCc`-ECCm7}Vo%;wB56m&-*l1PywR2APycS$Uafc0`DUlbj(-8_ z-^>0c7vSKD#IhhjS#<$riFE4X*XrFWi(1)y3&I&U)109J^!cmTtl*BEYO)FTvi7;i zh@Z-7TxZ{nxiT7u5A6a>NpD<4}*0;qaX&( znL2<@U=dnF-c_rXT#GF?$nvlSw5C=cqXjMK7W@-U7=|sh0 zi|GNEqydX}Vl|{xG%wcmpeKzXU-ecCXFHmn4Cp$fBHQ6i*d>Kkpm1$_0L~mflCjuZ zQf~sAG==qxR90=G^66h+P3bbh?I`S7sH#XNtI#lAg*CQ4z)2Ck>B!LdbLQyg>3@T$ zmaF|0ljxId&OHAG_BuLjEvT{ZzR|eVZe`O7Yh+?rDa3t5wE_F!ECY<=(@Uqq>Sh7` zm=<_)xcKwzf1OewzAXE2UdYU|{hY17kd}5Po2WU`aaFwLi3>2twU752?O5f`MRPTh zbZln}?`NlANokQ=t=o$A`YIJkQLLdx7whT_gu+T08Rv;q_)GeMDLX0_r7qg=C=$-Z zf_G`vmJe3pI`xT**N7{3DdfjNmps>WNeDGa&99j>`&cx|F)KwZr6f!<`1^_2<6`1?Hs7~x7($qG)S)$gQ_{)zW?(p>!s~nKq4+zl<85E1Iy}U)lY?2+i#93j$Eu4 zxZCC5#}%Y5D)E!4{*>oo!95pj%ba~y|ItZuS&`XfeG*Bol|?5x_>B|b9~Wh8;%ADe z7DgS7*nbT(Gs(56dmQXo4qF^bSS0nq%30jhL(Rfdz*Fre*u>_0-Bu}j!w{aU&8eB! zQ&udLx>~lKX|$^zSNv(UwhT|xUdjAv?FNLwGFN;YOnBnm(odCK$G>kaRD`c=`nBWC zvfDn%p80T5kUSuh+k*qlPF=Wze^`M226bIM)GfIUANWM8ro7h5sN07dl6zltWaQ~B zdJnF4)?ns}_-HZeWDw_EDd>0&RYSWa3y+jRT{(w&it(y@>~ZpjFmJ5mpVqZwn*rBy z!QO#?ApJ(1DeDzP=qnE7MpS-&RZ;{1m&MNSI!RC6oG=rn#RIZ=vlV*73v1^}r^nn) zk;gVWCY8pM3>>NL{<2HGW*Y}<(2dMm;bVY# zwYFw;M&vw)&5e|2#ga^Tx7+a>h+pXB@H&4Q?W`R>oZQdDw=kO9p1B%`#H;Jpyhb zfh?qtmRjUrj9c^ysO%xe5khk?DMp>kY0F-DIuntV+TvN{>9~ZL8)#S_M`k>Sk#Ec# zBz^n5aDGt7bm6u3kViMp6xQw}-3#bN8HjIMCqz`G@U>PWOdf>Xmfei@z%$%;G87D+ zUoMi>0jg!Dg=(i3{->ec^D$}5FIz`(q|KC@ik)0l8s_8rs!-W-mmh>#I+?dsBVLg~ zm4FG;Gmin2-7OOLK%~1Ss_e)_c0!ZhIWmF{LU!%7f+k zz0C^xV!X?r?2J8#J5Iiaq^Pe4#Nq`XPj9evQW4%F*C(m25+JctP^c(s%5zyiLX&ol zKqS5t#Wx3c@A~01#K|Xwwh70EkO<15DRiD)-)x3N zk`ym!XiiyaM5+;0a12O+2D*j?x?4Or|EWf)0ugPKCWjo?Om$R7q=$U(PK*U zBz~MfUle%b^x+}-QB`(%RYFUbXV>tWKG^Q&ida?2B);OG>ObKv6y z&HuWGu@{K~irj3|Oic3H!H@Ea9!?ehT=?MY*JLi}ar2!mpe(RM`om9jvJm^8o@NWj zYiH8+<%-}%bIS@kSC_FUPByjlY`%(f{XiA#%$|s+xk^tnl_KLKko@f@jsnSiqL4xy zD(KT*A=x+#aU62}QDjxpoXDkd;laV4KJhVde@l6liXB5>2&uI=B#O(mrFjvCn&;9{ zLvj?ib~&QG(LGaGUSU!7eq0y(=?#oj)W$JJW2vgYqf(|-BYRF{O75;K(afof-@3Mr zk2E+@`5sqYWUvdzD*PG_+{9Y|cJ3W>25HZFz(>*S5B>lpm(6*Ey2FOzO1~~Shz-AwNJA2qs?7!95iomMdmyh-_IN=pR=>ta;uom z*OS*C2!c}%ZlKwQ(@MSJJ+D;~O6HfKAPmNLLr5i!_Px|9x{t7g)-?09{>4y(E6c-k z_tL6%Gr`jh1IB7rFyX0O)ReZ`nDB(h9mGckX&|}ap-Zb)oY~fRC{OinVIgewpDy3e zje6759d`UpWzwb)4&K0-VhF_Kk6E5l0w10_X|+FtMyT&!nK2bEL}Y~xM4{+CM%#|8 zJg5b$8CJ{++JD4U)v$V|-!T(u`pt^XIOZlfu1B6|oYkVzlKcbyxjtlQW~f4%Fvbpt zRtoj4iTULf+eC}HBus4CAbfg7mo0oF<|o!xmapt4rr9nr=K{i->`xD&wEBDiFzmSA zcA$OsWtFTltoJ$r9vvbWTrQ`{8*`CTH0+{rqCSgJbcJ71?KN5dNEry(OZLZKC?oMx z)bT`r@&F9Jx~2Bpo~^No8OYx^3}Jeeg~lXsOq#`iX1CO1^dLv0vEiqx-eUM;WH()L z<^XbjbAtGCqqZk&E?qhVq#!DKi>|38d+p9Zn(_Ap*LYN%r~U-eR5upaWs#kYSg*me zn=Y=**|0#5Wgig+SWN#BXjX9Nn^hN2H}l|w#M?pA!0B8~3y~(t&?Uu?A%^t+BfVdM z^tEA}*h(Fo0@Ib$mfVYEpB`>q?%h0nRHOqEshAoA6=gqcDRxtOoc+c{C+AA7#EK!2 zrd{vq0uO2)Y97<|S>wV_Z$AzbQAwwIdSr{iGp#X+;TQgC{4adR0A!w81!>cHY}Dm!z5`_P(#QcdK{B=N#O2{77@!hFS!Z|?vVFF%#pjoWW6utPd^Kf$Z32L z9p79~?d3c@+3BG+QQ!v>ebaNNBDo85_8l?tD+{^&0kI)LpCS{6ka6pT$b7r0@;N?7 zzY$C2zG_p z^%ZxLw)^f_x<8pTq4sv`6wo00Re;UAU`~`Xo;D}7@&JjJLPPzf(O_mS$+zvTID)@v z*tx8B1s+d?N+q3AQ62N(Om@*X;r^xQ^K1)( zVy=Jw4iIc`PC)g4I$s|28j`3&h8#FEk_2o3G>!oigMz4ml~jM0L!#LD!^@OMM~*x} ziN>@Bn%0&br&(EJpWECTk1q~q_(D<&zqJ2)l!Jd^M}v}Ex`i`s{0-q>rWk+J7_)aX zGMPBX8+S-OWH`FQkq_uduR&dB>JPdRDg+TuyXo$Snlbt#5%}Jw&#Hy(nKnyu`;m?*cvNtR!@l7jQGHW}AdE%`8d&J8R-##e23}0ewF;E_BeKw6AHb24T`p z(X;L1dL_Rkh6W+{|CCG*(U%1 literal 0 HcmV?d00001 diff --git a/frontend-client/static/images/SOPORTE.webp b/frontend-client/static/images/SOPORTE.webp new file mode 100644 index 0000000000000000000000000000000000000000..5d771116385fa1078c1eae1cc05ef915b22ba3aa GIT binary patch literal 48978 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

  • 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

    d;A1&)3wgx|EN6BA8E@O zkt(QXe6fO7BZUVnn-#K)BOOlxO4#CyWA(3u(xScr(Oy>cknXa4d(8(HyR!$Qn4ykf z%^y*3Xjxob2|LPjvwTtkF!nm0xlRS3R?b~4JCH1;)Z(F^L{q$lGYgXvzr5Podoko> zlq_&<(N`O&6gF3Y?VwEzVI}6KqJsrsx1*o0jpN!An0h0CA~9tu9wDl82X=0|$>B>` z03aNpgwq1#7mal{AH_52)>!ON9~*&(arNi=Lvh!k4{t^ViEC{r7W0X;xvqY@8_p3Q z!7!{1Xr`*t@4Z1FF*^erKMyjF2=u@N`DRf?Q1)sMXWHjhkcKx#Ma4c=m6MKZ@AEc7 zFkLyj0l-w#*~H(ILrcebz+TEGT9|4%cXDgp$4r7+y!INPFdw2MA-%&fn)b=iN#a(X zT~~6SRERvNlB`LuOC1qYp>8(fEmQk|#DIIKH?VZDG(!cI21JDQ-ePP?dsGX)6HbB_T7FOYLzgoqV_Y{rr)7D;f8W(_K(=?6p45GkQ*uXn^;Lb+BRR^A{SJP zc(eG?qELxVC=2!hyy48n5QCGvQ4Nbq^h~Qd*K*~33Om5g<(QqHvqnM#llo=yh_L;= z!g!1g4oP$A=8Vu|^hjMO`j*`FHYM}Md_1jdxO9}l*!#NsDgPkh$$5)D zPolh8J&Z`TZMszs5>I=x{FDW>nXnw|x@o9Zmns>D{CI_ihH<=QTGx+nG(osJc%^bE zthkk!W6>zib?>*Cj?GNH<Lnv$oTRuuEsCvgX_J-eHFbK+*Y2B@_^@^IEoY3IXL$HT!y&o+UK z*$bQc7@?pr2>85$;PpNfX0QbOE4aWfU|FvrwPLIx%bxi43c%P6O+tfP((DI(7^_017 zI5%e$`>|Gm+wLBUM7uQIR$!^9O&Ki$sbiRv6Giy60sB})Zr~1eImM`L8S$;D1xic4 zBYCj116Fd34$dMCQ|@XhIN0{w7s!}r`S64??WS!DtH>s>w5;74Av<7t-aqE4)&KKk z0TYn+b8Ja><1F^KPCEs|EZ;c^JwU%PA$WJp^pQybGC|HRtSO0 z%e?1XYh4u+yPAxfc8xYxta#^;{Y|D>RIXlzma~lN9Z^}Rn}Tey%u%G1_mqoDE1x)> zSKhCVq{p+^MT-Qk6h29i3+Z|R_$80R2XXZ9T`b-*y(BWC6NcTOMREevW;qPiG2x(4 z_0FosnL&-!UseU}0?XOvEH#ACXhC=b8}}nk#NTfYvR<;{T>)DQtVx6G4su|wK}$Vc zO1KZ58W)A9Ae4>xtU{Rl?v6+@7kEr}NOoKM(#TME&dheQ!7_*BtQ6$kCU0<2<=b%y zn;*Tl_aW0rZdDmQszbiB?I3v?)XDfwbJiWN4AaR85_7hh)^&S>@^{(Z!s0}`1m1%R z&|EU=CgkqlkCg6GjaEfBejd$%C%L^5)HmXkq7`jFJip=LYk#nz)JJw(BFdiM?kijJ zt*kQM?2D|<7tH5pB;vYU)acuW<+c< zdMIHq@`WyE;Ut`e;6k<&?{*AqQxz_LS4_#3@uK;X-Uhp7Jq-wCZ5@7m=7WA$j-Q}? z1Z6>v!C1^_cI64&8jpc3bOr)kg3o`^O+ird`>|eKE`j?XbyrUEg^lY*RRmHMKU@G> zow;-l!O#*z&H0CFL1(6b+d4O0E10Ny)7|9L&$$|5tt+wp84;*`-L4=mPL?3_Iv=g< ztF&bEh<7#ClnO|9;b(9CrLr6bYKoOZ2fG{~_UU^#l$GQ((Wl_vB8Kp9td^LyfpL7n z%GrQ;b>ty6G;YT6X{Poyu-R}W=)Gx&{a@8UTq%`J@Lt}$zT!-3a$&jO2KM(Qz&RC^ z%E2Vhn<^#gv@EYQ9rELYT%NC3)o@c3UX4?gTV$o8Fm@l*vvkT?c5yh(B0yH?{hc4W=|8Bo03 zN!bZwPP2*3_XF`?tJRGH@yDl~<5<@#CAShO%5Rtv3mP^+YJYkp}#=O>^G0b=}e_KwXx z*IO^lB$oIIEAZ~k^S__07DuHx5ni<0TspdyP&VY&L18+@-^D55KpJD+XW17;28h%<7o&yOz01gmK23G}>KHjS>jvck3(H&mU2d-m8 zqs$7&kLu--ehdR~)JBQ;7@6*5sFJkZbEn`*H{4kSpSL&@5RA0Xi34j$o_Np3wr zUPLGCvWEHgox}>ub;%bZL@wClYGoR5O_Y^}3bO0Ad?bN~G1fHjt0OF#Z$)hX9lh_a zm-8SUESkA*a93ui{t)l9B8i+91q#%Kg=L@7zQ(=x#P1W__S_b8WQ~8kr;It(s9i#I zbTY6(tgjaK_FU?ro0`XGKzBrKN0i|}3c&vfL-E5B7HkJ}a7*Fs3*h$r5_Ecx$|LEy zfbgnE8`N{-!h8Z!|7!Djo7O8_3hp09sYaPK?_me^@QAuIz`Na9+YXs!wsuSrvRMxl zP}r2&=9e0vx2NK{$J3t(MNZ6QPDc4NzEjX2=pfm4EpL9&!`#*7eCgCdmQSr*eR2G) z@RuVJCE8#@cXI@0d}Z$2iO9%NVUgH(luD?l5J>hQ+=M1=t~Br0YYu2X(3PN z6IHu^yh%4RWE^dM>G56@(qtYVP6e27nXKZSax$84uEICpGu^KQj0>UyQ17&ZTu$>b zrL%0`JPRxu7VBHlgxdCZfNen)D*ggVJw>FDXovCW^_VQ>CdO^FgI)QKICFR5eYhS; z`uhOj{_3vd;(EtKlo}Ax%D}9{N-vt5On;Yke?tw0w2G?bRAru@}epDW3tgh~d3s z0P=x=OU`!E9T6Z8ytuEGO>PWz`%Jq>#W0whuv?3vDK(UG(z}<0kHYMO z8_JyKxFK_*0Nft@u<%8VvEyXT1ao0Mkb9H~8b2_KhzUIQ5r-_LLT_*&WKP?sVY_D6 zoCuA{m7pYa@*MWh>%)-D@y!K0(FyZ9e~XR_LYjitAu-Qw8xiOjHj*3H&+32%x&(M5 zHbV>~M+b>$tXMs$NH90Ayv(!^8+gxl$qj1{0UEOgyXoYVrp*0~U$ox^#ptrZ_=M!m zduNV!BW9dvy0yTI>sdGFZ0VlCu!PeeG_&~*O#-^s&tG7Ph$PH)XJ{6qOJQ_+J?dRxB@D=>RB=n241DjV$l89r`KzR}^)0ioGs)ryCq@ z=0*=QBvTgM%M?Dm@Q0i>F5fIi5U8H6W83x7MP19vY)tGe?~R zU7}_zw#?Q%Mx8^79D26hDgKI*Eg^2a+)e3+1q8y;q6>Ie>~nY`r}1B@CT{Rwh9UFV zLBRkXO%DGy#OVf`yQ?~*;C(~Tq`VQIr@3@}XouO3${jT!cKfH~-4gy*F9u^(b3^Oi4J>V!KbIRmCL@n&LCI|s}=#HT#Q}1vdc13S| z<(o_dHdu(kfUoo2uxlt_M+RIqP-P~`%7K`T9)pM#rK9PQzcxAtyZwY-IHe)XrQ(pT zOL-lktU2Mpc{5B+iIcTxNwdUp-N)J3Sm55?A~KTMYS5dQ&qjF$IIh*Tmrw1lH3pj{ znNbtSPJ~^#xhNK?(MQ^>WS%S-k?_!Zb1C-;>if3VO-Kc`-EJTl`}Sx}84lN{wLG=_ zXXBm(0)ahwGqlF&+;8JdneWeD2jpH+Waj*lIXO8iHfHEvzm~LNRHY_k48@K1mfNgw z(2=4NiN}I;LcY*Mbue5Rg|T+V1LYhxpAQ%EO~Z^qo+-PQ!#0>>UJ7P4_hm(;fK* zvK?LxMf@C@2Uf-gfn|#)foCTgI&W7#XTnF2i?7#&h;Ber>U*W2<+OOnychk= z%s1(Y4i{jIppP0gDq;<_Zw?Fqdb1!)hMw1NDEJR^G-wcDkmkuM#JF1p4RCO}9SYP8 zns8Yqa`(Q9{x1S9yI5Stz~osbPlb;7*(3!=6F<?i0eQ#yY998E`BhQVwxT;H8&P7z_hM9k1J+$+?lM8{PQWG@_BKI_|H zwC8Aa_KDo%rjXF)(TA)#rEa*a6FyZ?QxaHz7Q*1y>k9GJ;XGgjGR>dsALI!HHQq)Hs_mF7spAx7B&88uV)AdPpLwbx;xFe0ay_)E zO8R>v(-dWK#B257ClIRY0n^jFgFy?K$3-g2)spHUc4Q24`0Scv2=}z@=g+1HHCKk) zqbBOtLYKM9I#Krxj|(jb9F`OZbM+V;-E%=rGvH$K?K7vN&p+qdi*1rppk=6)Yz`KP zE#!*i3Ov`m6lcXp2^2T#D*_q9Zf4^o?Q37x1arb4oWQ#6{X1-A#{dOAO^(^2r8)H~ z@)?{^C=By$a9q|;dsOfo^3B-;?Qk*s9kQFetZ;&!s2VY~9rra}U%8L4zzY3B<>R#a z7k)`y$mYcKA_Qdxk|mRg%C#rL-nqDNe6#nnQ0 zqfurIGpr?>lVg{5C!UTL2+TS5(}`8GYi=)aMU0Y#lipS11NM^sb&@>{m`XjK8z<$F@@ zw+3`aJo6uyf0Cq85?~;{48dbTLY{@IzK8$3YCTu$<4C`jv}@!f5Lr#tX$}$C4QmM& z&mZAqxuw0bd9AuYwe6r?0}nozk7&C<8zkMqb3b;Oun?nIZ6-!VRn7TqCwkm%=GMmu zi&b1`{>H$p3u_H+S)#=|P%{glj=(2F;g(s`$OPw+Tp3DY6e4V$q1fl?;TB#`jx4Ft zgt#sBBydx!72aXcg_M|yi??3z(-nW@cc8F=x(dJi7*S#t7=t>O5B$*QcMpIiN!Al3 z(S)Z;3%O}d8~rz(MZ|ID;2>(cS1{cloCB%f4b)OXc&Y;oEL3M{i0Zf!R}(v|gkcQx@-K?MVZ0;?LoQ9E2cls&IZuI~2kto% z>!D%jx|C7k$XqXNa}9`|r?UY*OhnI#d^G>apFkfrygaLO3)k@vx(`chPz#JL_p=e8eOVzXdjo-17#?#@X z&lk-!*&ciQxTrkuA)dOH)HG3)wnc~g-#tm|0WVw!KmhOpeI1cab1{!pqd+45Fqpzo zqPhSGJ+XI=AdNTB5)>Hor^mS?r_NDD?A;u3CLX>&RDXffVZL3p{rNjQ@g5L#%uO#l!0UX%W*gz(gk()5;%1ms76 z#XTT6Bx&UXFkYcE&q$wdd$jJcfDw63A&j2$*F2?ZMIO(nV(z8&vea9UkW9h4y;QU7 zN0iON$Gln1!HA$(H(X{FqxV+^>W&rlDKXq^X!V2)@8Y zEMx)B3%c_^y0cxyT|cB;eN^X^dKh9CrckUto3l!}j#sU%Q5diROhuf>_pp019TmR= zkH>vV$AJ^3O4G5ipXJy_X7lPf5saE#DJcH&9i^RE(Zn!5_lX%M1Gj16MI@I)duLZW#Q!1RkzW6-FZxjC>7VxeP;c$EL zUCdIyGN39M*v<6?Sh6!0-B5V7ajDvqy=tK(-xSSV1!rY8`l_E$V*;b=R*b`bY7l6m zW&My}VII|6fAc$rk~eH{1f4Vv9A^%wuQWQfXeWvq>bfW34b*npr|Lf;P-TAsf%%e= zH(ijSnLLq%L4?iO11vGVujI1x?Hbtz0Y66w$GE|3sD2WkXdZaCntkEEg(CBv=-pSLHC}nYxtVgz!>2~;WcqdJDOZt&C(KA)5u|%sln#Ww?S|GKmevm~ z#`hIrPls+M@HxJQFL1KZFep6I2xN-;p9*I@n3^C-o{QP~L^4*F^!wqRETPTO29-KL zD9*@*rc?tcy;mGLgIRT36KNBfuJz4+CPJP4L9m;D39C$p;O3o*^ZAmXTgpg^2adzS z54?80mbEU}{Sp-{4e!7Gs*1~J$}=@glHG5%T!(7w)p>5s;w=q|qRSi9dj#&g+GGUb z2|UZbfgBTU-X1)7D^}q-%~!A4%KMB@e1_3cQ+isYSZQ7v$9s(y(%T(zE!7?7JG@>DW+ex~WE18}ZT z+O9zBRT|Fbg3N_W{3cf5D_k7h$ay!q*=KHc{7gt6vY2I&L&%e8bB4`j>9&cUczW|j zgeO7+xm|b0Xc$h+m$s&Hdl@8lap=z1?`nz31k4?JwzK%91(m9zb~nO}VW~Boa(oY+ z#gF`Ixl1PBwYipk9xc**zIVr?si~V%qnzfDEc3YQ5TKlhh?&l_F;%W1Qyti@cxQo% z3|lxq0n+<>%Ay9;N##c{Yv69Z-UrT=m_RH;ioWYX2g(Qn9~=n!E~Df^bVaPDdXu}+ zeS8iZ!J8MRsJFlRH2N~hg-x-(pFg)k0b$>Eelu$xHuNtE@{Ee{VswR=s?+bXw$6>q&iSb15kzs8k!bfD5 zAjpT91C0ANG?i;_X{^%6-R@%8KFYe(F^u?y_u_;P=A}phBdtI9gilKS(<-FJe}5mA zH*O#AyD;NOx^IB;decJ^;RHq&4sR(V{7G*Y1fQzdoPkQnyJeg%hzGQH$_bPl=<>n3 z^kHRU2AQZwlD32_b0?w>a!2reFpC;b|KRd+660K`=^rVOsPkr6E=P{5kzV|sr~%+8 zlG!j6=r2*>dw@L&=@@K&!qf9IFxH5{fRMcLaPsB}n_J{={G}L4MXD&XDV_H{;j5wp zcdO+9>umUx+3qmLU3bacIl}7!^Nk|Z-`db8k6_mZd1vT^fXd`U{J>JHVj&s*At#Dk zyx~dyYRiKqgJ4N6WLS1xW<`Lgh&+WYx`ra#{seVeIG%ad5Zp3-g_0k#)6^xjktE-p zegY7X`U0ii!k5uwc*O=4fd-0XS57e1WXHvdbi%5<0^Gy)?M^Y_M6YcpN_aH^V=DZlRJ_g z=?LAHx*KE5t>``0panNp5cCJc4QLpAG#RL+mEI*}_L*a1tY9t(2%O%!V@g&hVVpgk z6$<|_PMtR~M}*V@!B9!|>KPgRQi_u3YaWZzJ9}LlMWS)fpmp;!pl<=2L>g*|h_2`; z`4;i>yC{bANprGcF}kndL)pEUNI7z!p8cT4n+~jea*;`@IrZjg3vp+D6`?_uFJ#5_ z-KQBWcQY}|)@qJ)tD+`E%F!yoT}S|g_qy=7dwbDuy8i|vl5{^g&-naDJ%kyOUU&bh zl=1wl+Jq)9&i!`(<35QF2s;kcE~q$)zY}Z|aZ~1>{^fGB_Zhk9szecAcfv+)9RFeu zNH2f;vg;wFuKGsj*iLaS0m$o&c>RFg;XxYmwqp<+O~e z*%A%oQoaV8V*TlBd;OA)OR-i{Yqf}@ABV`I=$w3Z|69_;sVl?y=?DA&2b5vHQ?Txb zgu!Y3W0lxarE@|e5}SMx;h3Be1Cw|-eezHKkCg7s8I}X9i=Nv|{D5d}P8cB#yy=c3 zO7gn>r{pZBO395pzLur-eJ56xxtEz z%o+FqT-j_3uZ+1J7@SXxx2TX8<=C?Ev0O+VKKesU_c?q2_M?qX?z!^q#(bo`f#Pip zs)zVEd9q8wbBgG@nj?(#Ue8>{w~UT3mP|Hq3WmkaEzxXRBW@p8nE8w;0*~jw0U05| z<>DQeJGr|^LhV-=i&I0C@Q?nKZQtbW=WK>=4F?cf)$;+rc)Ww&V8D=6cOP{s=^);; zJM)2u{??R0#5dC3igQr;jt}V5)2&1UE(MPw4WVgX#6TzFh9o>~J|D0yN@NLWC_c3> zr!1i0s6PRp#{+F7BY637POc`kjBcph^X&Mjrk|`TDJ-EzHA5>Gbj@6% z@bs<%Fw=h)w(Yp<^?C6CweJY3J!1E@vM~-gNZfOge_GQ~1blh!C9ZLv=#g!=6cHsU zh+JZN;_@YIM`f9M8i;0rBmJy2SFxHM9?`U0M4Uh!J?2z)J%es$SgHq7qEFFQ?Q;P0 z5lWa0JAYpAnP-9bg+lJwhM6t&!o7F#(N@DEv|~_enSrRDk+!6xW`8lAZ_Dq39%ZD= zB}8+HGes%n&fkX1i_A+1DQ)zCeBjNAYcv{Gy{g4pgIyIiJzY>R4<5>M zitQ+cTP6Z=Q2>966CF>@Exv_L580j;UnH3*g5mrLo20WnAK*`*IB+phc9Fe5H(q;O z_ye-?ThsoZxY8SSnNfw$M~QP__cHs^A!mAPaNcfM7J>x~K;-xlQd!J@hd=5rx`?fsKU-OO<}Nc91usS0{^(NIIl@Y_i+Owe)|@( z6EZ^u%8QoZi<~;6w3#WhJBKc3*%K9sN)T2H*M+*b=Z7yV*1k_VoEkA{DYukk;j6;r zLHS`mGycB5l-OD%1BQ5kb{M}6S;r|zEU*bNVo+64s<*?bn zWLTPi)ueh>-jCkp)Is^}VBR5{kX>XDc_#6gyuQBhN1cX&I2|PMXvp~6qCFAW9cj51 z-tmEFjWG`mVu*x?BhH-H0#8+?^yWk3`7%UV59XUVlw6f=qphhUv zuZQCk81i*crr3jeJq3eF*IoexL!#)Evt*J69>uH(2Xa4?Ju`d7+#l#hbb@_aTP}XB zOT9EQBdj~bxVLN9pIRG`;!MO22u7w+)UUNphCM8TpD_%__g_yLOA=P;vIw@1UrPq- zWSM)IQ1s%fQF{eX7^sPgnp5Yw2banwPgHIJ@jSrKmR(4m>~6YhoWpkAY}F!n`^pjJ zg}S}P_b$pcnz+$R*(;Aqxh9jnqzl$BsvVR7d#m$cw>?|p@YVUz) z9&b&kqz#cw4`C$2rPG3$>*~x-06sPmGgCD~;sPY2!WbjUkD3<+)1vW@saersJI%Y* z3*}Wk-FO>lD;9B_`@C#Qw>uw>YQ{aOA>wNKKrAS}aS(jua@wrjEZpY)Tb)B|!Uasr z89`RvV!YO}W*r)!1QV6ZPG@;|I^l)1S87-W2C=%nY0CG&NO}nhw)bRq)g!CSSc~uY zLxbVchF6tl@+|mZYhXC7*7ohMsiYq;wcCdL&85nZy1kT@FZ6=vDGu^C#TAT9!lQ@C z>l8<;`-w{~-St3x%AxX?0s9*XuA9e+3|9j@MI+Lz?-~4T&Tdv`iKvjTJvXBa85R); z#K1=hF93K@x#5oP^JM;lLdGn{({U26IhzUpBbAvIK&}MmG=Yr^W6sQnf^(407s%mc z7vWsgN@!rZibKx6iVri^-afJX*NPK@HqL=4{;#}B|M$q0^bU^vyYqWzp>Y|=sgZqB zVtcR{*%3XEMBzT@vuY?o5odtpc|7o`j!gp49TJiN`#kK4<8#+>;}Nn4yUPbxh0Thi z*Ycl)_ftT}5XiGEgj<}!AS)bnmaWcTp_c@x){{P4~NfZlL0PvQXUkPd?cD%UZEU{!vvNL>r6oK4MnL=XhCI3ZLR z7Hag|Odq|L;1ss0>a^RGJ6t1n@f?6H9BHm4zk(Yt z1z4~86L2~)NHnH|<_?xE@DU^J!0!C0gN37BsFwEgU3x)29nY6ejTwi>cZjR0Z)rn`b~z9d=Y~q1jsfxZJ38>rgfKuT^ys zQ!7Gj{1p)bRm)4F0DZ0$vMG%Qn&!-F{nQW*DxTT<&eO=~FKwh(plmu?;{6Vv6Nve+ zqSgrYd4qnhWB!>JR?4-iJo#j=(euvf5xf?(mw-K1xWwtsY+A zZ^*xBtPML<>CAY)GcuIegwsvO1=f{k-Ev&i3RKit$OMT>tnoRnX!M8#x6SwH=r~-3 z`2djsGo{Kg0T^6C{?81Q^OhNeS`w&p3kZTyhoW^S^@LcJmy9|7A&Zaa_CdTqc%p+- zYO~#?LN=8t%Hr)3mNl4dLz|`0q7cPj_sgRg+24O2^GpSGGh$g1$0hLrYA88T^P_$! z#n;?BB`Xkk*aDXW`Z+&Ad;kM;R4P|A$#@`l8F8=~;_JAEyF!LL>WOC$8_=%xgH7l; zz0NDB&N9d5zY_Zc4toVRsLB6mhbL z?=CYL!Z-s(a+e$ZlVf!2TU--MEUDSGEr^FRRfzT9^)r8gCYbp34CW2`)@1Ksjg$9# zzyfNDce85hF^)GvYVRio;$N-9g)$$e5c z#J;P%-%EEs{Pe>Wmbt}42+BCsa{H?wjlO{p54=%_%v+SKdm<6rU9G8&7-ob63E=(| zlsb*jjDQ`Q7pR^8#+50{Q>2}BO!U`5Ghk=%#DdgYuRW28_T+CA128@t4-X5zh%30u zo5zx1t_=qjPs$|=AnrorziQB+`@jM18bc+PHS$55yk3+v9nJ2@4WINhqY=ELnY|Kn zh|wbzGy2s#Y+-Q0Qu)tdGgd_~t;|4%Z;?q7tDZ2l?Y4C+X@S$h7O4JBd>zZG(8+mG;yah4Uep zYqW>aCcUL<5ps~?s@R`|q=229OtFO7_4*qBx{e_OZ?dqk;fwP!#RPbtX&&aJuZLGr zb@dqS9+NL&aCmeL?^Y1qu`bjp-X=SY!u@iPi4OuF&Z{hI*FEsHNnlGWwc09afuCy~ zMZ;lA9{%|@XaTmven1G^uFRv#Vsab`7^Grjb@kic@F?v7`hEz`Y1?hgEbbNE z1H0jLmpI5h)AX9Q9$G}F~A%=!>J`v8d7USTM zAqY<6t-mR)0sKO@KQDg^YoDRyq{Kp9qk#n#(WPslccv5fK4b%8c zTn=6IHfY%E9a2p7%)seNMWE?#sL(&m5#-#qyBsHWnqug@aABMzUI=LP$R)!6Oz9Av zb@>OozaTOj*KpUH=yRSLIPJpMu)JMS6CSYbY_dH>pF2@DU-WKSrT%R8eILpDm-s2W zmOB0R%_+s>aLbx@Mql)?ZhAb=PA^B2dS#e$rDl4LyhvV`Z*T@;znL8taI(c3{ed^F zK*v(;2n?c})Yux&04n&_>pYIo7D10^)(YC*07g57?I5Q4npOtfY?im_v5RJnR!CZ$ zp21bEqw#!AoSMd!U-|=q;5t9OX!Xq4V7sB684dJeRWJBn6ui4YveUF3XoVLkyhr{K zMFxe3`RoiQZ`myMtFPq=sf}dA`>j>Gxb@)WPw|nAiHA}3)ckClj_ zq_`V)vj?3zuhX?EjWkbr#V@#<{@XhwIT3D7>!sgA-A2y&&|T~IKPjKmU4sk-Zr5qz z-FMwIQ3l#W&#M1A@Z8K$n5{zCnV@1bnx_qmvtY)10YIRW`0mWdt{Y)~1{>tRuh&4t zYe<0pC;xNP}p(ZsU`|+)_!8yps)R z{|k@pUo^)-n_t(Ban2DA6h4M-Mspe+zY+R~rJw-~}^moux1-X0|f;$7TjFY2BK!#SITu zpe`#`JM5k-v({#u4+j$xK&_x50BB41)8CWg@bmxlI$&}7WHNA{ch*`2liqp|Uc%5) ze>9-A(szd@|H#gSruztwL$q5SHMXydbRI|Ti#0UT4;lSyN7PIy_@*L6rFNnwah-de z#D_n0<%?r+=PQ69S~ga9i^7jPq4$9(dNT3g3jyyQfmpN#BJoI5V6^MOO3$&P>C33l z*&G!7OXiQM+}?(7>6j!^0Ct1{zcx1u*Ir?mgLrN7%daEfVpmt=t&tnfAxz;R2d>S6iFibxS^8n@< zVp)#|9Z}oL6s80hh21{`$i&UI|0DTdp6vVt^06IR^~SumnC#PiqUL*%w(tNL8&hep zxxE*Z;u9T-8UqZ3-HbT4tQQIpw0g8zYdavynP4#i-0i`~G)+gUPBBcY1{q8+YWuWY z#p@94iqj(k9$>F4TVAQ!24s3AA7L5zhK0f!M$=u@H%A3RJC#nq3a*9(@)`K3Nn_VMW57)*~oej2t zOWD6_q>0e92bmhlM9@*{pG_;lH4heDQdHPW3a~bs zyVQ%_>f*}8=1Xjaks##*X+SHT#%bWdfQ2^8V2>&^&I?}eMH{+HzXBXe*aPl;ZZ9D# z_!v+PLH!bZdGNJ!h}ViYp-`R7TW6xd+4`sI@dyVUfaRlOSHclQ5~Ct(KQ4?}sBJrv z=0+(zfP7^azq$J6zn4(=rgLeR|K4zRGv>{o^{2AHQ^(M{H{~OJSWLDKeV3&dk2)F{ z+*~+9DW)L6Gv0UCrRqh^gVM|ei##udz&t&PzHRsJc>Iio-;|pKSX00GufH0phZtM` zB*csVQ$r9>iDdT4{V_~*T_M?j*;H+@1Uc!Ui&R%-r6zF02YtJkX-ryFy7t~|zOw0D zaq0QsYUe;P;GVoTy-Pvvq!4!AvSFV2lppXtyLX3M=aFB&DU;|U(}FN)VuPt={fHEq z>Em8ohszhf*ElE|YDnKi7}-dR)M**LsHUs-~^Q8HuKb3}fy0 zz1l|@01*Jz+!r<0p3JHo#tT;$ITYG@*#%8szj(ttbv<6BeZi0L+H`m*L%n=-Fxw*2 z2Hri88IbQBZbe-L9~bbTe34+^2J7{3fw5LdnKEQJV}|ic+`QwGIvAAcJusX)Qg_7# zgSgkc^||9jD6{@dh(18K`#CG-YEWYQR^?<(KHa{33g{YN@JuQzQM|XQ;07neDGW+~ z_%SCeNdFOm?lz_rL0+<$fv=^2G4!PQXu~|0-r3VSD)fr{^UcYEU}}@nE{E55IYrVG z(r`7xbm)W!4H{4GnFLdAT+GH5-_+847n>zN`$i1lKHL_Y>Bs*jWQ}%_ustW%)%4^2 zcL=}HwXIsk*p9?Cqc`}Z30n1Ciu*8F5kjr!4NxNDNY6SFj0H=T<%|F_irQS3;c7=| zWzHj5vOA8spOX}wsnbR6UyURUS5|z|H_AS)O5;6-#fWkO#z+$MbKUN>gXjBPLD0hU_L)D&u^-Mi!IWU6d7+Xq`h2_qMtP|J)N0#SvjSwWQGkRtf=kjwW zzzS|^+Qxy4wtkPA1!J}MZv|CoJEA02E0e&O8wi0W(25X)8%^lm6ny0*qr5|ZG}`bY zQ$JZRSmc888#1#Ne_k2y-t4DGz`pA9tx(0bJ{!zCm7=_i^pL`VA2wMYvxoEVxhzyKlYUV|3+u6#}RUJf6qNFjzGKT z-6)OGsA{Z+JDR9PTV98bYJ9+Wd7_L;jeNmU%HH0>|*^`%lq2!3Ro$I zp2|6fb7$%xiDtv=x#hQ5ATP=`Wz+02SJvAx*%=F`=HA0=fwtHV9wjEDlFcqYM;H{g z1wXTMDiABmy{FMxO=x=|fxNy#{m2Chjult8I{LfWv(U#VJ5J^rxF0aX%i4R5ia54t zE>Lwan;F@nYk7?FuLzH3qHKNX8DeaXboS2=rD$(sp=c|KN1NIL54@7Vegwt3&QgYY zs#cN#bK=crkSJgvytN;RZSV7~w{6?k?2IW2EnAp06Xgl*M3aqLvr{~rb5c&_NBY9w zRE-c%8)TTRgaKKgK17nk$rSc~e*3W>{`P`l=;+3%=_iCh=jvpU7mZONSIN^a6XG?+ zWJg5~i>XFl}J1=#z1gMV_^MvTmZA2E3IS{gaX z%yldm{JX3+F}O94;81fS+-n5B%dCNw!e#A3eV(n3120GBK@fDMpphinLW9vypKOB> zPMK?geusBQ(q-c^ppm6P(%N=|3X<{3E^)ONJ}lZbZDg5NeTOFW&I7N@d1B-A^HYU{hq z=xSN4G3M6oYR_7~JIPz_!R@C5uZ;WM5$cX7%g98GB+ z0VH@nVrV|fv|+v?H}-OJX5gHFr?&wH$_ix5G46py(w9Y9SoONdd zOdT=uq?-srl*l!&o-~}7oyLgf?KjC_O&J1WLFrAelqb6`N&01B#xpMJGca${1Pl6m zLEJ(lEDN8+bz5&l=qV#YwaY)=_e4`^`a|9Q0Xi9FiO=&7kF5tzK0eSC4?9j*jscv8 z#ZL)gjDK68ApwL>z%%%>k-`xHz*g5&^N)vM%;RCAmkX-wdp*fz?v~@HsQ;A|&w zD2-VlYGv|$*ST5&OjcO!HSVH4yAEJ>VlTn;^FR3BC=vfw^;P?pJRoEohuHojJ}7R- zr!{hiU7aBwk@sTZvgCIZMc=oDH$jxL%9UK>`7Nb+b5i!!eOHvjW~kXgmax}&zR)M= z0+TVOJmBmrv9JTO3I*>){mMh>F&sM9GixUQyctKlcf9ou5dY+ z7+u}VtJ^NMo8Pe!G!xl)rch_WMHY~VI(1Gq=3(80$$E#`f^a^W_oK3NyGenrRpSu8 z3!~`_HZCJc&na9ha-wjlojQVc%3+qhqL6AFFwx|&w%5V?mQ%HasMQc8yW}5bqoYz| zRqk5L{ALp8w={v$n=EZiomqMWQ(t}=S-d#>*_)KJV{ecP2!rF7J!QrqL!Gn5*GE+G zp!+ep1py@S*~lI$a~&d(>7W4x@Qihnx{z+s!S<@I=O2G`o$;&_SOqyS>oI674`xn` z4`>%`H~Gfs+--kFO4BY_;@H&y#lDcY#H4`|QK~Nk%nvW{YQ+!+B*9b!`kYz+{AqDs zlAnxDMl?bZ5AOyha3jDa?HPM*x*t7IIaBu;aBdbMkMq6d7kf4vv${jm6oI>|oTa+< z5k#!|#y4QMydNutZ9d7GlG4tnj=>6t-7<(w7DZEFHzU{z@7(+hxp`J7s~L4ukvDJl zbs7$kWc6a-!nSmIIrhL=@t=;);P*J{6ANoir-l1sru0jZO5K#Ie>&++dlB>;IbIoc z(dm|c+mYW5&$MWPyW%BjqF1|CX;=jSDgSjMM)@@!h?28-Db` z>C~wv+!l#mj$!3M{W}YShjg-yo4hEb5MV98rj%S<9v*_BSzj6+f@r@s;|wvBQB~2A z+F;qBqjs(Qh3hCeJ4;mFvS;HuS?~%loKW1;GBb>jGD1#+a+S@j+ z*$<;PNwj@rv`-AwJ%VTRk5!QpmkKC|Z;$_=iIXam^kA!7GQ7I}mp5c_%q*hr=TT5; zJ9|Ib89vL&>*$OsM;szi(Sf@h6lTvZy6jNJsqg+O?mb8+rktOIyGi)?!mG*Eq?>kf zaq0rfc)zu)Wz8M*@iswzkgy_y=as)=jZT*~F^Oomhjj~v@&#-4ci44Ss7R_UC#bl& zB{_1p!$Oya`|c<|)|h@OgnkWr#r8@jjK+7f7XE3w)a<;2@9G$bhh}g)2O@JBsLyf! z5m-)5coSIt7d!Hpfk9ElSdlD8HG;A?9};`T*xL79Dkf@3D+zJIasIE(Ea2_* z7(L9V=SpoXV?R6AB-iZl)$p6RqepSxMx!2=vxeq1F4kKL2F4DSaYp_^pt7z4xn%T0 z39PB?#abe|>ftIk%WnfczPd9~-rU-4{LaC8WH+to`gU^awGRlcG#{w`1!qVz^V1ncPW8v8OL|H0|d(3s3FwL!*~{p>R=FquR(veESqWN z5M0lo)1)sRqExT@(hc}PSM3;rmGNWS8~_rT%@^nf5CN3N-)SI-dv6qMRA>c8fB-9l zBmsgg%%>*!3FVWDY7W%a!&GV^3YG1fD8K^M0Q+A;8z?P^Tk|`}i2oR7D`QuBe;|XE zdT(Q0n^BnC6-WWJQGJ0}R5qF<^gi*ixy8_kQ!LyUw`np+&CeAV7-52Di)hfSt(_cQ z7s@_<^qB)=YsO!ACqRD<0lLQ!7YNoIcdUW-MT5iyGw&u+T0L#&$G7uJ0X$TqH*^>L zCXd94tEvNwuAs6YY&dn(YO)K+J{Din;lGs!^zLn>sq{Dtp^J1)uY@szCJ)|qP-GPD z?)^Gokf}<=f`ng&h?N-w+EsD8s^S0Tp%A-wOZ1x)BQ|YXj2HP(wh5Y|_JPDFMR?L#>df z#+);phH3Z|`g1`8J^;T`6q){=J|O7 path.replace(/^\/api/, '') + } + } + }, + preview: { + port: 3000, + host: '0.0.0.0' + }, + build: { + target: 'esnext' + } +}); \ No newline at end of file diff --git a/frontend-internal/.eslintrc.json b/frontend-internal/.eslintrc.json new file mode 100644 index 0000000..9062750 --- /dev/null +++ b/frontend-internal/.eslintrc.json @@ -0,0 +1,42 @@ +{ + "extends": [ + "eslint:recommended", + "@typescript-eslint/recommended", + "prettier" + ], + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint", "svelte3"], + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + }, + "env": { + "browser": true, + "es2017": true, + "node": true + }, + "overrides": [ + { + "files": ["*.svelte"], + "processor": "svelte3/svelte3" + } + ], + "rules": { + "@typescript-eslint/no-unused-vars": [ + "error", + { "argsIgnorePattern": "^_" } + ], + "@typescript-eslint/no-explicit-any": "warn", + "no-console": ["warn", { "allow": ["warn", "error"] }], + "prefer-const": "error" + }, + "settings": { + "svelte3/typescript": true + }, + "ignorePatterns": [ + ".svelte-kit/**", + "build/**", + "dist/**", + "node_modules/**" + ] +} \ No newline at end of file diff --git a/frontend-internal/.prettierignore b/frontend-internal/.prettierignore new file mode 100644 index 0000000..aafd4e9 --- /dev/null +++ b/frontend-internal/.prettierignore @@ -0,0 +1,11 @@ +.svelte-kit +build +dist +.env +.env.* +!.env.example +node_modules +.DS_Store +*.log +coverage +.nyc_output \ No newline at end of file diff --git a/frontend-internal/.prettierrc b/frontend-internal/.prettierrc new file mode 100644 index 0000000..986d497 --- /dev/null +++ b/frontend-internal/.prettierrc @@ -0,0 +1,22 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "none", + "printWidth": 100, + "useTabs": false, + "quoteProps": "as-needed", + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "avoid", + "endOfLine": "lf", + "plugins": ["prettier-plugin-svelte"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ] +} \ No newline at end of file diff --git a/frontend-internal/package.json b/frontend-internal/package.json new file mode 100644 index 0000000..d5fbd92 --- /dev/null +++ b/frontend-internal/package.json @@ -0,0 +1,49 @@ +{ + "name": "@servicemanager/internal-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000 --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", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --plugin-search-dir . --check . && eslint .", + "format": "prettier --plugin-search-dir . --write .", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^1.3.1", + "@sveltejs/kit": "^1.20.4", + "@types/cookie": "^0.5.1", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "autoprefixer": "^10.4.14", + "eslint": "^8.28.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-svelte": "^2.30.0", + "postcss": "^8.4.24", + "prettier": "^2.8.0", + "prettier-plugin-svelte": "^2.10.1", + "svelte": "^4.0.5", + "svelte-check": "^3.4.3", + "tailwindcss": "^3.3.0", + "tslib": "^2.4.1", + "typescript": "^5.0.0", + "vite": "^4.4.2", + "vitest": "^0.34.0" + }, + "dependencies": { + "@tailwindcss/forms": "^0.5.4", + "@tailwindcss/typography": "^0.5.9", + "@heroicons/react": "^2.0.18", + "heroicons": "^2.0.18", + "zod": "^3.22.2", + "date-fns": "^2.30.0", + "chart.js": "^4.3.0", + "chartjs-adapter-date-fns": "^3.0.0" + } +} \ No newline at end of file diff --git a/frontend-internal/postcss.config.js b/frontend-internal/postcss.config.js new file mode 100644 index 0000000..e99ebc2 --- /dev/null +++ b/frontend-internal/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/frontend-internal/src/app.css b/frontend-internal/src/app.css new file mode 100644 index 0000000..dd81361 --- /dev/null +++ b/frontend-internal/src/app.css @@ -0,0 +1,225 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Custom base styles */ +@layer base { + html { + font-family: 'Inter', system-ui, sans-serif; + } + + body { + @apply text-gray-900 bg-gray-50; + } + + /* Focus styles */ + *:focus { + @apply outline-none ring-2 ring-primary-500 ring-offset-2; + } + + /* Selection styles */ + ::selection { + @apply bg-primary-100 text-primary-900; + } +} + +/* Custom component styles */ +@layer components { + /* Card component */ + .card { + @apply bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden; + } + + .card-content { + @apply p-6; + } + + /* Button variants */ + .btn { + @apply inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none px-4 py-2; + } + + .btn-primary { + @apply bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800; + } + + .btn-secondary { + @apply bg-secondary-100 text-secondary-900 hover:bg-secondary-200 active:bg-secondary-300; + } + + .btn-success { + @apply bg-success-600 text-white hover:bg-success-700 active:bg-success-800; + } + + .btn-warning { + @apply bg-warning-600 text-white hover:bg-warning-700 active:bg-warning-800; + } + + .btn-error { + @apply bg-error-600 text-white hover:bg-error-700 active:bg-error-800; + } + + .btn-ghost { + @apply bg-transparent hover:bg-secondary-100 active:bg-secondary-200; + } + + /* Card styles */ + .card { + @apply bg-white rounded-lg shadow-sm border border-gray-200; + } + + .card-header { + @apply p-6 border-b border-gray-200; + } + + .card-content { + @apply p-6; + } + + .card-footer { + @apply p-6 border-t border-gray-200 bg-gray-50 rounded-b-lg; + } + + /* Form styles */ + .form-input { + @apply block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm; + } + + .form-label { + @apply block text-sm font-medium text-gray-700 mb-1; + } + + .form-error { + @apply text-sm text-error-600 mt-1; + } + + /* Status badges */ + .badge { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; + } + + /* Animations */ + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + + .animate-fade-in { + animation: fadeIn 0.3s ease-out forwards; + } + + @keyframes slideUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .animate-slide-up { + animation: slideUp 0.4s ease-out forwards; + } + + .badge-new { + @apply badge bg-blue-100 text-blue-800; + } + + .badge-in-progress { + @apply badge bg-yellow-100 text-yellow-800; + } + + .badge-waiting { + @apply badge bg-orange-100 text-orange-800; + } + + .badge-resolved { + @apply badge bg-green-100 text-green-800; + } + + .badge-closed { + @apply badge bg-gray-100 text-gray-800; + } + + .badge-reopened { + @apply badge bg-red-100 text-red-800; + } + + /* Priority badges */ + .badge-priority-low { + @apply badge bg-gray-100 text-gray-600; + } + + .badge-priority-medium { + @apply badge bg-blue-100 text-blue-700; + } + + .badge-priority-high { + @apply badge bg-orange-100 text-orange-700; + } + + .badge-priority-urgent { + @apply badge bg-red-100 text-red-700; + } + + /* Role badges */ + .badge-admin { + @apply badge bg-purple-100 text-purple-800; + } + + .badge-support-manager { + @apply badge bg-indigo-100 text-indigo-800; + } + + .badge-agent { + @apply badge bg-blue-100 text-blue-800; + } + + .badge-auditor { + @apply badge bg-gray-100 text-gray-800; + } +} + +/* Custom utility classes */ +@layer utilities { + .text-balance { + text-wrap: balance; + } + + /* Loading spinner */ + .spinner { + @apply animate-spin rounded-full border-2 border-gray-300 border-t-primary-600; + } + + /* Animations */ + .animate-fade-in { + animation: fadeIn 0.3s ease-in-out; + } + + .animate-slide-up { + animation: slideUp 0.3s ease-out; + } +} + +/* Custom keyframes */ +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} \ No newline at end of file diff --git a/frontend-internal/src/app.html b/frontend-internal/src/app.html new file mode 100644 index 0000000..c556244 --- /dev/null +++ b/frontend-internal/src/app.html @@ -0,0 +1,16 @@ + + + + + + + + + + + %sveltekit.head% + + +

    %sveltekit.body%
    + + \ No newline at end of file diff --git a/frontend-internal/src/lib/components/Header.svelte b/frontend-internal/src/lib/components/Header.svelte new file mode 100644 index 0000000..de687f3 --- /dev/null +++ b/frontend-internal/src/lib/components/Header.svelte @@ -0,0 +1,89 @@ + + +
    +
    + +
    + + + +
    + + +
    + +
    + + + {#if isMenuOpen} +
    +
    +
    + {$auth.user?.email} +
    + isMenuOpen = false} + > + Mi Perfil + + +
    +
    + {/if} +
    +
    +
    +
    + + +{#if isMenuOpen} +
    isMenuOpen = false} + >
    +{/if} \ No newline at end of file diff --git a/frontend-internal/src/lib/components/Icon.svelte b/frontend-internal/src/lib/components/Icon.svelte new file mode 100644 index 0000000..1d2df71 --- /dev/null +++ b/frontend-internal/src/lib/components/Icon.svelte @@ -0,0 +1,43 @@ + + + + + \ No newline at end of file diff --git a/frontend-internal/src/lib/components/Modal.svelte b/frontend-internal/src/lib/components/Modal.svelte new file mode 100644 index 0000000..9100d6a --- /dev/null +++ b/frontend-internal/src/lib/components/Modal.svelte @@ -0,0 +1,44 @@ + + + + +{#if open} + +{/if} diff --git a/frontend-internal/src/lib/components/Sidebar.svelte b/frontend-internal/src/lib/components/Sidebar.svelte new file mode 100644 index 0000000..6f33e7a --- /dev/null +++ b/frontend-internal/src/lib/components/Sidebar.svelte @@ -0,0 +1,153 @@ + + + +{#if open} +
    +
    open = false}>
    +
    +{/if} + + +
    +
    + +
    +
    +
    + + + +
    +
    +

    ServiceManager

    +

    Panel Interno

    +
    +
    + + +
    + + + + + +
    +
    +
    + + {$auth.user?.first_name?.[0]}{$auth.user?.last_name?.[0]} + +
    +
    +

    + {$auth.user?.first_name} {$auth.user?.last_name} +

    +

    + {$auth.user?.role === 'ADMIN' ? 'Administrador' : + $auth.user?.role === 'SUPPORT_MANAGER' ? 'Gerente de Soporte' : + $auth.user?.role === 'AGENT' ? 'Agente' : 'Auditor'} +

    +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/frontend-internal/src/lib/components/Toast.svelte b/frontend-internal/src/lib/components/Toast.svelte new file mode 100644 index 0000000..779cafc --- /dev/null +++ b/frontend-internal/src/lib/components/Toast.svelte @@ -0,0 +1,117 @@ + + +{#if visible} +
    +
    +
    +
    + + + +
    + +
    +

    + {message} +

    +
    + + {#if dismissible} +
    + +
    + {/if} +
    +
    +
    +{/if} + + \ No newline at end of file diff --git a/frontend-internal/src/lib/stores/auth.ts b/frontend-internal/src/lib/stores/auth.ts new file mode 100644 index 0000000..4d3a0ee --- /dev/null +++ b/frontend-internal/src/lib/stores/auth.ts @@ -0,0 +1,215 @@ +import { writable } from 'svelte/store'; +import type { Writable } from 'svelte/store'; + +// Types +export interface InternalUser { + id: string; + email: string; + first_name: string; + last_name: string; + role: 'ADMIN' | 'SUPPORT_MANAGER' | 'AGENT' | 'AUDITOR'; + is_active: boolean; + is_two_factor_enabled: boolean; + created_at: string; + tenant_id: string; +} + +export interface AuthState { + user: InternalUser | null; + token: string | null; + refreshToken: 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; + refresh_token: string; + token_type: string; + expires_in: number; + user: InternalUser; +} + +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(initialState); + + return { + subscribe, + + // Initialize auth from localStorage + init: () => { + 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'); + } + } + } + }, + + // Login + login: async (credentials: LoginRequest): Promise => { + update(state => ({ ...state, isLoading: true })); + + try { + const response = await fetch('/api/v1/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + 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 + }); + } catch (error) { + update(state => ({ ...state, isLoading: false })); + throw error; + } + }, + + // Refresh Session + refreshSession: async (): Promise => { + // 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'); + } + set(initialState); + // Optional: Redirect to login + if (typeof window !== 'undefined') { + 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 })); + } + }; +} + +export const auth = createAuthStore(); \ No newline at end of file diff --git a/frontend-internal/src/lib/stores/toast.ts b/frontend-internal/src/lib/stores/toast.ts new file mode 100644 index 0000000..5f03152 --- /dev/null +++ b/frontend-internal/src/lib/stores/toast.ts @@ -0,0 +1,75 @@ +// Toast notification store +import { writable } from 'svelte/store'; +import type { Writable } from 'svelte/store'; + +export interface ToastMessage { + id: string; + type: 'success' | 'error' | 'warning' | 'info'; + message: string; + duration?: number; +} + +interface ToastState { + toasts: ToastMessage[]; +} + +const initialState: ToastState = { + toasts: [] +}; + +function createToastStore() { + const { subscribe, update }: Writable = writable(initialState); + + return { + subscribe, + + show: (type: ToastMessage['type'], message: string, duration = 5000) => { + const id = Math.random().toString(36).substring(2, 9); + const toast: ToastMessage = { id, type, message, duration }; + + update(state => ({ + toasts: [...state.toasts, toast] + })); + + // Auto-remove after duration + if (duration > 0) { + setTimeout(() => { + update(state => ({ + toasts: state.toasts.filter(t => t.id !== id) + })); + }, duration); + } + + return id; + }, + + dismiss: (id: string) => { + update(state => ({ + toasts: state.toasts.filter(t => t.id !== id) + })); + }, + + clear: () => { + update(() => initialState); + }, + + // Convenience methods + success: (message: string, duration?: number) => { + return createToastStore().show('success', message, duration); + }, + + error: (message: string, duration?: number) => { + return createToastStore().show('error', message, duration); + }, + + warning: (message: string, duration?: number) => { + return createToastStore().show('warning', message, duration); + }, + + info: (message: string, duration?: number) => { + return createToastStore().show('info', message, duration); + } + }; +} + +export const toast = createToastStore(); \ No newline at end of file diff --git a/frontend-internal/src/lib/utils/api.ts b/frontend-internal/src/lib/utils/api.ts new file mode 100644 index 0000000..a5321e6 --- /dev/null +++ b/frontend-internal/src/lib/utils/api.ts @@ -0,0 +1,73 @@ +import { auth } from '$lib/stores/auth'; +import { get } from 'svelte/store'; + +const API_BASE = '/api/v1'; + +interface RequestOptions extends RequestInit { + params?: Record; +} + +async function request(endpoint: string, options: RequestOptions = {}): Promise { + const { params, ...init } = options; + + let url = `${API_BASE}${endpoint}`; + if (params) { + const searchParams = new URLSearchParams(params); + url += `?${searchParams.toString()}`; + } + + const authState = get(auth); + const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null); + + const headers = new Headers(init.headers); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + if (!headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + + const response = await fetch(url, { + ...init, + 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'); + } + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || `API error: ${response.statusText}`); + } + + // Handle empty responses (like 204 No Content) + if (response.status === 204) { + return {} as T; + } + + return response.json(); +} + +export const api = { + get: (endpoint: string, params?: Record) => + request(endpoint, { method: 'GET', params }), + + post: (endpoint: string, body: any) => + request(endpoint, { method: 'POST', body: JSON.stringify(body) }), + + put: (endpoint: string, body: any) => + request(endpoint, { method: 'PUT', body: JSON.stringify(body) }), + + patch: (endpoint: string, body: any) => + request(endpoint, { method: 'PATCH', body: JSON.stringify(body) }), + + delete: (endpoint: string) => + request(endpoint, { method: 'DELETE' }) +}; diff --git a/frontend-internal/src/routes/+layout.svelte b/frontend-internal/src/routes/+layout.svelte new file mode 100644 index 0000000..8738daa --- /dev/null +++ b/frontend-internal/src/routes/+layout.svelte @@ -0,0 +1,53 @@ + + +
    + {#if $auth.isAuthenticated} + +
    + + + + +
    +
    + +
    + +
    +
    +
    + {:else} + +
    + +
    + {/if} + + + {#each $toast.toasts as toastMessage (toastMessage.id)} + toast.dismiss(toastMessage.id)} + /> + {/each} +
    \ No newline at end of file diff --git a/frontend-internal/src/routes/+page.svelte b/frontend-internal/src/routes/+page.svelte new file mode 100644 index 0000000..852e9b4 --- /dev/null +++ b/frontend-internal/src/routes/+page.svelte @@ -0,0 +1,98 @@ + + + + Dashboard Admin - ServiceManager + + +
    +
    +
    +

    + Panel de Administración +

    +

    + Bienvenido al sistema de gestión interna. +

    +
    +
    + + +
    diff --git a/frontend-internal/src/routes/categories/+page.svelte b/frontend-internal/src/routes/categories/+page.svelte new file mode 100644 index 0000000..7d840e1 --- /dev/null +++ b/frontend-internal/src/routes/categories/+page.svelte @@ -0,0 +1,184 @@ + + +
    +
    +
    +

    Categorías de Tickets

    +

    Gestión de categorías para clasificación de tickets.

    +
    +
    + +
    +
    + +
    +
    +
    +
    + + + + + + + + + + + + {#if isLoading} + + {:else if categories.length === 0} + + {:else} + {#each categories as category} + + + + + + + + {/each} + {/if} + +
    NombreDescripciónTipo (Cliente)Estado + Acciones +
    Cargando...
    No hay categorías registradas
    {category.name}{category.description || '-'} + + {getTenantName(category.tenant_id)} + + + + {category.is_active ? 'Activo' : 'Inactivo'} + + + +
    +
    +
    +
    +
    +
    + + showModal = false}> +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    +
    +
    diff --git a/frontend-internal/src/routes/login/+page.svelte b/frontend-internal/src/routes/login/+page.svelte new file mode 100644 index 0000000..c6da091 --- /dev/null +++ b/frontend-internal/src/routes/login/+page.svelte @@ -0,0 +1,211 @@ + + + + + Acceso Admin - ServiceManager + + +
    +
    + + + + + +
    + +
    +
    +

    Identifíquese

    +

    Acceso al sistema central

    +
    + +
    + {#if errorMessage} +
    + +

    {errorMessage}

    +
    + {/if} + + {#if !showTwoFactor} +
    +
    + +
    +
    + +
    + +
    +
    + +
    + +
    +
    + +
    + +
    +
    +
    + + {:else} + +
    + +
    + +
    +

    + Consulte su dispositivo autenticador +

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

    Aduanasoft Internal Systems © 2024

    +
    +
    +
    +
    \ No newline at end of file diff --git a/frontend-internal/src/routes/systems/+page.svelte b/frontend-internal/src/routes/systems/+page.svelte new file mode 100644 index 0000000..6354d64 --- /dev/null +++ b/frontend-internal/src/routes/systems/+page.svelte @@ -0,0 +1,147 @@ + + +
    +
    +
    +

    Sistemas

    +

    Catálogo de sistemas informáticos gestionados.

    +
    +
    + +
    +
    + +
    +
    +
    +
    + + + + + + + + + + + {#if isLoading} + + {:else if systems.length === 0} + + {:else} + {#each systems as system} + + + + + + + {/each} + {/if} + +
    NombreDescripciónEstado + Acciones +
    Cargando...
    No hay sistemas registrados
    {system.name}{system.description || '-'} + + {system.is_active ? 'Activo' : 'Inactivo'} + + + +
    +
    +
    +
    +
    +
    + + showModal = false}> +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    +
    +
    diff --git a/frontend-internal/src/routes/tenants/+page.svelte b/frontend-internal/src/routes/tenants/+page.svelte new file mode 100644 index 0000000..cf633d5 --- /dev/null +++ b/frontend-internal/src/routes/tenants/+page.svelte @@ -0,0 +1,157 @@ + + +
    +
    +
    +

    Clientes

    +

    Lista de todas las organizaciones/clientes registrados en el sistema.

    +
    +
    + +
    +
    + +
    +
    +
    +
    + + + + + + + + + + + + {#if isLoading} + + {:else if tenants.length === 0} + + {:else} + {#each tenants as tenant} + + + + + + + + {/each} + {/if} + +
    NombreSlugDominioEstado + Acciones +
    Cargando...
    No hay clientes registrados
    {tenant.name}{tenant.slug}{tenant.domain || '-'} + + {tenant.is_active ? 'Activo' : 'Inactivo'} + + + +
    +
    +
    +
    +
    +
    + + showModal = false}> +
    +
    + + +
    + +
    + + +

    Usado en URLs y subdominios.

    +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    +
    +
    diff --git a/frontend-internal/src/routes/users/+page.svelte b/frontend-internal/src/routes/users/+page.svelte new file mode 100644 index 0000000..aec2681 --- /dev/null +++ b/frontend-internal/src/routes/users/+page.svelte @@ -0,0 +1,231 @@ + + +
    +
    +
    +

    Usuarios

    +

    Gestión de usuarios internos y de clientes.

    +
    +
    + +
    +
    + +
    +
    +
    +
    + + + + + + + + + + + + {#if isLoading} + + {:else if users.length === 0} + + {:else} + {#each users as user} + + + + + + + + {/each} + {/if} + +
    UsuarioRolCliente (Tenant)Estado + Acciones +
    Cargando...
    No hay usuarios registrados
    +
    {user.first_name} {user.last_name}
    +
    {user.email}
    +
    {user.role}{getTenantName(user.tenant_id)} + + {user.is_active ? 'Activo' : 'Inactivo'} + + + +
    +
    +
    +
    +
    +
    + + showModal = false}> +
    +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    +
    +
    diff --git a/frontend-internal/svelte.config.js b/frontend-internal/svelte.config.js new file mode 100644 index 0000000..60909b8 --- /dev/null +++ b/frontend-internal/svelte.config.js @@ -0,0 +1,25 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/kit/vite'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + // Consult https://kit.svelte.dev/docs/integrations#preprocessors + // for more information about preprocessors + preprocess: vitePreprocess(), + + kit: { + // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. + // If your environment is not supported or you settled on a specific environment, switch out the adapter. + // See https://kit.svelte.dev/docs/adapters for more information about adapters. + adapter: adapter(), + + alias: { + '$components': 'src/lib/components', + '$stores': 'src/lib/stores', + '$utils': 'src/lib/utils', + '$types': 'src/lib/types' + } + } +}; + +export default config; diff --git a/frontend-internal/tailwind.config.js b/frontend-internal/tailwind.config.js new file mode 100644 index 0000000..654dc20 --- /dev/null +++ b/frontend-internal/tailwind.config.js @@ -0,0 +1,23 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{html,js,svelte,ts}'], + theme: { + extend: { + colors: { + primary: { + 50: '#eff6ff', + 100: '#dbeafe', + 200: '#bfdbfe', + 300: '#93c5fd', + 400: '#60a5fa', + 500: '#3b82f6', + 600: '#2563eb', + 700: '#1d4ed8', + 800: '#1e40af', + 900: '#1e3a8a', + } + } + } + }, + plugins: [], +} diff --git a/frontend-internal/vite.config.js b/frontend-internal/vite.config.js new file mode 100644 index 0000000..7e41c3f --- /dev/null +++ b/frontend-internal/vite.config.js @@ -0,0 +1,24 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], + server: { + port: 3000, + host: '0.0.0.0', + proxy: { + '/api/v1': { + target: 'http://servicemanager-backend:8000', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, '') + } + } + }, + preview: { + port: 3000, + host: '0.0.0.0' + }, + build: { + target: 'esnext' + } +}); diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..a25c89b --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,104 @@ +# ServiceManagerWeb - Scripts de Utilidad + +Este directorio contiene scripts para desarrollo y despliegue del sistema. + +## Scripts de Desarrollo + +### setup-dev.sh / setup-dev.ps1 +```bash +# Linux/Mac +./scripts/setup-dev.sh + +# Windows +./scripts/setup-dev.ps1 +``` + +Configura el entorno de desarrollo: +- Copia .env.example a .env +- Genera claves secretas seguras +- Levanta servicios con Docker Compose +- Ejecuta migraciones iniciales +- Carga datos de prueba + +### db-migrate.sh / db-migrate.ps1 +```bash +# Ejecutar migraciones pendientes +./scripts/db-migrate.sh + +# Crear nueva migración +./scripts/db-migrate.sh "add_user_preferences" +``` + +### test-all.sh / test-all.ps1 +```bash +# Ejecutar todos los tests +./scripts/test-all.sh + +# Solo backend +./scripts/test-all.sh backend + +# Con coverage +./scripts/test-all.sh --coverage +``` + +### lint-fix.sh / lint-fix.ps1 +```bash +# Linting y formateo automático +./scripts/lint-fix.sh +``` + +## Scripts de Producción + +### deploy.sh +```bash +# Despliegue en producción +./scripts/deploy.sh production + +# Con backup automático +./scripts/deploy.sh production --backup +``` + +### backup.sh +```bash +# Backup completo +./scripts/backup.sh + +# Solo base de datos +./scripts/backup.sh --db-only +``` + +### health-check.sh +```bash +# Verificar estado de servicios +./scripts/health-check.sh +``` + +## Scripts de Mantenimiento + +### cleanup.sh +```bash +# Limpiar logs antiguos y archivos temporales +./scripts/cleanup.sh + +# Limpiar containers e imágenes sin usar +./scripts/cleanup.sh --docker +``` + +### seed-data.sh +```bash +# Cargar datos de prueba +./scripts/seed-data.sh + +# Cargar datos específicos +./scripts/seed-data.sh --fixture=users +``` + +## Uso + +Todos los scripts incluyen help integrado: + +```bash +./scripts/script-name.sh --help +``` + +Los scripts están disponibles tanto para Unix (sh) como Windows (ps1). \ No newline at end of file diff --git a/scripts/setup-dev.sh b/scripts/setup-dev.sh new file mode 100644 index 0000000..242aadb --- /dev/null +++ b/scripts/setup-dev.sh @@ -0,0 +1,165 @@ +#!/bin/bash + +# ServiceManagerWeb - Setup de Desarrollo +# Configura el entorno de desarrollo completo + +set -e + +echo "🚀 Configurando ServiceManagerWeb - Entorno de Desarrollo" + +# Colores para output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Función para logs +log() { + echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')] $1${NC}" +} + +success() { + echo -e "${GREEN}✅ $1${NC}" +} + +warning() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +error() { + echo -e "${RED}❌ $1${NC}" + exit 1 +} + +# Verificar dependencias +check_dependencies() { + log "Verificando dependencias..." + + command -v docker >/dev/null 2>&1 || error "Docker no está instalado" + command -v docker-compose >/dev/null 2>&1 || error "Docker Compose no está instalado" + + success "Dependencias verificadas" +} + +# Configurar archivo .env +setup_env() { + log "Configurando variables de entorno..." + + if [ ! -f .env ]; then + cp .env.example .env + + # Generar claves secretas + SECRET_KEY=$(openssl rand -base64 32) + JWT_SECRET_KEY=$(openssl rand -base64 32) + + # Reemplazar en .env + sed -i "s|your-super-secret-key-change-in-production-min-32-chars|$SECRET_KEY|g" .env + sed -i "s|jwt-secret-key-change-in-production-min-32-chars|$JWT_SECRET_KEY|g" .env + + success "Archivo .env creado con claves seguras" + else + warning "Archivo .env ya existe, no se sobrescribirá" + fi +} + +# Crear directorios necesarios +create_directories() { + log "Creando directorios necesarios..." + + mkdir -p logs uploads + chmod 755 logs uploads + + success "Directorios creados" +} + +# Levantar servicios +start_services() { + log "Levantando servicios con Docker Compose..." + + docker-compose --profile dev up -d + + success "Servicios iniciados" +} + +# Esperar a que la base de datos esté lista +wait_for_db() { + log "Esperando a que PostgreSQL esté listo..." + + timeout=60 + while ! docker-compose exec -T postgres pg_isready -U servicemanager >/dev/null 2>&1; do + sleep 2 + timeout=$((timeout - 2)) + if [ $timeout -le 0 ]; then + error "Timeout esperando PostgreSQL" + fi + done + + success "PostgreSQL está listo" +} + +# Ejecutar migraciones (cuando estén implementadas) +run_migrations() { + log "Las migraciones se ejecutarán cuando el backend esté implementado..." + warning "Saltando migraciones por ahora" +} + +# Mostrar información útil +show_info() { + echo "" + echo "🎉 ¡Configuración completa!" + echo "" + echo "📊 URLs disponibles:" + echo " Frontend Cliente: http://localhost:3000" + echo " Frontend Interno: http://localhost:3001" + echo " API Backend: http://localhost:8000" + echo " API Docs: http://localhost:8000/docs" + echo "" + echo "🛠️ Herramientas de desarrollo:" + echo " Adminer (DB): http://localhost:8080" + echo " MailHog (Email): http://localhost:8025" + echo " Redis Commander: http://localhost:8081" + echo "" + echo "📝 Comandos útiles:" + echo " Ver logs: docker-compose logs -f" + echo " Parar servicios: docker-compose down" + echo " Reiniciar todo: docker-compose restart" + echo " Shell backend: docker-compose exec backend bash" + echo "" + echo "👤 Usuario por defecto:" + echo " Email: admin@aduanasoft.com" + echo " Password: admin123" + echo "" +} + +# Función principal +main() { + check_dependencies + setup_env + create_directories + start_services + wait_for_db + run_migrations + show_info +} + +# Manejar argumentos +case "${1:-}" in + -h|--help) + echo "Uso: $0 [opciones]" + echo "" + echo "Opciones:" + echo " -h, --help Mostrar esta ayuda" + echo " --no-start Solo configurar, no iniciar servicios" + echo "" + exit 0 + ;; + --no-start) + setup_env + create_directories + success "Configuración completada sin iniciar servicios" + exit 0 + ;; +esac + +main "$@" \ No newline at end of file diff --git a/workers/README.md b/workers/README.md new file mode 100644 index 0000000..636b324 --- /dev/null +++ b/workers/README.md @@ -0,0 +1,279 @@ +# ServiceManagerWeb Workers + +Workers asíncronos con Celery para el sistema de Mesa de Ayuda B2B. + +## Estructura + +``` +workers/ +├── app/ +│ ├── celery.py # Configuración principal de Celery +│ ├── core/ # Configuración compartida +│ │ ├── config.py # Settings para workers +│ │ └── logging.py # Logging estructurado +│ └── tasks/ # Tareas por dominio +│ ├── email_tasks.py # Envío de emails +│ ├── sla_tasks.py # Monitoreo de SLAs +│ ├── maintenance_tasks.py # Mantenimiento del sistema +│ └── notification_tasks.py # Notificaciones y digests +├── requirements.txt # Dependencias Python +└── README.md # Esta documentación +``` + +## Tareas Implementadas + +### Email Tasks (`email_tasks.py`) +- [x] `send_email_task`: Envío básico de emails SMTP +- [x] `send_templated_email_task`: Emails con plantillas Jinja2 +- [x] `send_bulk_email_task`: Envío masivo con progreso + +### SLA Tasks (`sla_tasks.py`) +- [x] `check_sla_violations`: Monitoreo de violaciones SLA +- [x] `calculate_sla_metrics`: Cálculo de métricas SLA +- [x] `send_sla_warnings`: Alertas de SLAs próximos a vencer + +### Maintenance Tasks (`maintenance_tasks.py`) +- [x] `health_check`: Health check de workers +- [x] `cleanup_old_logs`: Limpieza de logs antiguos +- [x] `generate_weekly_reports`: Reportes semanales +- [x] `cleanup_temp_files`: Limpieza de archivos temporales +- [x] `database_maintenance`: Mantenimiento de BD + +### Notification Tasks (`notification_tasks.py`) +- [x] `send_daily_digest`: Digest diario para agentes +- [x] `send_ticket_notifications`: Notificaciones de tickets +- [x] `send_system_alert`: Alertas del sistema + +## Programación Automática (Celery Beat) + +### Tareas Periódicas Configuradas + +```python +# Cada 5 minutos +"check-sla-violations": check_sla_violations + +# Diario a las 8:00 AM +"send-daily-digest": send_daily_digest + +# Semanal los domingos a las 2:00 AM +"cleanup-old-logs": cleanup_old_logs + +# Semanal los lunes a las 9:00 AM +"generate-weekly-reports": generate_weekly_reports + +# Cada minuto (health check) +"worker-health-check": health_check +``` + +## Quick Start + +### Desarrollo Local + +```bash +# Instalar dependencias +pip install -r requirements.txt + +# Variables de entorno (usar las del proyecto principal) +cp ../.env.example .env + +# Ejecutar worker +celery -A app.celery worker --loglevel=info + +# Ejecutar beat scheduler (en otra terminal) +celery -A app.celery beat --loglevel=info + +# Monitoreo con Flower (opcional) +celery -A app.celery flower +``` + +### Con Docker + +```bash +# Worker y Beat se ejecutan automáticamente con docker-compose +docker-compose up worker beat + +# Ver logs +docker-compose logs -f worker +docker-compose logs -f beat +``` + +## Configuración + +### Variables de Entorno Importantes + +```bash +# Celery +CELERY_BROKER_URL=redis://redis:6379/0 +CELERY_RESULT_BACKEND=redis://redis:6379/0 + +# Email +SMTP_HOST=mailhog +SMTP_PORT=1025 +DEFAULT_FROM_EMAIL=noreply@servicemanager.local + +# SLA +SLA_CHECK_ENABLED=true +SLA_WARNING_THRESHOLD=0.8 + +# Mantenimiento +LOG_RETENTION_DAYS=30 +DIGEST_ENABLED=true +``` + +### Colas de Trabajo + +- **default**: Tareas generales +- **email**: Envío de emails +- **sla**: Monitoreo SLA +- **maintenance**: Mantenimiento +- **notifications**: Notificaciones + +## Monitoreo + +### Logs Estructurados + +Todos los workers utilizan structured logging con: +- Task ID único +- Correlation ID para tracking +- Contexto de tenant +- Métricas de performance + +### Health Checks + +```bash +# Health check manual +celery -A app.celery inspect ping + +# Estadísticas de workers +celery -A app.celery inspect stats + +# Tareas activas +celery -A app.celery inspect active +``` + +### Métricas + +- Task execution times +- Success/failure rates +- Queue lengths +- Worker load + +## Desarrollo + +### Agregar Nueva Tarea + +1. Crear función en módulo apropiado: +```python +@celery_app.task(bind=True, time_limit=300) +def new_task(self, param1: str, param2: int): + logger = get_logger(__name__) + # Implementation + return result +``` + +2. Registrar en `celery.py` si es periódica: +```python +beat_schedule = { + "new-periodic-task": { + "task": "app.tasks.module.new_task", + "schedule": crontab(minute=0, hour=9), + } +} +``` + +3. Agregar tests en `tests/` + +### Retry y Error Handling + +```python +@celery_app.task( + bind=True, + autoretry_for=(ConnectionError, TimeoutError), + retry_kwargs={'max_retries': 3, 'countdown': 60} +) +def reliable_task(self): + # Task que se reintenta automáticamente + pass +``` + +### Templates de Email + +Los templates están definidos en código por ahora. En el futuro se moverán a base de datos para ser editables por tenants. + +Templates disponibles: +- `ticket_created` +- `ticket_assigned` +- `ticket_resolved` +- `sla_warning` +- `sla_violation` +- `daily_digest` +- `system_alert` + +## Testing + +```bash +# Ejecutar tests +pytest + +# Tests específicos de workers +pytest tests/test_tasks/ + +# Ejecutar tarea manualmente para testing +celery -A app.celery call app.tasks.email_tasks.send_email_task --args='["test@example.com", "Test Subject", "

    Test

    "]' +``` + +## Producción + +### Configuración Recomendada + +```bash +# Múltiples workers por queue +celery -A app.celery worker --loglevel=info --concurrency=4 --queues=email +celery -A app.celery worker --loglevel=info --concurrency=2 --queues=sla,maintenance +celery -A app.celery worker --loglevel=info --concurrency=1 --queues=default + +# Beat scheduler (solo una instancia) +celery -A app.celery beat --loglevel=info + +# Con systemd o supervisor para auto-restart +``` + +### Optimizaciones + +- Pool de conexiones Redis +- Compresión de mensajes grandes +- Rate limiting por tarea +- Monitoring con Prometheus/Grafana + +## Troubleshooting + +### Problemas Comunes + +1. **Tasks stuck in queue**: + - Verificar workers activos + - Revisar configuración de routing + +2. **Memory leaks**: + - Configurar `worker_max_tasks_per_child` + - Monitorear uso de memoria + +3. **Email delivery failures**: + - Verificar configuración SMTP + - Revisar logs de tareas email + +4. **SLA false positives**: + - Verificar timezones + - Validar lógica de cálculo + +### Debug + +```bash +# Ejecutar worker en modo debug +celery -A app.celery worker --loglevel=debug + +# Inspeccionar tareas fallidas +celery -A app.celery inspect failed + +# Purgar queue +celery -A app.celery purge -Q queue_name +``` \ No newline at end of file diff --git a/workers/app/celery.py b/workers/app/celery.py new file mode 100644 index 0000000..86e6fa0 --- /dev/null +++ b/workers/app/celery.py @@ -0,0 +1,129 @@ +""" +Celery Application - ServiceManagerWeb Workers + +Configuración principal de Celery para tareas asíncronas +""" + +from celery import Celery +from celery.schedules import crontab +import os +from app.core.config import get_settings +from app.core.logging import setup_logging + +# Setup logging +setup_logging() + +# Get settings +settings = get_settings() + +# Create Celery application +celery_app = Celery( + "servicemanager-workers", + broker=settings.CELERY_BROKER_URL, + backend=settings.CELERY_RESULT_BACKEND, + include=[ + "app.tasks.email_tasks", + "app.tasks.sla_tasks", + "app.tasks.maintenance_tasks", + "app.tasks.notification_tasks" + ] +) + +# Configure Celery +celery_app.conf.update( + # Task settings + task_serializer="json", + accept_content=["json"], + result_serializer="json", + timezone="UTC", + enable_utc=True, + + # Result backend settings + result_expires=3600, # 1 hour + result_persistent=True, + + # Worker settings + worker_prefetch_multiplier=1, + worker_max_tasks_per_child=1000, + worker_disable_rate_limits=False, + + # Task routing + task_routes={ + "app.tasks.email_tasks.*": {"queue": "email"}, + "app.tasks.sla_tasks.*": {"queue": "sla"}, + "app.tasks.maintenance_tasks.*": {"queue": "maintenance"}, + "app.tasks.notification_tasks.*": {"queue": "notifications"}, + }, + + # Queue configuration + task_default_queue="default", + task_default_exchange="default", + task_default_routing_key="default", + + # Beat schedule for periodic tasks + beat_schedule={ + # Check SLA violations every 5 minutes + "check-sla-violations": { + "task": "app.tasks.sla_tasks.check_sla_violations", + "schedule": crontab(minute="*/5"), + }, + + # Send daily digest at 8:00 AM + "send-daily-digest": { + "task": "app.tasks.notification_tasks.send_daily_digest", + "schedule": crontab(hour=8, minute=0), + }, + + # Clean old logs weekly on Sunday at 2:00 AM + "cleanup-old-logs": { + "task": "app.tasks.maintenance_tasks.cleanup_old_logs", + "schedule": crontab(hour=2, minute=0, day_of_week=0), + }, + + # Generate weekly reports on Monday at 9:00 AM + "generate-weekly-reports": { + "task": "app.tasks.maintenance_tasks.generate_weekly_reports", + "schedule": crontab(hour=9, minute=0, day_of_week=1), + }, + + # Health check every minute + "worker-health-check": { + "task": "app.tasks.maintenance_tasks.health_check", + "schedule": crontab(minute="*/1"), + }, + }, + + # Error handling + task_reject_on_worker_lost=True, + task_acks_late=True, + + # Monitoring + worker_send_task_events=True, + task_send_sent_event=True, + + # Security + worker_hijack_root_logger=False, + worker_log_format="[%(asctime)s: %(levelname)s/%(processName)s] %(message)s", + worker_task_log_format="[%(asctime)s: %(levelname)s/%(processName)s][%(task_name)s(%(task_id)s)] %(message)s", +) + +# Optional: Configure SSL if needed +if settings.ENVIRONMENT == "production": + # Enable SSL for production + celery_app.conf.update( + broker_use_ssl=True, + redis_backend_use_ssl=True, + ) + + +# Import all tasks to register them +from app.tasks import ( + email_tasks, + sla_tasks, + maintenance_tasks, + notification_tasks +) + + +if __name__ == "__main__": + celery_app.start() \ No newline at end of file diff --git a/workers/app/core/config.py b/workers/app/core/config.py new file mode 100644 index 0000000..5c692e8 --- /dev/null +++ b/workers/app/core/config.py @@ -0,0 +1,117 @@ +""" +Core Configuration for Workers - ServiceManagerWeb + +Configuración compartida entre workers usando Pydantic Settings +""" + +from functools import lru_cache +from typing import List, Optional +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings +import os + + +class WorkerSettings(BaseSettings): + """Configuración para workers Celery.""" + + # =================================== + # GENERAL + # =================================== + ENVIRONMENT: str = Field(default="development", env="ENVIRONMENT") + DEBUG: bool = Field(default=False, env="DEBUG") + + # =================================== + # DATABASE + # =================================== + DATABASE_URL: str = Field(..., env="DATABASE_URL") + + # =================================== + # CELERY & REDIS + # =================================== + CELERY_BROKER_URL: str = Field(..., env="CELERY_BROKER_URL") + CELERY_RESULT_BACKEND: str = Field(..., env="CELERY_RESULT_BACKEND") + REDIS_URL: str = Field(..., env="REDIS_URL") + + # =================================== + # EMAIL SETTINGS + # =================================== + SMTP_HOST: str = Field(default="localhost", env="SMTP_HOST") + SMTP_PORT: int = Field(default=587, env="SMTP_PORT") + SMTP_USER: Optional[str] = Field(default=None, env="SMTP_USER") + SMTP_PASSWORD: Optional[str] = Field(default=None, env="SMTP_PASSWORD") + SMTP_USE_TLS: bool = Field(default=True, env="SMTP_USE_TLS") + SMTP_USE_SSL: bool = Field(default=False, env="SMTP_USE_SSL") + + DEFAULT_FROM_EMAIL: str = Field(default="noreply@servicemanager.local", env="DEFAULT_FROM_EMAIL") + DEFAULT_FROM_NAME: str = Field(default="ServiceManager", env="DEFAULT_FROM_NAME") + + # Email retry settings + EMAIL_MAX_RETRIES: int = Field(default=3, env="EMAIL_MAX_RETRIES") + EMAIL_RETRY_DELAY: int = Field(default=60, env="EMAIL_RETRY_DELAY") # seconds + + # =================================== + # FILE PROCESSING + # =================================== + UPLOAD_PATH: str = Field(default="/app/uploads", env="UPLOAD_PATH") + MAX_UPLOAD_SIZE_MB: int = Field(default=10, env="MAX_UPLOAD_SIZE_MB") + + # =================================== + # SLA SETTINGS + # =================================== + SLA_CHECK_ENABLED: bool = Field(default=True, env="SLA_CHECK_ENABLED") + SLA_WARNING_THRESHOLD: float = Field(default=0.8, env="SLA_WARNING_THRESHOLD") # 80% of SLA time + + # =================================== + # LOGGING + # =================================== + LOG_LEVEL: str = Field(default="INFO", env="LOG_LEVEL") + LOG_FORMAT: str = Field(default="json", env="LOG_FORMAT") + LOG_FILE: Optional[str] = Field(default=None, env="LOG_FILE") + + # =================================== + # MONITORING + # =================================== + SENTRY_DSN: Optional[str] = Field(default=None, env="SENTRY_DSN") + PROMETHEUS_PORT: int = Field(default=8888, env="PROMETHEUS_PORT") + + # =================================== + # TASK SETTINGS + # =================================== + TASK_TIME_LIMIT: int = Field(default=300, env="TASK_TIME_LIMIT") # 5 minutes + TASK_SOFT_TIME_LIMIT: int = Field(default=240, env="TASK_SOFT_TIME_LIMIT") # 4 minutes + + # =================================== + # MAINTENANCE SETTINGS + # =================================== + LOG_RETENTION_DAYS: int = Field(default=30, env="LOG_RETENTION_DAYS") + ATTACHMENT_RETENTION_DAYS: int = Field(default=90, env="ATTACHMENT_RETENTION_DAYS") + + # =================================== + # NOTIFICATION SETTINGS + # =================================== + NOTIFICATION_BATCH_SIZE: int = Field(default=100, env="NOTIFICATION_BATCH_SIZE") + DIGEST_ENABLED: bool = Field(default=True, env="DIGEST_ENABLED") + + model_config = { + "env_file": ".env", + "env_file_encoding": "utf-8", + "case_sensitive": True + } + + def is_production(self) -> bool: + """Check if environment is production.""" + return self.ENVIRONMENT.lower() == "production" + + def is_development(self) -> bool: + """Check if environment is development.""" + return self.ENVIRONMENT.lower() == "development" + + +@lru_cache() +def get_settings() -> WorkerSettings: + """ + Get cached settings instance. + + Using lru_cache to create a singleton pattern for settings. + """ + return WorkerSettings() \ No newline at end of file diff --git a/workers/app/core/logging.py b/workers/app/core/logging.py new file mode 100644 index 0000000..52567e1 --- /dev/null +++ b/workers/app/core/logging.py @@ -0,0 +1,122 @@ +""" +Logging Configuration for Workers - ServiceManagerWeb + +Configuración de logging estructurado para workers Celery +""" + +import logging +import logging.config +import sys +from typing import Any, Dict +import structlog +from app.core.config import get_settings + +settings = get_settings() + + +def setup_logging(): + """Configure structured logging for workers.""" + + processors = [ + structlog.stdlib.filter_by_level, + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.stdlib.PositionalArgumentsFormatter(), + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.UnicodeDecoder(), + # Add worker-specific context + structlog.processors.CallsiteParameterAdder( + parameters=[ + structlog.processors.CallsiteParameter.FUNC_NAME, + structlog.processors.CallsiteParameter.PATHNAME, + structlog.processors.CallsiteParameter.LINENO, + ] + ), + ] + + if settings.LOG_FORMAT == "json": + processors.append(structlog.processors.JSONRenderer()) + else: + processors.append(structlog.dev.ConsoleRenderer(colors=True)) + + structlog.configure( + processors=processors, + wrapper_class=structlog.stdlib.BoundLogger, + logger_factory=structlog.stdlib.LoggerFactory(), + context_class=dict, + cache_logger_on_first_use=True, + ) + + # Configure standard library logging for Celery + logging_config = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "json": { + "()": structlog.stdlib.ProcessorFormatter, + "processor": structlog.processors.JSONRenderer(), + }, + "console": { + "()": structlog.stdlib.ProcessorFormatter, + "processor": structlog.dev.ConsoleRenderer(colors=True), + }, + }, + "handlers": { + "console": { + "level": settings.LOG_LEVEL, + "class": "logging.StreamHandler", + "stream": sys.stdout, + "formatter": "json" if settings.LOG_FORMAT == "json" else "console", + }, + }, + "loggers": { + "": { # root logger + "handlers": ["console"], + "level": settings.LOG_LEVEL, + "propagate": False, + }, + "celery": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, + "celery.worker": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, + "celery.task": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, + "app": { + "handlers": ["console"], + "level": settings.LOG_LEVEL, + "propagate": False, + }, + }, + } + + # Add file handler if specified + if settings.LOG_FILE: + logging_config["handlers"]["file"] = { + "level": settings.LOG_LEVEL, + "class": "logging.handlers.RotatingFileHandler", + "filename": settings.LOG_FILE, + "maxBytes": 10 * 1024 * 1024, # 10MB + "backupCount": 5, + "formatter": "json", + } + + for logger_config in logging_config["loggers"].values(): + logger_config["handlers"].append("file") + + logging.config.dictConfig(logging_config) + + +def get_logger(name: str = None) -> structlog.BoundLogger: + """Get a configured structlog logger.""" + return structlog.get_logger(name) \ No newline at end of file diff --git a/workers/app/tasks/email_tasks.py b/workers/app/tasks/email_tasks.py new file mode 100644 index 0000000..c38b950 --- /dev/null +++ b/workers/app/tasks/email_tasks.py @@ -0,0 +1,404 @@ +""" +Email Tasks - ServiceManagerWeb Workers + +Tareas asíncronas para envío de emails y notificaciones +""" + +from celery import current_task +from celery.exceptions import Retry +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.mime.base import MIMEBase +from email import encoders +import smtplib +import ssl +from typing import Dict, List, Optional, Any +from jinja2 import Template, Environment, BaseLoader +import structlog + +from app.celery import celery_app +from app.core.config import get_settings +from app.core.logging import get_logger + +settings = get_settings() +logger = get_logger(__name__) + + +class MemoryLoader(BaseLoader): + """Jinja2 loader for templates from memory/database.""" + + def __init__(self, templates: Dict[str, str]): + self.templates = templates + + def get_source(self, environment, template): + if template in self.templates: + source = self.templates[template] + return source, None, lambda: True + raise TemplateNotFoundError(template) + + +@celery_app.task( + bind=True, + autoretry_for=(Exception,), + retry_kwargs={'max_retries': 3, 'countdown': 60}, + time_limit=120, + soft_time_limit=90 +) +def send_email_task( + self, + to_email: str, + subject: str, + html_content: str, + text_content: Optional[str] = None, + from_email: Optional[str] = None, + from_name: Optional[str] = None, + attachments: Optional[List[Dict[str, Any]]] = None, + tenant_id: Optional[str] = None, + correlation_id: Optional[str] = None +) -> Dict[str, Any]: + """ + Send email using SMTP. + + Args: + to_email: Recipient email address + subject: Email subject + html_content: HTML content + text_content: Plain text content (optional) + from_email: Sender email (optional, uses default) + from_name: Sender name (optional) + attachments: List of attachment dicts + tenant_id: Tenant ID for logging + correlation_id: Correlation ID for tracking + + Returns: + Dict with send result + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name, + tenant_id=tenant_id, + correlation_id=correlation_id + ) + + task_logger.info( + "Starting email send task", + to_email=to_email, + subject=subject + ) + + try: + # Prepare email + msg = MIMEMultipart('alternative') + msg['Subject'] = subject + msg['From'] = f"{from_name or settings.DEFAULT_FROM_NAME} <{from_email or settings.DEFAULT_FROM_EMAIL}>" + msg['To'] = to_email + + # Add text content + if text_content: + text_part = MIMEText(text_content, 'plain', 'utf-8') + msg.attach(text_part) + + # Add HTML content + html_part = MIMEText(html_content, 'html', 'utf-8') + msg.attach(html_part) + + # Add attachments + if attachments: + for attachment in attachments: + part = MIMEBase('application', 'octet-stream') + part.set_payload(attachment['content']) + encoders.encode_base64(part) + part.add_header( + 'Content-Disposition', + f'attachment; filename= {attachment["filename"]}' + ) + msg.attach(part) + + # Send email + context = ssl.create_default_context() + + with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server: + if settings.SMTP_USE_TLS: + server.starttls(context=context) + + if settings.SMTP_USER and settings.SMTP_PASSWORD: + server.login(settings.SMTP_USER, settings.SMTP_PASSWORD) + + server.send_message(msg) + + task_logger.info( + "Email sent successfully", + to_email=to_email, + subject=subject + ) + + return { + "success": True, + "to_email": to_email, + "subject": subject, + "sent_at": current_task.request.eta or "now" + } + + except Exception as exc: + task_logger.error( + "Failed to send email", + to_email=to_email, + subject=subject, + error=str(exc), + exc_info=True + ) + + # Check if we should retry + if self.request.retries < self.max_retries: + task_logger.info( + "Retrying email send", + retry_count=self.request.retries + 1, + max_retries=self.max_retries + ) + raise self.retry(countdown=60 * (2 ** self.request.retries)) + + return { + "success": False, + "to_email": to_email, + "subject": subject, + "error": str(exc) + } + + +@celery_app.task( + bind=True, + time_limit=300, + soft_time_limit=240 +) +def send_templated_email_task( + self, + to_email: str, + template_name: str, + context: Dict[str, Any], + tenant_id: Optional[str] = None, + correlation_id: Optional[str] = None +) -> Dict[str, Any]: + """ + Send email using a template. + + Args: + to_email: Recipient email + template_name: Template identifier + context: Template context variables + tenant_id: Tenant ID + correlation_id: Correlation ID + + Returns: + Dict with send result + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name, + tenant_id=tenant_id, + correlation_id=correlation_id + ) + + task_logger.info( + "Starting templated email task", + to_email=to_email, + template_name=template_name + ) + + try: + # TODO: Fetch template from database + # For now, use mock templates + templates = { + "ticket_created": { + "subject": "Nuevo ticket #{{ ticket_number }}: {{ subject }}", + "html": """ +

    Nuevo ticket creado

    +

    Hola {{ user_name }},

    +

    Se ha creado un nuevo ticket:

    +
      +
    • Número: #{{ ticket_number }}
    • +
    • Asunto: {{ subject }}
    • +
    • Prioridad: {{ priority }}
    • +
    +

    Ver ticket

    +

    Saludos,
    Equipo de Soporte

    + """, + "text": """ + Nuevo ticket creado + + Hola {{ user_name }}, + + Se ha creado un nuevo ticket: + + Número: #{{ ticket_number }} + Asunto: {{ subject }} + Prioridad: {{ priority }} + + Ver ticket: {{ ticket_url }} + + Saludos, + Equipo de Soporte + """ + }, + "ticket_assigned": { + "subject": "Ticket #{{ ticket_number }} asignado a ti", + "html": """ +

    Ticket asignado

    +

    Hola {{ agent_name }},

    +

    Se te ha asignado el ticket:

    +
      +
    • Número: #{{ ticket_number }}
    • +
    • Asunto: {{ subject }}
    • +
    • Cliente: {{ customer_name }}
    • +
    • Prioridad: {{ priority }}
    • +
    +

    Ver ticket

    + """, + "text": """ + Ticket asignado + + Hola {{ agent_name }}, + + Se te ha asignado el ticket: + + Número: #{{ ticket_number }} + Asunto: {{ subject }} + Cliente: {{ customer_name }} + Prioridad: {{ priority }} + + Ver ticket: {{ ticket_url }} + """ + } + } + + if template_name not in templates: + raise ValueError(f"Template '{template_name}' not found") + + template_data = templates[template_name] + + # Render templates + env = Environment(loader=MemoryLoader({ + f"{template_name}_subject": template_data["subject"], + f"{template_name}_html": template_data["html"], + f"{template_name}_text": template_data["text"] + })) + + subject_template = env.get_template(f"{template_name}_subject") + html_template = env.get_template(f"{template_name}_html") + text_template = env.get_template(f"{template_name}_text") + + subject = subject_template.render(**context) + html_content = html_template.render(**context) + text_content = text_template.render(**context) + + # Send email using the basic send task + return send_email_task.apply_async( + kwargs={ + "to_email": to_email, + "subject": subject, + "html_content": html_content, + "text_content": text_content, + "tenant_id": tenant_id, + "correlation_id": correlation_id + } + ).get() + + except Exception as exc: + task_logger.error( + "Failed to send templated email", + to_email=to_email, + template_name=template_name, + error=str(exc), + exc_info=True + ) + + return { + "success": False, + "to_email": to_email, + "template_name": template_name, + "error": str(exc) + } + + +@celery_app.task( + bind=True, + time_limit=600, + soft_time_limit=540 +) +def send_bulk_email_task( + self, + email_list: List[Dict[str, Any]], + tenant_id: Optional[str] = None, + correlation_id: Optional[str] = None +) -> Dict[str, Any]: + """ + Send bulk emails. + + Args: + email_list: List of email dicts with to_email, subject, content + tenant_id: Tenant ID + correlation_id: Correlation ID + + Returns: + Dict with bulk send results + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name, + tenant_id=tenant_id, + correlation_id=correlation_id + ) + + total_emails = len(email_list) + task_logger.info(f"Starting bulk email task", total_emails=total_emails) + + results = [] + + for i, email_data in enumerate(email_list): + try: + result = send_email_task.apply_async( + kwargs={ + **email_data, + "tenant_id": tenant_id, + "correlation_id": correlation_id + } + ).get() + + results.append(result) + + # Update task progress + current_task.update_state( + state='PROGRESS', + meta={'current': i + 1, 'total': total_emails} + ) + + except Exception as exc: + task_logger.error( + "Failed to send bulk email item", + index=i, + email_data=email_data, + error=str(exc) + ) + + results.append({ + "success": False, + "to_email": email_data.get("to_email"), + "error": str(exc) + }) + + # Calculate stats + successful = sum(1 for r in results if r.get("success")) + failed = total_emails - successful + + task_logger.info( + "Bulk email task completed", + total=total_emails, + successful=successful, + failed=failed + ) + + return { + "total": total_emails, + "successful": successful, + "failed": failed, + "results": results + } \ No newline at end of file diff --git a/workers/app/tasks/maintenance_tasks.py b/workers/app/tasks/maintenance_tasks.py new file mode 100644 index 0000000..eb788a0 --- /dev/null +++ b/workers/app/tasks/maintenance_tasks.py @@ -0,0 +1,371 @@ +""" +Maintenance Tasks - ServiceManagerWeb Workers + +Tareas de mantenimiento del sistema +""" + +from celery import current_task +from datetime import datetime, timedelta +from typing import Dict, Any, List +import os +import structlog + +from app.celery import celery_app +from app.core.config import get_settings +from app.core.logging import get_logger + +settings = get_settings() +logger = get_logger(__name__) + + +@celery_app.task(bind=True) +def health_check(self) -> Dict[str, Any]: + """ + Worker health check task. + + Returns basic health information about the worker. + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name + ) + + try: + current_time = datetime.utcnow() + + # Basic health checks + health_data = { + "status": "healthy", + "timestamp": current_time.isoformat(), + "worker_id": self.request.hostname, + "task_id": self.request.id, + "environment": settings.ENVIRONMENT, + "checks": { + "redis": "unknown", # TODO: Check Redis connectivity + "database": "unknown", # TODO: Check database connectivity + "disk_space": "unknown", # TODO: Check disk space + "memory": "unknown" # TODO: Check memory usage + } + } + + task_logger.info("Worker health check completed", status="healthy") + + return health_data + + except Exception as exc: + task_logger.error( + "Worker health check failed", + error=str(exc), + exc_info=True + ) + + return { + "status": "unhealthy", + "timestamp": datetime.utcnow().isoformat(), + "error": str(exc) + } + + +@celery_app.task( + bind=True, + time_limit=1800, # 30 minutes + soft_time_limit=1500 # 25 minutes +) +def cleanup_old_logs(self) -> Dict[str, Any]: + """ + Clean up old log files and database records. + + Removes: + - Log files older than LOG_RETENTION_DAYS + - Old notification logs + - Old audit logs (if configured) + - Temp files + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name + ) + + task_logger.info("Starting cleanup of old logs") + + try: + current_time = datetime.utcnow() + cutoff_date = current_time - timedelta(days=settings.LOG_RETENTION_DAYS) + + cleanup_results = { + "started_at": current_time.isoformat(), + "cutoff_date": cutoff_date.isoformat(), + "files_removed": 0, + "bytes_freed": 0, + "database_records_removed": 0, + "errors": [] + } + + # TODO: Implement actual file cleanup + # For now, simulate cleanup + + # Clean up log files + log_dir = "/app/logs" + if os.path.exists(log_dir): + for filename in os.listdir(log_dir): + filepath = os.path.join(log_dir, filename) + if os.path.isfile(filepath): + file_mtime = datetime.fromtimestamp(os.path.getmtime(filepath)) + if file_mtime < cutoff_date and filename.endswith('.log'): + try: + file_size = os.path.getsize(filepath) + os.remove(filepath) + cleanup_results["files_removed"] += 1 + cleanup_results["bytes_freed"] += file_size + task_logger.info(f"Removed old log file", filename=filename) + except Exception as e: + cleanup_results["errors"].append(f"Failed to remove {filename}: {str(e)}") + + # TODO: Clean up database records + # - Old notification_logs + # - Old audit_logs (with retention policy) + # - Expired refresh_tokens + # - Old file attachments (if configured) + + task_logger.info( + "Cleanup completed", + files_removed=cleanup_results["files_removed"], + bytes_freed=cleanup_results["bytes_freed"], + errors=len(cleanup_results["errors"]) + ) + + return cleanup_results + + except Exception as exc: + task_logger.error( + "Cleanup task failed", + error=str(exc), + exc_info=True + ) + raise + + +@celery_app.task( + bind=True, + time_limit=3600, # 1 hour + soft_time_limit=3300 # 55 minutes +) +def generate_weekly_reports(self) -> Dict[str, Any]: + """ + Generate weekly reports for all tenants. + + Creates: + - SLA performance reports + - Ticket volume reports + - Agent performance reports + - Customer satisfaction reports + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name + ) + + task_logger.info("Starting weekly reports generation") + + try: + current_time = datetime.utcnow() + week_start = current_time - timedelta(days=7) + + report_results = { + "generated_at": current_time.isoformat(), + "period_start": week_start.isoformat(), + "period_end": current_time.isoformat(), + "reports_generated": [], + "errors": [] + } + + # TODO: Get list of active tenants from database + mock_tenants = [ + {"id": "tenant-1", "name": "Aduanasoft Demo", "slug": "aduanasoft-demo"} + ] + + for tenant in mock_tenants: + try: + task_logger.info( + "Generating report for tenant", + tenant_id=tenant["id"], + tenant_name=tenant["name"] + ) + + # TODO: Generate actual reports + # For now, simulate report generation + + report_data = { + "tenant_id": tenant["id"], + "tenant_name": tenant["name"], + "period_start": week_start.isoformat(), + "period_end": current_time.isoformat(), + "metrics": { + "tickets_created": 25, + "tickets_resolved": 23, + "avg_response_time_hours": 2.1, + "avg_resolution_time_hours": 18.5, + "sla_response_met_percentage": 92.0, + "sla_resolution_met_percentage": 87.0, + "customer_satisfaction_avg": 4.2 + } + } + + report_results["reports_generated"].append(report_data) + + # TODO: Store report in database + # TODO: Send report email to admins + + # Update task progress + current_task.update_state( + state='PROGRESS', + meta={ + 'current': len(report_results["reports_generated"]), + 'total': len(mock_tenants) + } + ) + + except Exception as e: + error_msg = f"Failed to generate report for tenant {tenant['id']}: {str(e)}" + report_results["errors"].append(error_msg) + task_logger.error( + "Report generation failed for tenant", + tenant_id=tenant["id"], + error=str(e) + ) + + task_logger.info( + "Weekly reports generation completed", + reports_generated=len(report_results["reports_generated"]), + errors=len(report_results["errors"]) + ) + + return report_results + + except Exception as exc: + task_logger.error( + "Weekly reports generation failed", + error=str(exc), + exc_info=True + ) + raise + + +@celery_app.task( + bind=True, + time_limit=900, # 15 minutes + soft_time_limit=780 # 13 minutes +) +def cleanup_temp_files(self) -> Dict[str, Any]: + """ + Clean up temporary files and orphaned uploads. + + Removes: + - Temp upload files older than 24 hours + - Orphaned attachment files (no DB reference) + - Processing artifacts + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name + ) + + task_logger.info("Starting temp files cleanup") + + try: + current_time = datetime.utcnow() + cutoff_date = current_time - timedelta(hours=24) + + cleanup_results = { + "started_at": current_time.isoformat(), + "temp_files_removed": 0, + "orphaned_files_removed": 0, + "bytes_freed": 0, + "errors": [] + } + + # Clean up temp directory + temp_dirs = ["/tmp", "/app/temp", f"{settings.UPLOAD_PATH}/temp"] + + for temp_dir in temp_dirs: + if os.path.exists(temp_dir): + for filename in os.listdir(temp_dir): + filepath = os.path.join(temp_dir, filename) + if os.path.isfile(filepath): + try: + file_mtime = datetime.fromtimestamp(os.path.getmtime(filepath)) + if file_mtime < cutoff_date: + file_size = os.path.getsize(filepath) + os.remove(filepath) + cleanup_results["temp_files_removed"] += 1 + cleanup_results["bytes_freed"] += file_size + except Exception as e: + cleanup_results["errors"].append(f"Failed to remove temp file {filepath}: {str(e)}") + + # TODO: Check for orphaned files in uploads directory + # - Query database for all attachment file_paths + # - Compare with actual files in upload directory + # - Remove orphaned files + + task_logger.info( + "Temp files cleanup completed", + temp_files_removed=cleanup_results["temp_files_removed"], + orphaned_files_removed=cleanup_results["orphaned_files_removed"], + bytes_freed=cleanup_results["bytes_freed"] + ) + + return cleanup_results + + except Exception as exc: + task_logger.error( + "Temp files cleanup failed", + error=str(exc), + exc_info=True + ) + raise + + +@celery_app.task( + bind=True, + time_limit=300, + soft_time_limit=240 +) +def database_maintenance(self) -> Dict[str, Any]: + """ + Perform database maintenance tasks. + + - VACUUM and ANALYZE tables + - Update statistics + - Check for slow queries + - Optimize indices if needed + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name + ) + + task_logger.info("Starting database maintenance") + + try: + # TODO: Implement database maintenance + # For now, return placeholder results + + maintenance_results = { + "started_at": datetime.utcnow().isoformat(), + "tables_analyzed": 0, + "indices_optimized": 0, + "slow_queries_found": 0, + "space_reclaimed_mb": 0 + } + + task_logger.info("Database maintenance completed (placeholder)") + + return maintenance_results + + except Exception as exc: + task_logger.error( + "Database maintenance failed", + error=str(exc), + exc_info=True + ) + raise \ No newline at end of file diff --git a/workers/app/tasks/notification_tasks.py b/workers/app/tasks/notification_tasks.py new file mode 100644 index 0000000..002c8df --- /dev/null +++ b/workers/app/tasks/notification_tasks.py @@ -0,0 +1,469 @@ +""" +Notification Tasks - ServiceManagerWeb Workers + +Tareas para notificaciones y comunicaciones +""" + +from celery import current_task +from datetime import datetime, timedelta +from typing import Dict, Any, List, Optional +import structlog + +from app.celery import celery_app +from app.core.config import get_settings +from app.core.logging import get_logger +from app.tasks.email_tasks import send_templated_email_task, send_bulk_email_task + +settings = get_settings() +logger = get_logger(__name__) + + +@celery_app.task( + bind=True, + time_limit=900, # 15 minutes + soft_time_limit=780 # 13 minutes +) +def send_daily_digest(self) -> Dict[str, Any]: + """ + Send daily digest emails to agents and managers. + + Includes: + - New tickets assigned + - SLA warnings + - Performance summary + - Pending tasks + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name + ) + + task_logger.info("Starting daily digest generation") + + if not settings.DIGEST_ENABLED: + task_logger.info("Daily digest disabled, skipping") + return {"status": "disabled"} + + try: + current_time = datetime.utcnow() + yesterday = current_time - timedelta(days=1) + + digest_results = { + "generated_at": current_time.isoformat(), + "period_start": yesterday.isoformat(), + "period_end": current_time.isoformat(), + "digests_sent": 0, + "errors": [] + } + + # TODO: Get active agents and managers from database + mock_recipients = [ + { + "user_id": "user-1", + "email": "agent1@example.com", + "name": "Agent One", + "role": "AGENT", + "tenant_id": "tenant-1" + }, + { + "user_id": "user-2", + "email": "manager@example.com", + "name": "Support Manager", + "role": "SUPPORT_MANAGER", + "tenant_id": "tenant-1" + } + ] + + for recipient in mock_recipients: + try: + task_logger.info( + "Generating digest for user", + user_id=recipient["user_id"], + email=recipient["email"], + role=recipient["role"] + ) + + # TODO: Generate actual digest data from database + digest_data = generate_digest_data( + recipient["user_id"], + recipient["role"], + recipient["tenant_id"], + yesterday, + current_time + ) + + # Send digest email + send_templated_email_task.apply_async(kwargs={ + "to_email": recipient["email"], + "template_name": "daily_digest", + "context": { + "user_name": recipient["name"], + "role": recipient["role"], + "date": current_time.strftime("%Y-%m-%d"), + **digest_data + }, + "tenant_id": recipient["tenant_id"], + "correlation_id": self.request.id + }) + + digest_results["digests_sent"] += 1 + + except Exception as e: + error_msg = f"Failed to send digest to {recipient['email']}: {str(e)}" + digest_results["errors"].append(error_msg) + task_logger.error( + "Digest generation failed for user", + user_id=recipient["user_id"], + error=str(e) + ) + + task_logger.info( + "Daily digest generation completed", + digests_sent=digest_results["digests_sent"], + errors=len(digest_results["errors"]) + ) + + return digest_results + + except Exception as exc: + task_logger.error( + "Daily digest generation failed", + error=str(exc), + exc_info=True + ) + raise + + +def generate_digest_data( + user_id: str, + role: str, + tenant_id: str, + period_start: datetime, + period_end: datetime +) -> Dict[str, Any]: + """ + Generate digest data for a specific user. + + Args: + user_id: User ID + role: User role + tenant_id: Tenant ID + period_start: Start of digest period + period_end: End of digest period + + Returns: + Dict with digest data + """ + # TODO: Implement actual database queries + # For now, return mock data + + base_data = { + "summary": { + "new_tickets": 5, + "resolved_tickets": 7, + "pending_tickets": 12, + "overdue_tickets": 2 + }, + "sla_status": { + "response_sla_met": 8, + "response_sla_missed": 1, + "resolution_sla_met": 6, + "resolution_sla_missed": 2 + } + } + + if role == "AGENT": + base_data.update({ + "assigned_tickets": [ + { + "ticket_number": "TKT-2024-000001", + "subject": "Problema de conexión", + "priority": "HIGH", + "created_at": "2024-01-15T10:00:00Z", + "sla_due": "2024-01-15T12:00:00Z" + } + ], + "urgent_tickets": 1, + "performance": { + "avg_response_time_hours": 1.5, + "avg_resolution_time_hours": 18.2, + "customer_satisfaction": 4.3 + } + }) + + elif role in ["SUPPORT_MANAGER", "ADMIN"]: + base_data.update({ + "team_summary": { + "total_agents": 5, + "active_agents": 4, + "avg_load_per_agent": 6.2 + }, + "escalations": [ + { + "ticket_number": "TKT-2024-000002", + "reason": "SLA violation", + "assigned_to": "agent1@example.com" + } + ], + "trends": { + "ticket_volume_change": "+12%", + "resolution_time_change": "-5%" + } + }) + + return base_data + + +@celery_app.task( + bind=True, + time_limit=600, + soft_time_limit=540 +) +def send_ticket_notifications( + self, + ticket_id: str, + event_type: str, + tenant_id: str, + context: Dict[str, Any], + correlation_id: Optional[str] = None +) -> Dict[str, Any]: + """ + Send ticket-related notifications. + + Args: + ticket_id: Ticket ID + event_type: Type of event (created, assigned, updated, resolved, etc.) + tenant_id: Tenant ID + context: Context data for notifications + correlation_id: Correlation ID + + Returns: + Dict with notification results + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name, + ticket_id=ticket_id, + event_type=event_type, + tenant_id=tenant_id, + correlation_id=correlation_id + ) + + task_logger.info("Starting ticket notifications") + + try: + notification_results = { + "ticket_id": ticket_id, + "event_type": event_type, + "notifications_sent": 0, + "notifications": [] + } + + # Determine who should receive notifications based on event type + recipients = get_notification_recipients(ticket_id, event_type, tenant_id) + + for recipient in recipients: + try: + template_name = f"ticket_{event_type}" + + # Send notification + result = send_templated_email_task.apply_async(kwargs={ + "to_email": recipient["email"], + "template_name": template_name, + "context": { + **context, + "recipient_name": recipient["name"], + "recipient_role": recipient["role"] + }, + "tenant_id": tenant_id, + "correlation_id": correlation_id or self.request.id + }).get() + + notification_results["notifications"].append({ + "recipient": recipient["email"], + "template": template_name, + "success": result.get("success", False), + "error": result.get("error") + }) + + if result.get("success"): + notification_results["notifications_sent"] += 1 + + except Exception as e: + task_logger.error( + "Failed to send notification", + recipient_email=recipient["email"], + error=str(e) + ) + + notification_results["notifications"].append({ + "recipient": recipient["email"], + "success": False, + "error": str(e) + }) + + task_logger.info( + "Ticket notifications completed", + notifications_sent=notification_results["notifications_sent"], + total_recipients=len(recipients) + ) + + return notification_results + + except Exception as exc: + task_logger.error( + "Ticket notifications failed", + error=str(exc), + exc_info=True + ) + raise + + +def get_notification_recipients( + ticket_id: str, + event_type: str, + tenant_id: str +) -> List[Dict[str, Any]]: + """ + Get list of users who should receive notifications for a ticket event. + + Args: + ticket_id: Ticket ID + event_type: Event type + tenant_id: Tenant ID + + Returns: + List of recipient dicts + """ + # TODO: Implement actual database queries + # For now, return mock recipients based on event type + + recipients = [] + + if event_type == "created": + # Notify assigned agent (if any) and customer + recipients = [ + {"email": "customer@example.com", "name": "Customer", "role": "CLIENT_USER"}, + {"email": "agent@example.com", "name": "Agent", "role": "AGENT"} + ] + + elif event_type == "assigned": + # Notify assigned agent and customer + recipients = [ + {"email": "agent@example.com", "name": "Assigned Agent", "role": "AGENT"}, + {"email": "customer@example.com", "name": "Customer", "role": "CLIENT_USER"} + ] + + elif event_type == "updated": + # Notify all participants + recipients = [ + {"email": "customer@example.com", "name": "Customer", "role": "CLIENT_USER"}, + {"email": "agent@example.com", "name": "Agent", "role": "AGENT"} + ] + + elif event_type == "resolved": + # Notify customer for feedback + recipients = [ + {"email": "customer@example.com", "name": "Customer", "role": "CLIENT_USER"} + ] + + elif event_type == "escalated": + # Notify manager + recipients = [ + {"email": "manager@example.com", "name": "Manager", "role": "SUPPORT_MANAGER"} + ] + + return recipients + + +@celery_app.task( + bind=True, + time_limit=300, + soft_time_limit=240 +) +def send_system_alert( + self, + alert_type: str, + message: str, + severity: str = "INFO", + tenant_id: Optional[str] = None, + context: Optional[Dict[str, Any]] = None +) -> Dict[str, Any]: + """ + Send system alert to administrators. + + Args: + alert_type: Type of alert (system_error, sla_violation, etc.) + message: Alert message + severity: Alert severity (INFO, WARNING, ERROR, CRITICAL) + tenant_id: Optional tenant ID + context: Additional context data + + Returns: + Dict with alert results + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name, + alert_type=alert_type, + severity=severity, + tenant_id=tenant_id + ) + + task_logger.info("Sending system alert", message=message) + + try: + # TODO: Get administrators from configuration/database + admin_emails = ["admin@example.com", "alerts@example.com"] + + alert_context = { + "alert_type": alert_type, + "message": message, + "severity": severity, + "timestamp": datetime.utcnow().isoformat(), + "environment": settings.ENVIRONMENT, + "tenant_id": tenant_id, + **(context or {}) + } + + notifications_sent = 0 + + for admin_email in admin_emails: + try: + send_templated_email_task.apply_async(kwargs={ + "to_email": admin_email, + "template_name": "system_alert", + "context": alert_context, + "tenant_id": tenant_id, + "correlation_id": self.request.id + }) + notifications_sent += 1 + + except Exception as e: + task_logger.error( + "Failed to send alert to admin", + admin_email=admin_email, + error=str(e) + ) + + task_logger.info( + "System alert sent", + notifications_sent=notifications_sent, + total_admins=len(admin_emails) + ) + + return { + "alert_type": alert_type, + "message": message, + "severity": severity, + "notifications_sent": notifications_sent, + "sent_at": datetime.utcnow().isoformat() + } + + except Exception as exc: + task_logger.error( + "System alert failed", + error=str(exc), + exc_info=True + ) + raise \ No newline at end of file diff --git a/workers/app/tasks/sla_tasks.py b/workers/app/tasks/sla_tasks.py new file mode 100644 index 0000000..51a5737 --- /dev/null +++ b/workers/app/tasks/sla_tasks.py @@ -0,0 +1,327 @@ +""" +SLA Tasks - ServiceManagerWeb Workers + +Tareas para monitoreo y gestión de SLAs +""" + +from celery import current_task +from datetime import datetime, timedelta +from typing import List, Dict, Any, Optional +import structlog + +from app.celery import celery_app +from app.core.config import get_settings +from app.core.logging import get_logger +from app.tasks.email_tasks import send_templated_email_task + +settings = get_settings() +logger = get_logger(__name__) + + +@celery_app.task( + bind=True, + time_limit=300, + soft_time_limit=240 +) +def check_sla_violations(self) -> Dict[str, Any]: + """ + Check for SLA violations and send alerts. + + This task runs every 5 minutes to check for: + - Response SLA violations + - Resolution SLA violations + - SLA warnings (approaching deadline) + + Returns: + Dict with check results + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name + ) + + task_logger.info("Starting SLA violations check") + + if not settings.SLA_CHECK_ENABLED: + task_logger.info("SLA check disabled, skipping") + return {"status": "disabled"} + + try: + current_time = datetime.utcnow() + results = { + "checked_at": current_time.isoformat(), + "response_violations": [], + "resolution_violations": [], + "warnings": [], + "notifications_sent": 0 + } + + # TODO: Implement actual database queries + # For now, simulate some checks + + # Mock violations for development + mock_violations = [ + { + "ticket_id": "mock-ticket-1", + "ticket_number": "TKT-2024-000001", + "subject": "Problema urgente de conexión", + "priority": "HIGH", + "sla_type": "response", + "due_at": (current_time - timedelta(minutes=30)).isoformat(), + "assigned_to_email": "agent@example.com", + "created_by_email": "cliente@example.com", + "tenant_id": "mock-tenant-1" + } + ] + + # Process violations + for violation in mock_violations: + task_logger.info( + "Processing SLA violation", + ticket_id=violation["ticket_id"], + sla_type=violation["sla_type"] + ) + + if violation["sla_type"] == "response": + results["response_violations"].append(violation) + + # Send notification to assigned agent + if violation["assigned_to_email"]: + send_templated_email_task.apply_async(kwargs={ + "to_email": violation["assigned_to_email"], + "template_name": "sla_response_violation", + "context": { + "ticket_number": violation["ticket_number"], + "subject": violation["subject"], + "priority": violation["priority"], + "due_at": violation["due_at"], + "ticket_url": f"https://admin.servicemanager.local/tickets/{violation['ticket_id']}" + }, + "tenant_id": violation["tenant_id"], + "correlation_id": self.request.id + }) + results["notifications_sent"] += 1 + + elif violation["sla_type"] == "resolution": + results["resolution_violations"].append(violation) + + # Send escalation notification + send_templated_email_task.apply_async(kwargs={ + "to_email": "manager@example.com", # TODO: Get from tenant config + "template_name": "sla_resolution_violation", + "context": { + "ticket_number": violation["ticket_number"], + "subject": violation["subject"], + "priority": violation["priority"], + "assigned_to": violation["assigned_to_email"], + "ticket_url": f"https://admin.servicemanager.local/tickets/{violation['ticket_id']}" + }, + "tenant_id": violation["tenant_id"], + "correlation_id": self.request.id + }) + results["notifications_sent"] += 1 + + # TODO: Check for SLA warnings (approaching deadline) + + task_logger.info( + "SLA violations check completed", + response_violations=len(results["response_violations"]), + resolution_violations=len(results["resolution_violations"]), + warnings=len(results["warnings"]), + notifications_sent=results["notifications_sent"] + ) + + return results + + except Exception as exc: + task_logger.error( + "SLA violations check failed", + error=str(exc), + exc_info=True + ) + raise + + +@celery_app.task( + bind=True, + time_limit=600, + soft_time_limit=540 +) +def calculate_sla_metrics(self, tenant_id: str, date_from: str, date_to: str) -> Dict[str, Any]: + """ + Calculate SLA metrics for a tenant and date range. + + Args: + tenant_id: Tenant ID + date_from: Start date (ISO format) + date_to: End date (ISO format) + + Returns: + Dict with SLA metrics + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name, + tenant_id=tenant_id + ) + + task_logger.info( + "Starting SLA metrics calculation", + date_from=date_from, + date_to=date_to + ) + + try: + # TODO: Implement actual database queries + # For now, return mock metrics + + metrics = { + "tenant_id": tenant_id, + "date_from": date_from, + "date_to": date_to, + "calculated_at": datetime.utcnow().isoformat(), + "response_sla": { + "target_hours": 2, + "met_count": 45, + "total_count": 50, + "percentage": 90.0, + "avg_response_time_hours": 1.8 + }, + "resolution_sla": { + "target_hours": 24, + "met_count": 42, + "total_count": 48, + "percentage": 87.5, + "avg_resolution_time_hours": 22.5 + }, + "by_priority": { + "LOW": { + "response_sla_percentage": 95.0, + "resolution_sla_percentage": 90.0 + }, + "MEDIUM": { + "response_sla_percentage": 88.0, + "resolution_sla_percentage": 85.0 + }, + "HIGH": { + "response_sla_percentage": 92.0, + "resolution_sla_percentage": 88.0 + }, + "URGENT": { + "response_sla_percentage": 85.0, + "resolution_sla_percentage": 80.0 + } + }, + "trends": { + "response_sla_trend": "+2.5%", + "resolution_sla_trend": "-1.2%" + } + } + + task_logger.info( + "SLA metrics calculation completed", + response_sla_percentage=metrics["response_sla"]["percentage"], + resolution_sla_percentage=metrics["resolution_sla"]["percentage"] + ) + + return metrics + + except Exception as exc: + task_logger.error( + "SLA metrics calculation failed", + error=str(exc), + exc_info=True + ) + raise + + +@celery_app.task( + bind=True, + time_limit=300, + soft_time_limit=240 +) +def send_sla_warnings(self, tenant_id: Optional[str] = None) -> Dict[str, Any]: + """ + Send SLA warning notifications for tickets approaching deadline. + + Args: + tenant_id: Optional tenant ID to filter by + + Returns: + Dict with warning results + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name, + tenant_id=tenant_id + ) + + task_logger.info("Starting SLA warnings check") + + try: + current_time = datetime.utcnow() + warning_threshold = settings.SLA_WARNING_THRESHOLD # 80% of SLA time + + # TODO: Query database for tickets approaching SLA deadlines + + # Mock warnings + warnings = [ + { + "ticket_id": "mock-ticket-2", + "ticket_number": "TKT-2024-000002", + "subject": "Consulta técnica", + "priority": "MEDIUM", + "sla_type": "response", + "due_at": (current_time + timedelta(minutes=30)).isoformat(), + "time_remaining_percent": 15.0, + "assigned_to_email": "agent@example.com", + "tenant_id": "mock-tenant-1" + } + ] + + notifications_sent = 0 + + for warning in warnings: + if warning["time_remaining_percent"] <= (1 - warning_threshold) * 100: + task_logger.info( + "Sending SLA warning", + ticket_id=warning["ticket_id"], + time_remaining_percent=warning["time_remaining_percent"] + ) + + send_templated_email_task.apply_async(kwargs={ + "to_email": warning["assigned_to_email"], + "template_name": "sla_warning", + "context": { + "ticket_number": warning["ticket_number"], + "subject": warning["subject"], + "priority": warning["priority"], + "sla_type": warning["sla_type"], + "due_at": warning["due_at"], + "time_remaining_percent": warning["time_remaining_percent"], + "ticket_url": f"https://admin.servicemanager.local/tickets/{warning['ticket_id']}" + }, + "tenant_id": warning["tenant_id"], + "correlation_id": self.request.id + }) + notifications_sent += 1 + + task_logger.info( + "SLA warnings check completed", + warnings_found=len(warnings), + notifications_sent=notifications_sent + ) + + return { + "warnings_found": len(warnings), + "notifications_sent": notifications_sent, + "warnings": warnings + } + + except Exception as exc: + task_logger.error( + "SLA warnings check failed", + error=str(exc), + exc_info=True + ) + raise \ No newline at end of file diff --git a/workers/requirements.txt b/workers/requirements.txt new file mode 100644 index 0000000..d76886d --- /dev/null +++ b/workers/requirements.txt @@ -0,0 +1,74 @@ +# Celery Workers Requirements - ServiceManagerWeb +# Dependencias para workers asíncronos + +# =================================== +# CORE CELERY +# =================================== +celery==5.3.4 +redis==5.0.1 + +# =================================== +# DATABASE (shared with backend) +# =================================== +sqlalchemy==2.0.23 +asyncpg==0.29.0 +psycopg2-binary==2.9.9 +alembic==1.13.0 + +# =================================== +# CONFIGURATION & VALIDATION +# =================================== +pydantic==2.5.0 +pydantic-settings==2.1.0 + +# =================================== +# EMAIL PROCESSING +# =================================== +email-validator==2.1.0 +jinja2==3.1.2 +aiosmtplib==3.0.1 # Async SMTP client +premailer==3.10.0 # CSS inlining for emails +beautifulsoup4==4.12.2 # HTML parsing + +# =================================== +# FILE PROCESSING +# =================================== +python-magic==0.4.27 +pillow==10.1.0 +pypdf==3.17.1 # PDF processing +openpyxl==3.1.2 # Excel processing + +# =================================== +# HTTP CLIENTS +# =================================== +httpx==0.25.2 +aiofiles==23.2.1 + +# =================================== +# UTILITIES +# =================================== +python-dateutil==2.8.2 +pytz==2023.3 +slugify==0.0.1 + +# =================================== +# LOGGING & MONITORING +# =================================== +structlog==23.2.0 +prometheus-client==0.19.0 +sentry-sdk==1.38.0 + +# =================================== +# DEVELOPMENT +# =================================== +pytest==7.4.3 +pytest-asyncio==0.21.1 +pytest-celery==0.0.0a1 +faker==20.1.0 + +# =================================== +# CODE QUALITY +# =================================== +ruff==0.1.7 +black==23.11.0 +mypy==1.7.1 \ No newline at end of file