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

+ + {ticket.title} + +

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

+ {ticket.description} +

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

+ {message} +

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

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

+

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

+
+
+ + + + + +
+
+

Tickets Recientes

+

Últimos tickets que has creado o actualizado

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

Cargando tickets...

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

Error al cargar los tickets

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

No tienes tickets creados

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

+ + {ticket.title} + +

+

+ {ticket.description} +

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

Bienvenido

+

Ingrese a su cuenta corporativa

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

Ingrese el código de 6 dígitos

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

Mi Perfil

+

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

+
+ +
+ +
+
+

Información Personal

+

Actualiza tu información básica

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

{profileErrors.firstName}

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

{profileErrors.lastName}

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

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

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

Seguridad de la Cuenta

+

Gestiona tu contraseña y configuración de seguridad

+
+ +
+ +
+
+

Autenticación de dos factores (2FA)

+

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

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

Cambiar Contraseña

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

{passwordErrors.currentPassword}

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

{passwordErrors.newPassword}

+ {/if} +

+ Mínimo 8 caracteres +

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

{passwordErrors.confirmPassword}

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

Información de la Cuenta

+

Detalles sobre tu cuenta y organización

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

Mis Tickets

+

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

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

Total

+

{$tickets.tickets.length}

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

En Progreso

+

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

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

Esperando

+

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

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

Resueltos

+

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

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

Cargando tickets...

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

Error al cargar tickets

+

{$tickets.error}

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

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

+

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

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

Cargando ticket...

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

Error al cargar ticket

+

{$tickets.error}

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

+ {$tickets.currentTicket.title} +

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

+ {$tickets.currentTicket.description} +

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

Resolución:

+

+ {$tickets.currentTicket.resolution} +

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

Archivos Adjuntos

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

+ {attachment.original_filename} +

+

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

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

Conversación

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

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

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

+ {comment.content} +

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

Información

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

+ Cerrar Ticket +

+
+

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

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

Crear Nuevo Ticket

+

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

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

{errors.title}

+ {/if} +

+ {title.length}/200 caracteres +

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

{errors.categoryId}

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

{errors.description}

+ {/if} +

+ {description.length}/2000 caracteres +

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

+ Tips para un mejor soporte +

+
+
    +
  • Sé específico y detallado en tu descripción
  • +
  • Incluye capturas de pantalla si es posible (puedes adjuntarlas después)
  • +
  • Menciona qué navegador/sistema operativo estás usando
  • +
  • Indica si el problema es recurrente o fue la primera vez
  • +
  • Si hay mensajes de error, cópialos exactamente
  • +
