=== ESTRUCTURA DEL PROYECTO === ./ .env.example .gitignore docker-compose.yml proyecto_completo.txt README.md .github/ copilot-instructions.md backend/ add_columns.py fix_password_script.py pyproject.toml README.md requirements.txt alembic/ versions/ add_clients_table.py app/ main.py api/ deps.py v1/ router.py endpoints/ auth.py categories.py clients.py health.py systems.py tenants.py users.py core/ config.py database.py logging.py security.py middleware/ correlation_id.py tenant.py models/ category.py client.py system.py tenant.py ticket.py user.py clientes/ schemas/ client.py logs/ tests/ test_clients.py uploads/ db/ schema.sql docker/ Dockerfile.backend Dockerfile.frontend Dockerfile.worker nginx/ default.conf nginx.conf docs/ api-contract.md database-schema.md frontend-client/ .eslintrc.json .prettierignore .prettierrc package.json postcss.config.js svelte.config.js tailwind.config.js vite.config.js .svelte-kit/ ambient.d.ts tsconfig.json generated/ root.svelte client/ app.js matchers.js nodes/ 0.js 1.js 2.js 3.js 4.js 5.js 6.js 7.js server/ internal.js types/ route_meta_data.json src/ routes/ $types.d.ts login/ $types.d.ts profile/ $types.d.ts tickets/ $types.d.ts new/ $types.d.ts [id]/ $types.d.ts src/ app.css app.html lib/ components/ Header.svelte Icon.svelte TicketCard.svelte Toast.svelte stores/ app.ts auth.ts clientes.ts tickets.ts toast.ts routes/ +layout.svelte +page.svelte login/ +page.svelte profile/ +page.svelte tickets/ +page.svelte new/ +page.svelte [id]/ +page.svelte static/ images/ Icono AS(1).png Logo AS 192px -192px(1).png Logo AS 512px - 512px(1).png Logo AS blanco(1).png SOPORTE.webp frontend-internal/ .eslintrc.json .prettierignore .prettierrc package.json postcss.config.js svelte.config.js tailwind.config.js vite.config.js .svelte-kit/ ambient.d.ts tsconfig.json generated/ root.svelte client/ app.js matchers.js nodes/ 0.js 1.js 2.js 3.js 4.js 5.js 6.js 7.js server/ internal.js types/ route_meta_data.json src/ routes/ $types.d.ts categories/ $types.d.ts login/ $types.d.ts systems/ $types.d.ts tenants/ $types.d.ts users/ $types.d.ts src/ app.css app.html lib/ components/ Header.svelte Icon.svelte Modal.svelte Sidebar.svelte Toast.svelte stores/ api.ts auth.ts toast.ts utils/ api.ts routes/ +layout.svelte +page.svelte categories/ +page.svelte login/ +page.svelte systems/ +page.svelte tenants/ +page.svelte users/ +page.svelte scripts/ README.md setup-dev.sh workers/ README.md requirements.txt app/ celery.py core/ config.py logging.py tasks/ email_tasks.py maintenance_tasks.py notification_tasks.py sla_tasks.py beat-data/ celerybeat-schedule/ logs/ uploads/ Zpracticante/ Documentacion Practicas.docx ~$cumentacion Practicas.docx Backups/ respaldo_docker_v1.sql === CONTENIDO DE LOS ARCHIVOS === ================================================== ARCHIVO: .\.env.example ================================================== # 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 ================================================== ARCHIVO: .\docker-compose.yml ================================================== 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 command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload 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 ================================================== ARCHIVO: .\README.md ================================================== # 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 ================================================== ARCHIVO: .\.github\copilot-instructions.md ================================================== # 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. ================================================== ARCHIVO: .\backend\add_columns.py ================================================== 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()) ================================================== ARCHIVO: .\backend\fix_password_script.py ================================================== 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()) ================================================== ARCHIVO: .\backend\pyproject.toml ================================================== # 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", ] ================================================== ARCHIVO: .\backend\README.md ================================================== # 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 ================================================== ARCHIVO: .\backend\alembic\versions\add_clients_table.py ================================================== """ Add clients table """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "add_clients_table" down_revision = None branch_labels = None depends_on = None def upgrade(): op.create_table( "clients", sa.Column("id", sa.Integer, primary_key=True, index=True), sa.Column("name", sa.String, nullable=False), sa.Column("email", sa.String, unique=True, nullable=False), sa.Column("phone", sa.String, nullable=False), ) def downgrade(): op.drop_table("clients") ================================================== ARCHIVO: .\backend\app\main.py ================================================== """ 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" ) ================================================== ARCHIVO: .\backend\app\api\deps.py ================================================== 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 ================================================== ARCHIVO: .\backend\app\api\v1\router.py ================================================== """ 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, clients 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"] ) ================================================== ARCHIVO: .\backend\app\api\v1\endpoints\auth.py ================================================== """ 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 ================================================== ARCHIVO: .\backend\app\api\v1\endpoints\categories.py ================================================== 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 ================================================== ARCHIVO: .\backend\app\api\v1\endpoints\clients.py ================================================== from fastapi import APIRouter, HTTPException, Depends from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import text # <--- IMPORTANTE: Necesario para consultas SQL from app.core.database import get_db # IMPORTANTE: Renombramos para evitar conflictos from app.models.client import Client as ClientModel from app.schemas.client import ClientCreate, ClientUpdate, Client as ClientSchema router = APIRouter() # GET: Obtener todos los clientes @router.get("/clients", response_model=list[ClientSchema]) async def get_clients(db: AsyncSession = Depends(get_db)): # Corrección: Usamos text() y mappings().all() query = text("SELECT * FROM clients") result = await db.execute(query) return result.mappings().all() # POST: Crear cliente @router.post("/clients", response_model=ClientSchema) async def create_client(client: ClientCreate, db: AsyncSession = Depends(get_db)): # Usamos ClientModel para guardar en BD new_client = ClientModel(**client.dict()) db.add(new_client) await db.commit() await db.refresh(new_client) return new_client # GET ONE: Obtener un cliente por ID @router.get("/clients/{client_id}", response_model=ClientSchema) async def read_client(client_id: int, db: AsyncSession = Depends(get_db)): db_client = await db.get(ClientModel, client_id) if not db_client: raise HTTPException(status_code=404, detail="Client not found") return db_client # PUT: Actualizar cliente @router.put("/clients/{client_id}", response_model=ClientSchema) async def update_client(client_id: int, client: ClientUpdate, db: AsyncSession = Depends(get_db)): db_client = await db.get(ClientModel, client_id) if not db_client: raise HTTPException(status_code=404, detail="Client not found") for key, value in client.dict(exclude_unset=True).items(): setattr(db_client, key, value) await db.commit() await db.refresh(db_client) return db_client # DELETE: Borrar cliente @router.delete("/clients/{client_id}") async def delete_client(client_id: int, db: AsyncSession = Depends(get_db)): db_client = await db.get(ClientModel, client_id) if not db_client: raise HTTPException(status_code=404, detail="Client not found") await db.delete(db_client) await db.commit() return {"message": "Client deleted successfully"} ================================================== ARCHIVO: .\backend\app\api\v1\endpoints\health.py ================================================== """ 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"} ================================================== ARCHIVO: .\backend\app\api\v1\endpoints\systems.py ================================================== 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 ================================================== ARCHIVO: .\backend\app\api\v1\endpoints\tenants.py ================================================== 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 ================================================== ARCHIVO: .\backend\app\api\v1\endpoints\users.py ================================================== 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 ================================================== ARCHIVO: .\backend\app\core\config.py ================================================== """ 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() ================================================== ARCHIVO: .\backend\app\core\database.py ================================================== """ 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 ================================================== ARCHIVO: .\backend\app\core\logging.py ================================================== """ 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) ================================================== ARCHIVO: .\backend\app\core\security.py ================================================== """ 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() ================================================== ARCHIVO: .\backend\app\middleware\correlation_id.py ================================================== """ 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 ================================================== ARCHIVO: .\backend\app\middleware\tenant.py ================================================== """ 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) ================================================== ARCHIVO: .\backend\app\models\category.py ================================================== """ 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"" ================================================== ARCHIVO: .\backend\app\models\client.py ================================================== from sqlalchemy import Column, Integer, String from app.core.database import Base class Client(Base): __tablename__ = "clients" id = Column(Integer, primary_key=True, index=True) clave = Column(String, nullable=False) tipo_cliente = Column(String, nullable=False) nombre = Column(String, nullable=False) pais = Column(String, nullable=False) estado = Column(String, nullable=False) ciudad = Column(String, nullable=False) direccion = Column(String, nullable=False) numero_ext = Column(String, nullable=True) cp = Column(String, nullable=True) colonia = Column(String, nullable=True) lada = Column(String, nullable=True) telefono1 = Column(String, nullable=True) telefono2 = Column(String, nullable=True) tel_directo = Column(String, nullable=True) ext = Column(String, nullable=True) fax = Column(String, nullable=True) horario = Column(String, nullable=True) pagina = Column(String, nullable=True) correo = Column(String, nullable=True) medio_publicidad = Column(String, nullable=True) nacionalidad = Column(String, nullable=True) logo = Column(String, nullable=True) ================================================== ARCHIVO: .\backend\app\models\system.py ================================================== """ 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"" ================================================== ARCHIVO: .\backend\app\models\tenant.py ================================================== """ 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 ================================================== ARCHIVO: .\backend\app\models\ticket.py ================================================== """ 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" ) ================================================== ARCHIVO: .\backend\app\models\user.py ================================================== """ 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 ================================================== ARCHIVO: .\backend\app\schemas\client.py ================================================== from pydantic import BaseModel class ClientBase(BaseModel): clave: str tipo_cliente: str nombre: str pais: str estado: str ciudad: str direccion: str numero_ext: str | None = None cp: str | None = None colonia: str | None = None lada: str | None = None telefono1: str | None = None telefono2: str | None = None tel_directo: str | None = None ext: str | None = None fax: str | None = None horario: str | None = None pagina: str | None = None correo: str | None = None medio_publicidad: str | None = None nacionalidad: str | None = None logo: str | None = None class ClientCreate(ClientBase): pass class ClientUpdate(ClientBase): pass class Client(ClientBase): id: int class Config: orm_mode = True ================================================== ARCHIVO: .\backend\tests\test_clients.py ================================================== import pytest from fastapi.testclient import TestClient from app.main import app client = TestClient(app) @pytest.fixture def sample_client_data(): return { "clave": "12345", "tipo_cliente": "Regular", "nombre": "Cliente Prueba", "pais": "México", "estado": "Chihuahua", "ciudad": "Cd. Juárez", "direccion": "Calle Falsa 123", "numero_ext": "12", "cp": "32000", "colonia": "Centro", "lada": "656", "telefono1": "1234567890", "telefono2": "0987654321", "tel_directo": "1231231234", "ext": "101", "fax": "1231231235", "horario": "9:00 - 18:00", "pagina": "www.clienteprueba.com", "correo": "cliente@prueba.com", "medio_publicidad": "Internet", "nacionalidad": "Mexicana", "logo": "logo.png" } def test_create_client(sample_client_data): response = client.post("/api/v1/clients", json=sample_client_data) assert response.status_code == 200 assert response.json()["clave"] == sample_client_data["clave"] def test_read_client(sample_client_data): # Create a client first create_response = client.post("/api/v1/clients", json=sample_client_data) client_id = create_response.json()["id"] # Read the client response = client.get(f"/api/v1/clients/{client_id}") assert response.status_code == 200 assert response.json()["id"] == client_id def test_update_client(sample_client_data): # Create a client first create_response = client.post("/api/v1/clients", json=sample_client_data) client_id = create_response.json()["id"] # Update the client updated_data = {"nombre": "Cliente Actualizado"} response = client.put(f"/api/v1/clients/{client_id}", json=updated_data) assert response.status_code == 200 assert response.json()["nombre"] == "Cliente Actualizado" def test_delete_client(sample_client_data): # Create a client first create_response = client.post("/api/v1/clients", json=sample_client_data) client_id = create_response.json()["id"] # Delete the client response = client.delete(f"/api/v1/clients/{client_id}") assert response.status_code == 200 # Verify deletion response = client.get(f"/api/v1/clients/{client_id}") assert response.status_code == 404 ================================================== ARCHIVO: .\db\schema.sql ================================================== -- 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: CLIENTS (Cartera de Clientes) -- =================================== -- Enum para los radio buttons de estatus CREATE TYPE client_status_enum AS ENUM ( 'prospect', 'active', 'suspended', 'cancelled' ); CREATE TABLE clients ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, -- Identificación (Sección General) code VARCHAR(50), -- Clave del cliente name VARCHAR(200) NOT NULL, -- Nombre / Razón Social tax_id VARCHAR(20), -- RFC client_type VARCHAR(50), -- Tipo de Cliente account_manager VARCHAR(100), -- Encargado Cliente (Texto simple por ahora) -- Dirección (Desglosada) address_street TEXT, -- Dirección / Calle address_ext_num VARCHAR(20), -- Núm. Ext neighborhood VARCHAR(100), -- Colonia zip_code VARCHAR(10), -- CP city VARCHAR(100), -- Ciudad state VARCHAR(100), -- Estado country VARCHAR(100) DEFAULT 'Mexico', -- Contacto phone_primary VARCHAR(20), -- Teléfono 1 phone_secondary VARCHAR(20),-- Teléfono 2 fax VARCHAR(20), email VARCHAR(320), -- Correo website VARCHAR(255), -- Página Web -- Configuración y Flexibilidad status client_status_enum DEFAULT 'prospect', logo_url VARCHAR(500), -- Ruta al archivo PDF/Imagen del logo -- Campo JSONB para lo que sobre (Horario, Nacionalidad, Medio Publicidad, Lada, etc.) properties JSONB DEFAULT '{}'::jsonb, -- Auditoría estándar is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), -- Restricciones: No repetir RFC ni Clave dentro del mismo Tenant UNIQUE(tenant_id, code), UNIQUE(tenant_id, tax_id) ); -- Índices para búsqueda rápida CREATE INDEX idx_clients_tenant_id ON clients(tenant_id); CREATE INDEX idx_clients_tax_id ON clients(tax_id); CREATE INDEX idx_clients_name ON clients(name); CREATE INDEX idx_clients_properties ON clients USING gin (properties); -- Trigger para mantener actualizado el campo updated_at -- (Usa la función que ya definiste al final de tu script) CREATE TRIGGER update_clients_updated_at BEFORE UPDATE ON clients FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- =================================== -- 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'; ================================================== ARCHIVO: .\docs\api-contract.md ================================================== # 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 ================================================== ARCHIVO: .\docs\database-schema.md ================================================== # 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 ================================================== ARCHIVO: .\frontend-client\.eslintrc.json ================================================== { "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/**" ] } ================================================== ARCHIVO: .\frontend-client\package.json ================================================== { "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" } } ================================================== ARCHIVO: .\frontend-client\postcss.config.js ================================================== export default { plugins: { tailwindcss: {}, autoprefixer: {}, }, } ================================================== ARCHIVO: .\frontend-client\svelte.config.js ================================================== 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; ================================================== ARCHIVO: .\frontend-client\tailwind.config.js ================================================== /** @type {import('tailwindcss').Config} */ export default { content: ['./src/**/*.{html,js,svelte,ts}'], theme: { extend: { colors: { // Brand colors for ServiceManager primary: { 50: '#eff6ff', 100: '#dbeafe', 200: '#bfdbfe', 300: '#93c5fd', 400: '#60a5fa', 500: '#3b82f6', // Main brand color 600: '#2563eb', 700: '#1d4ed8', 800: '#1e40af', 900: '#1e3a8a', }, secondary: { 50: '#f8fafc', 100: '#f1f5f9', 200: '#e2e8f0', 300: '#cbd5e1', 400: '#94a3b8', 500: '#64748b', 600: '#475569', 700: '#334155', 800: '#1e293b', 900: '#0f172a', }, success: { 50: '#f0fdf4', 100: '#dcfce7', 200: '#bbf7d0', 300: '#86efac', 400: '#4ade80', 500: '#22c55e', 600: '#16a34a', 700: '#15803d', 800: '#166534', 900: '#14532d', }, warning: { 50: '#fffbeb', 100: '#fef3c7', 200: '#fde68a', 300: '#fcd34d', 400: '#fbbf24', 500: '#f59e0b', 600: '#d97706', 700: '#b45309', 800: '#92400e', 900: '#78350f', }, error: { 50: '#fef2f2', 100: '#fee2e2', 200: '#fecaca', 300: '#fca5a5', 400: '#f87171', 500: '#ef4444', 600: '#dc2626', 700: '#b91c1c', 800: '#991b1b', 900: '#7f1d1d', } }, fontFamily: { sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'], }, boxShadow: { 'sm': '0 1px 2px 0 rgb(0 0 0 / 0.05)', 'DEFAULT': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', 'md': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', 'lg': '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)', 'xl': '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)', '2xl': '0 25px 50px -12px rgb(0 0 0 / 0.25)', }, animation: { 'fade-in': 'fadeIn 0.5s ease-in-out', 'slide-up': 'slideUp 0.3s ease-out', 'pulse': 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite', }, keyframes: { fadeIn: { '0%': { opacity: '0' }, '100%': { opacity: '1' }, }, slideUp: { '0%': { transform: 'translateY(10px)', opacity: '0' }, '100%': { transform: 'translateY(0)', opacity: '1' }, } } }, }, plugins: [ require('@tailwindcss/forms'), require('@tailwindcss/typography'), ], }; ================================================== ARCHIVO: .\frontend-client\vite.config.js ================================================== 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': { target: 'http://servicemanager-backend:8000', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } }, preview: { port: 3000, host: '0.0.0.0' }, build: { target: 'esnext' } }); ================================================== ARCHIVO: .\frontend-client\.svelte-kit\ambient.d.ts ================================================== // this file is generated — do not edit it /// /** * Environment variables [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env`. Like [`$env/dynamic/private`](https://kit.svelte.dev/docs/modules#$env-dynamic-private), this module cannot be imported into client-side code. This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://kit.svelte.dev/docs/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://kit.svelte.dev/docs/configuration#env) (if configured). * * _Unlike_ [`$env/dynamic/private`](https://kit.svelte.dev/docs/modules#$env-dynamic-private), the values exported from this module are statically injected into your bundle at build time, enabling optimisations like dead code elimination. * * ```ts * import { API_KEY } from '$env/static/private'; * ``` * * Note that all environment variables referenced in your code should be declared (for example in an `.env` file), even if they don't have a value until the app is deployed: * * ``` * MY_FEATURE_FLAG="" * ``` * * You can override `.env` values from the command line like so: * * ```bash * MY_FEATURE_FLAG="enabled" npm run dev * ``` */ declare module '$env/static/private' { export const npm_config_user_agent: string; export const NODE_VERSION: string; export const HOSTNAME: string; export const YARN_VERSION: string; export const npm_node_execpath: string; export const SHLVL: string; export const npm_config_noproxy: string; export const HOME: string; export const npm_package_json: string; export const npm_config_userconfig: string; export const npm_config_local_prefix: string; export const COLOR: string; export const npm_config_prefix: string; export const npm_config_npm_version: string; export const npm_config_cache: string; export const npm_config_node_gyp: string; export const PATH: string; export const NODE: string; export const npm_package_name: string; export const npm_lifecycle_script: string; export const npm_package_version: string; export const npm_lifecycle_event: string; export const npm_config_globalconfig: string; export const npm_config_init_module: string; export const PWD: string; export const npm_execpath: string; export const npm_config_global_prefix: string; export const npm_command: string; export const NODE_ENV: string; export const INIT_CWD: string; export const EDITOR: string; } /** * Similar to [`$env/static/private`](https://kit.svelte.dev/docs/modules#$env-static-private), except that it only includes environment variables that begin with [`config.kit.env.publicPrefix`](https://kit.svelte.dev/docs/configuration#env) (which defaults to `PUBLIC_`), and can therefore safely be exposed to client-side code. * * Values are replaced statically at build time. * * ```ts * import { PUBLIC_BASE_URL } from '$env/static/public'; * ``` */ declare module '$env/static/public' { export const PUBLIC_APP_NAME: string; export const PUBLIC_API_URL: string; } /** * This module provides access to runtime environment variables, as defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/master/packages/adapter-node) (or running [`vite preview`](https://kit.svelte.dev/docs/cli)), this is equivalent to `process.env`. This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://kit.svelte.dev/docs/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://kit.svelte.dev/docs/configuration#env) (if configured). * * This module cannot be imported into client-side code. * * ```ts * import { env } from '$env/dynamic/private'; * console.log(env.DEPLOYMENT_SPECIFIC_VARIABLE); * ``` * * > In `dev`, `$env/dynamic` always includes environment variables from `.env`. In `prod`, this behavior will depend on your adapter. */ declare module '$env/dynamic/private' { export const env: { npm_config_user_agent: string; NODE_VERSION: string; HOSTNAME: string; YARN_VERSION: string; npm_node_execpath: string; SHLVL: string; npm_config_noproxy: string; HOME: string; npm_package_json: string; npm_config_userconfig: string; npm_config_local_prefix: string; COLOR: string; npm_config_prefix: string; npm_config_npm_version: string; npm_config_cache: string; npm_config_node_gyp: string; PATH: string; NODE: string; npm_package_name: string; npm_lifecycle_script: string; npm_package_version: string; npm_lifecycle_event: string; npm_config_globalconfig: string; npm_config_init_module: string; PWD: string; npm_execpath: string; npm_config_global_prefix: string; npm_command: string; NODE_ENV: string; INIT_CWD: string; EDITOR: string; [key: `PUBLIC_${string}`]: undefined; [key: `${string}`]: string | undefined; } } /** * Similar to [`$env/dynamic/private`](https://kit.svelte.dev/docs/modules#$env-dynamic-private), but only includes variables that begin with [`config.kit.env.publicPrefix`](https://kit.svelte.dev/docs/configuration#env) (which defaults to `PUBLIC_`), and can therefore safely be exposed to client-side code. * * Note that public dynamic environment variables must all be sent from the server to the client, causing larger network requests — when possible, use `$env/static/public` instead. * * ```ts * import { env } from '$env/dynamic/public'; * console.log(env.PUBLIC_DEPLOYMENT_SPECIFIC_VARIABLE); * ``` */ declare module '$env/dynamic/public' { export const env: { PUBLIC_APP_NAME: string; PUBLIC_API_URL: string; [key: `PUBLIC_${string}`]: string | undefined; } } ================================================== ARCHIVO: .\frontend-client\.svelte-kit\tsconfig.json ================================================== { "compilerOptions": { "paths": { "$components": [ "../src/lib/components" ], "$components/*": [ "../src/lib/components/*" ], "$stores": [ "../src/lib/stores" ], "$stores/*": [ "../src/lib/stores/*" ], "$utils": [ "../src/lib/utils" ], "$utils/*": [ "../src/lib/utils/*" ], "$types": [ "../src/lib/types" ], "$types/*": [ "../src/lib/types/*" ], "$lib": [ "../src/lib" ], "$lib/*": [ "../src/lib/*" ] }, "rootDirs": [ "..", "./types" ], "importsNotUsedAsValues": "error", "isolatedModules": true, "preserveValueImports": true, "lib": [ "esnext", "DOM", "DOM.Iterable" ], "moduleResolution": "node", "module": "esnext", "noEmit": true, "target": "esnext", "ignoreDeprecations": "5.0" }, "include": [ "ambient.d.ts", "./types/**/$types.d.ts", "../vite.config.js", "../vite.config.ts", "../src/**/*.js", "../src/**/*.ts", "../src/**/*.svelte", "../tests/**/*.js", "../tests/**/*.ts", "../tests/**/*.svelte" ], "exclude": [ "../node_modules/**", "./[!ambient.d.ts]**", "../src/service-worker.js", "../src/service-worker.ts", "../src/service-worker.d.ts" ] } ================================================== ARCHIVO: .\frontend-client\.svelte-kit\generated\root.svelte ================================================== {#if constructors[1]} {:else} {/if} {#if mounted}
{#if navigated} {title} {/if}
{/if} ================================================== ARCHIVO: .\frontend-client\.svelte-kit\generated\client\app.js ================================================== export { matchers } from './matchers.js'; export const nodes = [ () => import('./nodes/0'), () => import('./nodes/1'), () => import('./nodes/2'), () => import('./nodes/3'), () => import('./nodes/4'), () => import('./nodes/5'), () => import('./nodes/6'), () => import('./nodes/7') ]; export const server_loads = []; export const dictionary = { "/": [2], "/login": [3], "/profile": [4], "/tickets": [5], "/tickets/new": [7], "/tickets/[id]": [6] }; export const hooks = { handleError: (({ error }) => { console.error(error) }), }; export { default as root } from '../root.svelte'; ================================================== ARCHIVO: .\frontend-client\.svelte-kit\generated\client\matchers.js ================================================== export const matchers = {}; ================================================== ARCHIVO: .\frontend-client\.svelte-kit\generated\client\nodes\0.js ================================================== export { default as component } from "../../../../src/routes/+layout.svelte"; ================================================== ARCHIVO: .\frontend-client\.svelte-kit\generated\client\nodes\1.js ================================================== export { default as component } from "../../../../node_modules/@sveltejs/kit/src/runtime/components/error.svelte"; ================================================== ARCHIVO: .\frontend-client\.svelte-kit\generated\client\nodes\2.js ================================================== export { default as component } from "../../../../src/routes/+page.svelte"; ================================================== ARCHIVO: .\frontend-client\.svelte-kit\generated\client\nodes\3.js ================================================== export { default as component } from "../../../../src/routes/login/+page.svelte"; ================================================== ARCHIVO: .\frontend-client\.svelte-kit\generated\client\nodes\4.js ================================================== export { default as component } from "../../../../src/routes/profile/+page.svelte"; ================================================== ARCHIVO: .\frontend-client\.svelte-kit\generated\client\nodes\5.js ================================================== export { default as component } from "../../../../src/routes/tickets/+page.svelte"; ================================================== ARCHIVO: .\frontend-client\.svelte-kit\generated\client\nodes\6.js ================================================== export { default as component } from "../../../../src/routes/tickets/[id]/+page.svelte"; ================================================== ARCHIVO: .\frontend-client\.svelte-kit\generated\client\nodes\7.js ================================================== export { default as component } from "../../../../src/routes/tickets/new/+page.svelte"; ================================================== ARCHIVO: .\frontend-client\.svelte-kit\generated\server\internal.js ================================================== import root from '../root.svelte'; import { set_building } from '__sveltekit/environment'; import { set_assets } from '__sveltekit/paths'; import { set_private_env, set_public_env } from '../../../node_modules/@sveltejs/kit/src/runtime/shared-server.js'; export const options = { app_template_contains_nonce: false, csp: {"mode":"auto","directives":{"upgrade-insecure-requests":false,"block-all-mixed-content":false},"reportOnly":{"upgrade-insecure-requests":false,"block-all-mixed-content":false}}, csrf_check_origin: true, track_server_fetches: false, embedded: false, env_public_prefix: 'PUBLIC_', env_private_prefix: '', hooks: null, // added lazily, via `get_hooks` preload_strategy: "modulepreload", root, service_worker: false, templates: { app: ({ head, body, assets, nonce, env }) => "\r\n\r\n\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t" + head + "\r\n\t\r\n\t\r\n\t\t
" + body + "
\r\n\t\r\n", error: ({ status, message }) => "\n\n\t\n\t\t\n\t\t" + message + "\n\n\t\t\n\t\n\t\n\t\t
\n\t\t\t" + status + "\n\t\t\t
\n\t\t\t\t

" + message + "

\n\t\t\t
\n\t\t
\n\t\n\n" }, version_hash: "3bl7rp" }; export function get_hooks() { return {}; } export { set_assets, set_building, set_private_env, set_public_env }; ================================================== ARCHIVO: .\frontend-client\.svelte-kit\types\route_meta_data.json ================================================== { "/": [], "/login": [], "/profile": [], "/tickets": [], "/tickets/new": [], "/tickets/[id]": [] } ================================================== ARCHIVO: .\frontend-client\.svelte-kit\types\src\routes\$types.d.ts ================================================== import type * as Kit from '@sveltejs/kit'; type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; // @ts-ignore type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; type RouteParams = { }; type RouteId = '/'; type MaybeWithVoid = {} extends T ? T | void : T; export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; type OutputDataShape = MaybeWithVoid> & Partial> & Record> type EnsureDefined = T extends null | undefined ? {} : T; type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; export type Snapshot = Kit.Snapshot; type PageParentData = EnsureDefined; type LayoutRouteId = RouteId | "/" | "/login" | "/profile" | "/tickets" | "/tickets/[id]" | "/tickets/new" | null type LayoutParams = RouteParams & { id?: string } type LayoutParentData = EnsureDefined<{}>; export type PageServerData = null; export type PageData = Expand; export type LayoutServerData = null; export type LayoutData = Expand; ================================================== ARCHIVO: .\frontend-client\.svelte-kit\types\src\routes\login\$types.d.ts ================================================== import type * as Kit from '@sveltejs/kit'; type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; // @ts-ignore type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; type RouteParams = { }; type RouteId = '/login'; type MaybeWithVoid = {} extends T ? T | void : T; export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; type OutputDataShape = MaybeWithVoid> & Partial> & Record> type EnsureDefined = T extends null | undefined ? {} : T; type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; export type Snapshot = Kit.Snapshot; type PageParentData = EnsureDefined; export type PageServerData = null; export type PageData = Expand; ================================================== ARCHIVO: .\frontend-client\.svelte-kit\types\src\routes\profile\$types.d.ts ================================================== import type * as Kit from '@sveltejs/kit'; type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; // @ts-ignore type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; type RouteParams = { }; type RouteId = '/profile'; type MaybeWithVoid = {} extends T ? T | void : T; export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; type OutputDataShape = MaybeWithVoid> & Partial> & Record> type EnsureDefined = T extends null | undefined ? {} : T; type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; export type Snapshot = Kit.Snapshot; type PageParentData = EnsureDefined; export type PageServerData = null; export type PageData = Expand; ================================================== ARCHIVO: .\frontend-client\.svelte-kit\types\src\routes\tickets\$types.d.ts ================================================== import type * as Kit from '@sveltejs/kit'; type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; // @ts-ignore type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; type RouteParams = { }; type RouteId = '/tickets'; type MaybeWithVoid = {} extends T ? T | void : T; export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; type OutputDataShape = MaybeWithVoid> & Partial> & Record> type EnsureDefined = T extends null | undefined ? {} : T; type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; export type Snapshot = Kit.Snapshot; type PageParentData = EnsureDefined; export type PageServerData = null; export type PageData = Expand; ================================================== ARCHIVO: .\frontend-client\.svelte-kit\types\src\routes\tickets\new\$types.d.ts ================================================== import type * as Kit from '@sveltejs/kit'; type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; // @ts-ignore type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; type RouteParams = { }; type RouteId = '/tickets/new'; type MaybeWithVoid = {} extends T ? T | void : T; export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; type OutputDataShape = MaybeWithVoid> & Partial> & Record> type EnsureDefined = T extends null | undefined ? {} : T; type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; export type Snapshot = Kit.Snapshot; type PageParentData = EnsureDefined; export type PageServerData = null; export type PageData = Expand; ================================================== ARCHIVO: .\frontend-client\.svelte-kit\types\src\routes\tickets\[id]\$types.d.ts ================================================== import type * as Kit from '@sveltejs/kit'; type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; // @ts-ignore type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; type RouteParams = { id: string }; type RouteId = '/tickets/[id]'; type MaybeWithVoid = {} extends T ? T | void : T; export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; type OutputDataShape = MaybeWithVoid> & Partial> & Record> type EnsureDefined = T extends null | undefined ? {} : T; type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; export type Snapshot = Kit.Snapshot; type PageParentData = EnsureDefined; export type EntryGenerator = () => Promise> | Array; export type PageServerData = null; export type PageData = Expand; ================================================== ARCHIVO: .\frontend-client\src\app.css ================================================== @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); } } ================================================== ARCHIVO: .\frontend-client\src\app.html ================================================== %sveltekit.head%
%sveltekit.body%
================================================== ARCHIVO: .\frontend-client\src\lib\components\Header.svelte ==================================================
{#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} ================================================== ARCHIVO: .\frontend-client\src\lib\components\Icon.svelte ================================================== ================================================== ARCHIVO: .\frontend-client\src\lib\components\TicketCard.svelte ==================================================

{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)}
================================================== ARCHIVO: .\frontend-client\src\lib\components\Toast.svelte ================================================== {#if visible}

{message}

{#if dismissible}
{/if}
{/if} ================================================== ARCHIVO: .\frontend-client\src\lib\stores\app.ts ================================================== 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(); ================================================== ARCHIVO: .\frontend-client\src\lib\stores\auth.ts ================================================== 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(); ================================================== ARCHIVO: .\frontend-client\src\lib\stores\clientes.ts ================================================== import { writable } from 'svelte/store'; export const clients = writable([]); export async function fetchClients() { const response = await fetch('/api/v1/clients'); const data = await response.json(); clients.set(data); } export async function createClient(client) { const response = await fetch('/api/v1/clients', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(client), }); if (response.ok) { fetchClients(); } } export async function updateClient(clientId, client) { const response = await fetch(`/api/v1/clients/${clientId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(client), }); if (response.ok) { fetchClients(); } } export async function deleteClient(clientId) { const response = await fetch(`/api/v1/clients/${clientId}`, { method: 'DELETE', }); if (response.ok) { fetchClients(); } } ================================================== ARCHIVO: .\frontend-client\src\lib\stores\tickets.ts ================================================== 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(); ================================================== ARCHIVO: .\frontend-client\src\lib\stores\toast.ts ================================================== // 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(); ================================================== ARCHIVO: .\frontend-client\src\routes\+layout.svelte ==================================================
{#if showHeader}
{/if}
{#each $toast.toasts as toastMessage (toastMessage.id)} toast.dismiss(toastMessage.id)} /> {/each}
================================================== ARCHIVO: .\frontend-client\src\routes\+page.svelte ================================================== 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}
================================================== ARCHIVO: .\frontend-client\src\routes\login\+page.svelte ==================================================

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.
================================================== ARCHIVO: .\frontend-client\src\routes\profile\+page.svelte ================================================== 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'}
================================================== ARCHIVO: .\frontend-client\src\routes\tickets\+page.svelte ================================================== 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}
================================================== ARCHIVO: .\frontend-client\src\routes\tickets\new\+page.svelte ================================================== 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
================================================== ARCHIVO: .\frontend-client\src\routes\tickets\[id]\+page.svelte ================================================== {$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} ================================================== ARCHIVO: .\frontend-internal\.eslintrc.json ================================================== { "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/**" ] } ================================================== ARCHIVO: .\frontend-internal\package.json ================================================== { "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" } } ================================================== ARCHIVO: .\frontend-internal\postcss.config.js ================================================== export default { plugins: { tailwindcss: {}, autoprefixer: {}, }, } ================================================== ARCHIVO: .\frontend-internal\svelte.config.js ================================================== 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; ================================================== ARCHIVO: .\frontend-internal\tailwind.config.js ================================================== /** @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: [], } ================================================== ARCHIVO: .\frontend-internal\vite.config.js ================================================== 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' } }); ================================================== ARCHIVO: .\frontend-internal\.svelte-kit\ambient.d.ts ================================================== // this file is generated — do not edit it /// /** * Environment variables [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env`. Like [`$env/dynamic/private`](https://kit.svelte.dev/docs/modules#$env-dynamic-private), this module cannot be imported into client-side code. This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://kit.svelte.dev/docs/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://kit.svelte.dev/docs/configuration#env) (if configured). * * _Unlike_ [`$env/dynamic/private`](https://kit.svelte.dev/docs/modules#$env-dynamic-private), the values exported from this module are statically injected into your bundle at build time, enabling optimisations like dead code elimination. * * ```ts * import { API_KEY } from '$env/static/private'; * ``` * * Note that all environment variables referenced in your code should be declared (for example in an `.env` file), even if they don't have a value until the app is deployed: * * ``` * MY_FEATURE_FLAG="" * ``` * * You can override `.env` values from the command line like so: * * ```bash * MY_FEATURE_FLAG="enabled" npm run dev * ``` */ declare module '$env/static/private' { export const npm_config_user_agent: string; export const NODE_VERSION: string; export const HOSTNAME: string; export const YARN_VERSION: string; export const npm_node_execpath: string; export const SHLVL: string; export const npm_config_noproxy: string; export const HOME: string; export const npm_package_json: string; export const npm_config_userconfig: string; export const npm_config_local_prefix: string; export const COLOR: string; export const npm_config_prefix: string; export const npm_config_npm_version: string; export const npm_config_cache: string; export const npm_config_node_gyp: string; export const PATH: string; export const NODE: string; export const npm_package_name: string; export const npm_lifecycle_script: string; export const npm_package_version: string; export const npm_lifecycle_event: string; export const npm_config_globalconfig: string; export const npm_config_init_module: string; export const PWD: string; export const npm_execpath: string; export const npm_config_global_prefix: string; export const npm_command: string; export const NODE_ENV: string; export const INIT_CWD: string; export const EDITOR: string; } /** * Similar to [`$env/static/private`](https://kit.svelte.dev/docs/modules#$env-static-private), except that it only includes environment variables that begin with [`config.kit.env.publicPrefix`](https://kit.svelte.dev/docs/configuration#env) (which defaults to `PUBLIC_`), and can therefore safely be exposed to client-side code. * * Values are replaced statically at build time. * * ```ts * import { PUBLIC_BASE_URL } from '$env/static/public'; * ``` */ declare module '$env/static/public' { export const PUBLIC_APP_NAME: string; export const PUBLIC_API_URL: string; } /** * This module provides access to runtime environment variables, as defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/master/packages/adapter-node) (or running [`vite preview`](https://kit.svelte.dev/docs/cli)), this is equivalent to `process.env`. This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://kit.svelte.dev/docs/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://kit.svelte.dev/docs/configuration#env) (if configured). * * This module cannot be imported into client-side code. * * ```ts * import { env } from '$env/dynamic/private'; * console.log(env.DEPLOYMENT_SPECIFIC_VARIABLE); * ``` * * > In `dev`, `$env/dynamic` always includes environment variables from `.env`. In `prod`, this behavior will depend on your adapter. */ declare module '$env/dynamic/private' { export const env: { npm_config_user_agent: string; NODE_VERSION: string; HOSTNAME: string; YARN_VERSION: string; npm_node_execpath: string; SHLVL: string; npm_config_noproxy: string; HOME: string; npm_package_json: string; npm_config_userconfig: string; npm_config_local_prefix: string; COLOR: string; npm_config_prefix: string; npm_config_npm_version: string; npm_config_cache: string; npm_config_node_gyp: string; PATH: string; NODE: string; npm_package_name: string; npm_lifecycle_script: string; npm_package_version: string; npm_lifecycle_event: string; npm_config_globalconfig: string; npm_config_init_module: string; PWD: string; npm_execpath: string; npm_config_global_prefix: string; npm_command: string; NODE_ENV: string; INIT_CWD: string; EDITOR: string; [key: `PUBLIC_${string}`]: undefined; [key: `${string}`]: string | undefined; } } /** * Similar to [`$env/dynamic/private`](https://kit.svelte.dev/docs/modules#$env-dynamic-private), but only includes variables that begin with [`config.kit.env.publicPrefix`](https://kit.svelte.dev/docs/configuration#env) (which defaults to `PUBLIC_`), and can therefore safely be exposed to client-side code. * * Note that public dynamic environment variables must all be sent from the server to the client, causing larger network requests — when possible, use `$env/static/public` instead. * * ```ts * import { env } from '$env/dynamic/public'; * console.log(env.PUBLIC_DEPLOYMENT_SPECIFIC_VARIABLE); * ``` */ declare module '$env/dynamic/public' { export const env: { PUBLIC_APP_NAME: string; PUBLIC_API_URL: string; [key: `PUBLIC_${string}`]: string | undefined; } } ================================================== ARCHIVO: .\frontend-internal\.svelte-kit\tsconfig.json ================================================== { "compilerOptions": { "paths": { "$components": [ "../src/lib/components" ], "$components/*": [ "../src/lib/components/*" ], "$stores": [ "../src/lib/stores" ], "$stores/*": [ "../src/lib/stores/*" ], "$utils": [ "../src/lib/utils" ], "$utils/*": [ "../src/lib/utils/*" ], "$types": [ "../src/lib/types" ], "$types/*": [ "../src/lib/types/*" ], "$lib": [ "../src/lib" ], "$lib/*": [ "../src/lib/*" ] }, "rootDirs": [ "..", "./types" ], "importsNotUsedAsValues": "error", "isolatedModules": true, "preserveValueImports": true, "lib": [ "esnext", "DOM", "DOM.Iterable" ], "moduleResolution": "node", "module": "esnext", "noEmit": true, "target": "esnext", "ignoreDeprecations": "5.0" }, "include": [ "ambient.d.ts", "./types/**/$types.d.ts", "../vite.config.js", "../vite.config.ts", "../src/**/*.js", "../src/**/*.ts", "../src/**/*.svelte", "../tests/**/*.js", "../tests/**/*.ts", "../tests/**/*.svelte" ], "exclude": [ "../node_modules/**", "./[!ambient.d.ts]**", "../src/service-worker.js", "../src/service-worker.ts", "../src/service-worker.d.ts" ] } ================================================== ARCHIVO: .\frontend-internal\.svelte-kit\generated\root.svelte ================================================== {#if constructors[1]} {:else} {/if} {#if mounted}
{#if navigated} {title} {/if}
{/if} ================================================== ARCHIVO: .\frontend-internal\.svelte-kit\generated\client\app.js ================================================== export { matchers } from './matchers.js'; export const nodes = [ () => import('./nodes/0'), () => import('./nodes/1'), () => import('./nodes/2'), () => import('./nodes/3'), () => import('./nodes/4'), () => import('./nodes/5'), () => import('./nodes/6'), () => import('./nodes/7') ]; export const server_loads = []; export const dictionary = { "/": [2], "/categories": [3], "/login": [4], "/systems": [5], "/tenants": [6], "/users": [7] }; export const hooks = { handleError: (({ error }) => { console.error(error) }), }; export { default as root } from '../root.svelte'; ================================================== ARCHIVO: .\frontend-internal\.svelte-kit\generated\client\matchers.js ================================================== export const matchers = {}; ================================================== ARCHIVO: .\frontend-internal\.svelte-kit\generated\client\nodes\0.js ================================================== export { default as component } from "../../../../src/routes/+layout.svelte"; ================================================== ARCHIVO: .\frontend-internal\.svelte-kit\generated\client\nodes\1.js ================================================== export { default as component } from "../../../../node_modules/@sveltejs/kit/src/runtime/components/error.svelte"; ================================================== ARCHIVO: .\frontend-internal\.svelte-kit\generated\client\nodes\2.js ================================================== export { default as component } from "../../../../src/routes/+page.svelte"; ================================================== ARCHIVO: .\frontend-internal\.svelte-kit\generated\client\nodes\3.js ================================================== export { default as component } from "../../../../src/routes/categories/+page.svelte"; ================================================== ARCHIVO: .\frontend-internal\.svelte-kit\generated\client\nodes\4.js ================================================== export { default as component } from "../../../../src/routes/login/+page.svelte"; ================================================== ARCHIVO: .\frontend-internal\.svelte-kit\generated\client\nodes\5.js ================================================== export { default as component } from "../../../../src/routes/systems/+page.svelte"; ================================================== ARCHIVO: .\frontend-internal\.svelte-kit\generated\client\nodes\6.js ================================================== export { default as component } from "../../../../src/routes/tenants/+page.svelte"; ================================================== ARCHIVO: .\frontend-internal\.svelte-kit\generated\client\nodes\7.js ================================================== export { default as component } from "../../../../src/routes/users/+page.svelte"; ================================================== ARCHIVO: .\frontend-internal\.svelte-kit\generated\server\internal.js ================================================== import root from '../root.svelte'; import { set_building } from '__sveltekit/environment'; import { set_assets } from '__sveltekit/paths'; import { set_private_env, set_public_env } from '../../../node_modules/@sveltejs/kit/src/runtime/shared-server.js'; export const options = { app_template_contains_nonce: false, csp: {"mode":"auto","directives":{"upgrade-insecure-requests":false,"block-all-mixed-content":false},"reportOnly":{"upgrade-insecure-requests":false,"block-all-mixed-content":false}}, csrf_check_origin: true, track_server_fetches: false, embedded: false, env_public_prefix: 'PUBLIC_', env_private_prefix: '', hooks: null, // added lazily, via `get_hooks` preload_strategy: "modulepreload", root, service_worker: false, templates: { app: ({ head, body, assets, nonce, env }) => "\r\n\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n " + head + "\r\n \r\n \r\n
" + body + "
\r\n \r\n", error: ({ status, message }) => "\n\n\t\n\t\t\n\t\t" + message + "\n\n\t\t\n\t\n\t\n\t\t
\n\t\t\t" + status + "\n\t\t\t
\n\t\t\t\t

" + message + "

\n\t\t\t
\n\t\t
\n\t\n\n" }, version_hash: "eelz0a" }; export function get_hooks() { return {}; } export { set_assets, set_building, set_private_env, set_public_env }; ================================================== ARCHIVO: .\frontend-internal\.svelte-kit\types\route_meta_data.json ================================================== { "/": [], "/categories": [], "/login": [], "/systems": [], "/tenants": [], "/users": [] } ================================================== ARCHIVO: .\frontend-internal\.svelte-kit\types\src\routes\$types.d.ts ================================================== import type * as Kit from '@sveltejs/kit'; type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; // @ts-ignore type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; type RouteParams = { }; type RouteId = '/'; type MaybeWithVoid = {} extends T ? T | void : T; export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; type OutputDataShape = MaybeWithVoid> & Partial> & Record> type EnsureDefined = T extends null | undefined ? {} : T; type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; export type Snapshot = Kit.Snapshot; type PageParentData = EnsureDefined; type LayoutRouteId = RouteId | "/" | "/categories" | "/login" | "/systems" | "/tenants" | "/users" | null type LayoutParams = RouteParams & { } type LayoutParentData = EnsureDefined<{}>; export type PageServerData = null; export type PageData = Expand; export type LayoutServerData = null; export type LayoutData = Expand; ================================================== ARCHIVO: .\frontend-internal\.svelte-kit\types\src\routes\categories\$types.d.ts ================================================== import type * as Kit from '@sveltejs/kit'; type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; // @ts-ignore type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; type RouteParams = { }; type RouteId = '/categories'; type MaybeWithVoid = {} extends T ? T | void : T; export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; type OutputDataShape = MaybeWithVoid> & Partial> & Record> type EnsureDefined = T extends null | undefined ? {} : T; type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; export type Snapshot = Kit.Snapshot; type PageParentData = EnsureDefined; export type PageServerData = null; export type PageData = Expand; ================================================== ARCHIVO: .\frontend-internal\.svelte-kit\types\src\routes\login\$types.d.ts ================================================== import type * as Kit from '@sveltejs/kit'; type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; // @ts-ignore type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; type RouteParams = { }; type RouteId = '/login'; type MaybeWithVoid = {} extends T ? T | void : T; export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; type OutputDataShape = MaybeWithVoid> & Partial> & Record> type EnsureDefined = T extends null | undefined ? {} : T; type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; export type Snapshot = Kit.Snapshot; type PageParentData = EnsureDefined; export type PageServerData = null; export type PageData = Expand; ================================================== ARCHIVO: .\frontend-internal\.svelte-kit\types\src\routes\systems\$types.d.ts ================================================== import type * as Kit from '@sveltejs/kit'; type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; // @ts-ignore type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; type RouteParams = { }; type RouteId = '/systems'; type MaybeWithVoid = {} extends T ? T | void : T; export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; type OutputDataShape = MaybeWithVoid> & Partial> & Record> type EnsureDefined = T extends null | undefined ? {} : T; type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; export type Snapshot = Kit.Snapshot; type PageParentData = EnsureDefined; export type PageServerData = null; export type PageData = Expand; ================================================== ARCHIVO: .\frontend-internal\.svelte-kit\types\src\routes\tenants\$types.d.ts ================================================== import type * as Kit from '@sveltejs/kit'; type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; // @ts-ignore type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; type RouteParams = { }; type RouteId = '/tenants'; type MaybeWithVoid = {} extends T ? T | void : T; export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; type OutputDataShape = MaybeWithVoid> & Partial> & Record> type EnsureDefined = T extends null | undefined ? {} : T; type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; export type Snapshot = Kit.Snapshot; type PageParentData = EnsureDefined; export type PageServerData = null; export type PageData = Expand; ================================================== ARCHIVO: .\frontend-internal\.svelte-kit\types\src\routes\users\$types.d.ts ================================================== import type * as Kit from '@sveltejs/kit'; type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; // @ts-ignore type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; type RouteParams = { }; type RouteId = '/users'; type MaybeWithVoid = {} extends T ? T | void : T; export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; type OutputDataShape = MaybeWithVoid> & Partial> & Record> type EnsureDefined = T extends null | undefined ? {} : T; type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; export type Snapshot = Kit.Snapshot; type PageParentData = EnsureDefined; export type PageServerData = null; export type PageData = Expand; ================================================== ARCHIVO: .\frontend-internal\src\app.css ================================================== @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); } } ================================================== ARCHIVO: .\frontend-internal\src\app.html ================================================== %sveltekit.head%
%sveltekit.body%
================================================== ARCHIVO: .\frontend-internal\src\lib\components\Header.svelte ==================================================
{#if isMenuOpen}
{$auth.user?.email}
isMenuOpen = false} > Mi Perfil
{/if}
{#if isMenuOpen}
isMenuOpen = false} >
{/if} ================================================== ARCHIVO: .\frontend-internal\src\lib\components\Icon.svelte ================================================== ================================================== ARCHIVO: .\frontend-internal\src\lib\components\Modal.svelte ================================================== {#if open} {/if} ================================================== ARCHIVO: .\frontend-internal\src\lib\components\Sidebar.svelte ================================================== {#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'}

================================================== ARCHIVO: .\frontend-internal\src\lib\components\Toast.svelte ================================================== {#if visible}

{message}

{#if dismissible}
{/if}
{/if} ================================================== ARCHIVO: .\frontend-internal\src\lib\stores\api.ts ================================================== import { writable } from 'svelte/store'; export const api = { async getClients() { const response = await fetch('/api/v1/clients'); if (!response.ok) { throw new Error('Error fetching clients'); } return await response.json(); }, async createClient(client) { const response = await fetch('/api/v1/clients', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(client), }); if (!response.ok) { throw new Error('Error creating client'); } return await response.json(); }, async updateClient(clientId, client) { const response = await fetch(`/api/v1/clients/${clientId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(client), }); if (!response.ok) { throw new Error('Error updating client'); } return await response.json(); }, async deleteClient(clientId) { const response = await fetch(`/api/v1/clients/${clientId}`, { method: 'DELETE', }); if (!response.ok) { throw new Error('Error deleting client'); } return await response.json(); }, }; ================================================== ARCHIVO: .\frontend-internal\src\lib\stores\auth.ts ================================================== 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(); ================================================== ARCHIVO: .\frontend-internal\src\lib\stores\toast.ts ================================================== // 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(); ================================================== ARCHIVO: .\frontend-internal\src\lib\utils\api.ts ================================================== 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' }) }; ================================================== ARCHIVO: .\frontend-internal\src\routes\+layout.svelte ==================================================
{#if $auth.isAuthenticated}
{:else}
{/if} {#each $toast.toasts as toastMessage (toastMessage.id)} toast.dismiss(toastMessage.id)} /> {/each}
================================================== ARCHIVO: .\frontend-internal\src\routes\+page.svelte ================================================== Dashboard Admin - ServiceManager

Panel de Administración

Bienvenido al sistema de gestión interna.

================================================== ARCHIVO: .\frontend-internal\src\routes\categories\+page.svelte ==================================================

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}
Nombre Descripción Tipo (Cliente) Estado Acciones
Cargando...
No hay categorías registradas
{category.name} {category.description || '-'} {getTenantName(category.tenant_id)} {category.is_active ? 'Activo' : 'Inactivo'}
showModal = false}>
================================================== ARCHIVO: .\frontend-internal\src\routes\login\+page.svelte ================================================== 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

================================================== ARCHIVO: .\frontend-internal\src\routes\systems\+page.svelte ==================================================

Sistemas

Catálogo de sistemas informáticos gestionados.

{#if isLoading} {:else if systems.length === 0} {:else} {#each systems as system} {/each} {/if}
Nombre Descripción Estado Acciones
Cargando...
No hay sistemas registrados
{system.name} {system.description || '-'} {system.is_active ? 'Activo' : 'Inactivo'}
showModal = false}>
================================================== ARCHIVO: .\frontend-internal\src\routes\tenants\+page.svelte ==================================================

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}
Nombre Slug Dominio Estado Acciones
Cargando...
No hay clientes registrados
{tenant.name} {tenant.slug} {tenant.domain || '-'} {tenant.is_active ? 'Activo' : 'Inactivo'}
showModal = false}>

Usado en URLs y subdominios.

================================================== ARCHIVO: .\frontend-internal\src\routes\users\+page.svelte ==================================================

Usuarios

Gestión de usuarios internos y de clientes.

{#if isLoading} {:else if users.length === 0} {:else} {#each users as user} {/each} {/if}
Usuario Rol Cliente (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}>
================================================== ARCHIVO: .\scripts\README.md ================================================== # 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). ================================================== ARCHIVO: .\workers\README.md ================================================== # 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 ``` ================================================== ARCHIVO: .\workers\app\celery.py ================================================== """ 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() ================================================== ARCHIVO: .\workers\app\core\config.py ================================================== """ 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() ================================================== ARCHIVO: .\workers\app\core\logging.py ================================================== """ 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) ================================================== ARCHIVO: .\workers\app\tasks\email_tasks.py ================================================== """ 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 } ================================================== ARCHIVO: .\workers\app\tasks\maintenance_tasks.py ================================================== """ 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 ================================================== ARCHIVO: .\workers\app\tasks\notification_tasks.py ================================================== """ 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 ================================================== ARCHIVO: .\workers\app\tasks\sla_tasks.py ================================================== """ 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 ================================================== ARCHIVO: .\Zpracticante\Backups\respaldo_docker_v1.sql ================================================== pg_dump: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: FATAL: role "postgres" does not exist