+
+
+
+
+
+ + +
+ + Cancelar + + + +
+
+
\ No newline at end of file diff --git a/frontend-client/static/images/Icono AS(1).png b/frontend-client/static/images/Icono AS(1).png new file mode 100644 index 0000000..7b4a555 Binary files /dev/null and b/frontend-client/static/images/Icono AS(1).png differ diff --git a/frontend-client/static/images/Logo AS 192px -192px(1).png b/frontend-client/static/images/Logo AS 192px -192px(1).png new file mode 100644 index 0000000..853d117 Binary files /dev/null and b/frontend-client/static/images/Logo AS 192px -192px(1).png differ diff --git a/frontend-client/static/images/Logo AS 512px - 512px(1).png b/frontend-client/static/images/Logo AS 512px - 512px(1).png new file mode 100644 index 0000000..cc4b8f3 Binary files /dev/null and b/frontend-client/static/images/Logo AS 512px - 512px(1).png differ diff --git a/frontend-client/static/images/Logo AS blanco(1).png b/frontend-client/static/images/Logo AS blanco(1).png new file mode 100644 index 0000000..65cfbe1 Binary files /dev/null and b/frontend-client/static/images/Logo AS blanco(1).png differ diff --git a/frontend-client/static/images/SOPORTE.webp b/frontend-client/static/images/SOPORTE.webp new file mode 100644 index 0000000..5d77111 Binary files /dev/null and b/frontend-client/static/images/SOPORTE.webp differ diff --git a/frontend-client/svelte.config.js b/frontend-client/svelte.config.js new file mode 100644 index 0000000..a26f722 --- /dev/null +++ b/frontend-client/svelte.config.js @@ -0,0 +1,25 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/kit/vite'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + // Consult https://kit.svelte.dev/docs/integrations#preprocessors + // for more information about preprocessors + preprocess: vitePreprocess(), + + kit: { + // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. + // If your environment is not supported or you settled on a specific environment, switch out the adapter. + // See https://kit.svelte.dev/docs/adapters for more information about adapters. + adapter: adapter(), + + alias: { + '$components': 'src/lib/components', + '$stores': 'src/lib/stores', + '$utils': 'src/lib/utils', + '$types': 'src/lib/types' + } + } +}; + +export default config; \ No newline at end of file diff --git a/frontend-client/tailwind.config.js b/frontend-client/tailwind.config.js new file mode 100644 index 0000000..34e18f3 --- /dev/null +++ b/frontend-client/tailwind.config.js @@ -0,0 +1,101 @@ +/** @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'), + ], +}; \ No newline at end of file diff --git a/frontend-client/vite.config.js b/frontend-client/vite.config.js new file mode 100644 index 0000000..152d435 --- /dev/null +++ b/frontend-client/vite.config.js @@ -0,0 +1,24 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], + server: { + port: 3000, + host: '0.0.0.0', + proxy: { + '/api': { + target: 'http://servicemanager-backend:8000', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, '') + } + } + }, + preview: { + port: 3000, + host: '0.0.0.0' + }, + build: { + target: 'esnext' + } +}); \ No newline at end of file diff --git a/frontend-internal/.eslintrc.json b/frontend-internal/.eslintrc.json new file mode 100644 index 0000000..9062750 --- /dev/null +++ b/frontend-internal/.eslintrc.json @@ -0,0 +1,42 @@ +{ + "extends": [ + "eslint:recommended", + "@typescript-eslint/recommended", + "prettier" + ], + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint", "svelte3"], + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + }, + "env": { + "browser": true, + "es2017": true, + "node": true + }, + "overrides": [ + { + "files": ["*.svelte"], + "processor": "svelte3/svelte3" + } + ], + "rules": { + "@typescript-eslint/no-unused-vars": [ + "error", + { "argsIgnorePattern": "^_" } + ], + "@typescript-eslint/no-explicit-any": "warn", + "no-console": ["warn", { "allow": ["warn", "error"] }], + "prefer-const": "error" + }, + "settings": { + "svelte3/typescript": true + }, + "ignorePatterns": [ + ".svelte-kit/**", + "build/**", + "dist/**", + "node_modules/**" + ] +} \ No newline at end of file diff --git a/frontend-internal/.prettierignore b/frontend-internal/.prettierignore new file mode 100644 index 0000000..aafd4e9 --- /dev/null +++ b/frontend-internal/.prettierignore @@ -0,0 +1,11 @@ +.svelte-kit +build +dist +.env +.env.* +!.env.example +node_modules +.DS_Store +*.log +coverage +.nyc_output \ No newline at end of file diff --git a/frontend-internal/.prettierrc b/frontend-internal/.prettierrc new file mode 100644 index 0000000..986d497 --- /dev/null +++ b/frontend-internal/.prettierrc @@ -0,0 +1,22 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "none", + "printWidth": 100, + "useTabs": false, + "quoteProps": "as-needed", + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "avoid", + "endOfLine": "lf", + "plugins": ["prettier-plugin-svelte"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ] +} \ No newline at end of file diff --git a/frontend-internal/package.json b/frontend-internal/package.json new file mode 100644 index 0000000..d5fbd92 --- /dev/null +++ b/frontend-internal/package.json @@ -0,0 +1,49 @@ +{ + "name": "@servicemanager/internal-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000 --host 0.0.0.0", + "build": "vite build", + "preview": "vite preview --port 3000 --host 0.0.0.0", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --plugin-search-dir . --check . && eslint .", + "format": "prettier --plugin-search-dir . --write .", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^1.3.1", + "@sveltejs/kit": "^1.20.4", + "@types/cookie": "^0.5.1", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "autoprefixer": "^10.4.14", + "eslint": "^8.28.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-svelte": "^2.30.0", + "postcss": "^8.4.24", + "prettier": "^2.8.0", + "prettier-plugin-svelte": "^2.10.1", + "svelte": "^4.0.5", + "svelte-check": "^3.4.3", + "tailwindcss": "^3.3.0", + "tslib": "^2.4.1", + "typescript": "^5.0.0", + "vite": "^4.4.2", + "vitest": "^0.34.0" + }, + "dependencies": { + "@tailwindcss/forms": "^0.5.4", + "@tailwindcss/typography": "^0.5.9", + "@heroicons/react": "^2.0.18", + "heroicons": "^2.0.18", + "zod": "^3.22.2", + "date-fns": "^2.30.0", + "chart.js": "^4.3.0", + "chartjs-adapter-date-fns": "^3.0.0" + } +} \ No newline at end of file diff --git a/frontend-internal/postcss.config.js b/frontend-internal/postcss.config.js new file mode 100644 index 0000000..e99ebc2 --- /dev/null +++ b/frontend-internal/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/frontend-internal/src/app.css b/frontend-internal/src/app.css new file mode 100644 index 0000000..dd81361 --- /dev/null +++ b/frontend-internal/src/app.css @@ -0,0 +1,225 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Custom base styles */ +@layer base { + html { + font-family: 'Inter', system-ui, sans-serif; + } + + body { + @apply text-gray-900 bg-gray-50; + } + + /* Focus styles */ + *:focus { + @apply outline-none ring-2 ring-primary-500 ring-offset-2; + } + + /* Selection styles */ + ::selection { + @apply bg-primary-100 text-primary-900; + } +} + +/* Custom component styles */ +@layer components { + /* Card component */ + .card { + @apply bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden; + } + + .card-content { + @apply p-6; + } + + /* Button variants */ + .btn { + @apply inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none px-4 py-2; + } + + .btn-primary { + @apply bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800; + } + + .btn-secondary { + @apply bg-secondary-100 text-secondary-900 hover:bg-secondary-200 active:bg-secondary-300; + } + + .btn-success { + @apply bg-success-600 text-white hover:bg-success-700 active:bg-success-800; + } + + .btn-warning { + @apply bg-warning-600 text-white hover:bg-warning-700 active:bg-warning-800; + } + + .btn-error { + @apply bg-error-600 text-white hover:bg-error-700 active:bg-error-800; + } + + .btn-ghost { + @apply bg-transparent hover:bg-secondary-100 active:bg-secondary-200; + } + + /* Card styles */ + .card { + @apply bg-white rounded-lg shadow-sm border border-gray-200; + } + + .card-header { + @apply p-6 border-b border-gray-200; + } + + .card-content { + @apply p-6; + } + + .card-footer { + @apply p-6 border-t border-gray-200 bg-gray-50 rounded-b-lg; + } + + /* Form styles */ + .form-input { + @apply block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm; + } + + .form-label { + @apply block text-sm font-medium text-gray-700 mb-1; + } + + .form-error { + @apply text-sm text-error-600 mt-1; + } + + /* Status badges */ + .badge { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; + } + + /* Animations */ + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + + .animate-fade-in { + animation: fadeIn 0.3s ease-out forwards; + } + + @keyframes slideUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .animate-slide-up { + animation: slideUp 0.4s ease-out forwards; + } + + .badge-new { + @apply badge bg-blue-100 text-blue-800; + } + + .badge-in-progress { + @apply badge bg-yellow-100 text-yellow-800; + } + + .badge-waiting { + @apply badge bg-orange-100 text-orange-800; + } + + .badge-resolved { + @apply badge bg-green-100 text-green-800; + } + + .badge-closed { + @apply badge bg-gray-100 text-gray-800; + } + + .badge-reopened { + @apply badge bg-red-100 text-red-800; + } + + /* Priority badges */ + .badge-priority-low { + @apply badge bg-gray-100 text-gray-600; + } + + .badge-priority-medium { + @apply badge bg-blue-100 text-blue-700; + } + + .badge-priority-high { + @apply badge bg-orange-100 text-orange-700; + } + + .badge-priority-urgent { + @apply badge bg-red-100 text-red-700; + } + + /* Role badges */ + .badge-admin { + @apply badge bg-purple-100 text-purple-800; + } + + .badge-support-manager { + @apply badge bg-indigo-100 text-indigo-800; + } + + .badge-agent { + @apply badge bg-blue-100 text-blue-800; + } + + .badge-auditor { + @apply badge bg-gray-100 text-gray-800; + } +} + +/* Custom utility classes */ +@layer utilities { + .text-balance { + text-wrap: balance; + } + + /* Loading spinner */ + .spinner { + @apply animate-spin rounded-full border-2 border-gray-300 border-t-primary-600; + } + + /* Animations */ + .animate-fade-in { + animation: fadeIn 0.3s ease-in-out; + } + + .animate-slide-up { + animation: slideUp 0.3s ease-out; + } +} + +/* Custom keyframes */ +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} \ No newline at end of file diff --git a/frontend-internal/src/app.html b/frontend-internal/src/app.html new file mode 100644 index 0000000..c556244 --- /dev/null +++ b/frontend-internal/src/app.html @@ -0,0 +1,16 @@ + + + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + \ No newline at end of file diff --git a/frontend-internal/src/lib/components/Header.svelte b/frontend-internal/src/lib/components/Header.svelte new file mode 100644 index 0000000..de687f3 --- /dev/null +++ b/frontend-internal/src/lib/components/Header.svelte @@ -0,0 +1,89 @@ + + +
+
+ +
+ + + +
+ + +
+ +
+ + + {#if isMenuOpen} +
+
+
+ {$auth.user?.email} +
+ isMenuOpen = false} + > + Mi Perfil + + +
+
+ {/if} +
+
+
+
+ + +{#if isMenuOpen} +
isMenuOpen = false} + >
+{/if} \ No newline at end of file diff --git a/frontend-internal/src/lib/components/Icon.svelte b/frontend-internal/src/lib/components/Icon.svelte new file mode 100644 index 0000000..1d2df71 --- /dev/null +++ b/frontend-internal/src/lib/components/Icon.svelte @@ -0,0 +1,43 @@ + + + + + \ No newline at end of file diff --git a/frontend-internal/src/lib/components/Modal.svelte b/frontend-internal/src/lib/components/Modal.svelte new file mode 100644 index 0000000..9100d6a --- /dev/null +++ b/frontend-internal/src/lib/components/Modal.svelte @@ -0,0 +1,44 @@ + + + + +{#if open} + +{/if} diff --git a/frontend-internal/src/lib/components/Sidebar.svelte b/frontend-internal/src/lib/components/Sidebar.svelte new file mode 100644 index 0000000..6f33e7a --- /dev/null +++ b/frontend-internal/src/lib/components/Sidebar.svelte @@ -0,0 +1,153 @@ + + + +{#if open} +
+
open = false}>
+
+{/if} + + +
+
+ +
+
+
+ + + +
+
+

ServiceManager

+

Panel Interno

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

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

+

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

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

+ {message} +

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

+ Panel de Administración +

+

+ Bienvenido al sistema de gestión interna. +

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

Categorías de Tickets

+

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

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

Identifíquese

+

Acceso al sistema central

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

{errorMessage}

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

+ Consulte su dispositivo autenticador +

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

Aduanasoft Internal Systems © 2024

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

Sistemas

+

Catálogo de sistemas informáticos gestionados.

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

Clientes

+

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

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

Usado en URLs y subdominios.

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

Usuarios

+

Gestión de usuarios internos y de clientes.

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

Test

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

Nuevo ticket creado

+

Hola {{ user_name }},

+

Se ha creado un nuevo ticket:

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

Ver ticket

+

Saludos,
Equipo de Soporte

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

Ticket asignado

+

Hola {{ agent_name }},

+

Se te ha asignado el ticket:

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

Ver ticket

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