Initial commit

This commit is contained in:
2026-01-12 08:17:17 -07:00
commit de5b6feef4
104 changed files with 12925 additions and 0 deletions

110
.env.example Normal file
View File

@@ -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

101
.github/copilot-instructions.md vendored Normal file
View File

@@ -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.

32
.gitignore vendored Normal file
View File

@@ -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/

142
README.md Normal file
View File

@@ -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 <repo>
cd ServiceManagerWeb
cp .env.example .env
# Levantar servicios
docker-compose up -d
# Verificar estado
docker-compose ps
```
## URLs por Defecto
- Frontend Clientes: http://localhost:3000
- Frontend Interno: http://localhost:3001
- API Backend: http://localhost:8000
- API Docs: http://localhost:8000/docs
- Adminer (DB): http://localhost:8080
## Scripts de Desarrollo
```bash
# Backend
cd backend
python -m uvicorn app.main:app --reload --port 8000
# Frontend Cliente
cd frontend-client
npm run dev -- --port 3000
# Frontend Interno
cd frontend-internal
npm run dev -- --port 3001
# Workers
cd workers
celery -A app.worker worker --loglevel=info
celery -A app.worker beat --loglevel=info
```
## Testing
```bash
# Backend tests
cd backend
pytest
# Frontend tests
cd frontend-client
npm test
cd ../frontend-internal
npm test
```
## 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

171
backend/README.md Normal file
View File

@@ -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

22
backend/add_columns.py Normal file
View File

@@ -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())

57
backend/app/api/deps.py Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"]
)

142
backend/app/core/config.py Normal file
View File

@@ -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()

View File

@@ -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

145
backend/app/core/logging.py Normal file
View File

@@ -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)

View File

@@ -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()

201
backend/app/main.py Normal file
View File

@@ -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"
)

View File

@@ -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

View File

@@ -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)

View File

@@ -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"<Category(id={self.id}, name='{self.name}')>"

View File

@@ -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"<System(id={self.id}, name='{self.name}')>"

View File

@@ -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"<Tenant(id={self.id}, name='{self.name}', slug='{self.slug}')>"
@property
def is_active(self) -> bool:
"""Check if tenant is active."""
return self.status == TenantStatus.ACTIVE

View File

@@ -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"
)

135
backend/app/models/user.py Normal file
View File

@@ -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"<User(id={self.id}, email='{self.email}', role='{self.role}')>"
@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

View File

@@ -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())

181
backend/pyproject.toml Normal file
View File

@@ -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",
]

86
backend/requirements.txt Normal file
View File

@@ -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

500
db/schema.sql Normal file
View File

@@ -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';

294
docker-compose.yml Normal file
View File

@@ -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

43
docker/Dockerfile.backend Normal file
View File

@@ -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"]

View File

@@ -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"]

35
docker/Dockerfile.worker Normal file
View File

@@ -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"]

144
docker/nginx/default.conf Normal file
View File

@@ -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/;
}
}

65
docker/nginx/nginx.conf Normal file
View File

@@ -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;
}

547
docs/api-contract.md Normal file
View File

@@ -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 <token>`
- Refresh token para renovación automática
## Headers Estándar
```
Authorization: Bearer <jwt_token>
Content-Type: application/json
X-Tenant-ID: <tenant_uuid> # Requerido para endpoints multi-tenant
X-Correlation-ID: <uuid> # 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

96
docs/database-schema.md Normal file
View File

@@ -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

View File

@@ -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/**"
]
}

View File

@@ -0,0 +1,11 @@
.svelte-kit
build
dist
.env
.env.*
!.env.example
node_modules
.DS_Store
*.log
coverage
.nyc_output

View File

@@ -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"
}
}
]
}

View File

@@ -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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

183
frontend-client/src/app.css Normal file
View File

@@ -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);
}
}

View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#3b82f6" />
<!-- SEO Meta Tags -->
<meta name="description" content="ServiceManager - Portal de Soporte Técnico para Clientes" />
<meta name="keywords" content="soporte técnico, mesa de ayuda, tickets, aduanasoft" />
<meta name="author" content="Aduanasoft" />
<!-- Open Graph Meta Tags -->
<meta property="og:type" content="website" />
<meta property="og:title" content="ServiceManager - Portal Cliente" />
<meta property="og:description" content="Gestiona tus tickets de soporte técnico" />
<meta property="og:site_name" content="ServiceManager" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="min-h-screen bg-gray-50 antialiased">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,130 @@
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import Icon from './Icon.svelte';
export let showLogo = true;
export let showNavigation = true;
let isMenuOpen = false;
onMount(() => {
auth.init();
});
function toggleMenu() {
isMenuOpen = !isMenuOpen;
}
function handleLogout() {
auth.logout();
isMenuOpen = false;
}
</script>
<header class="bg-white shadow-sm border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- Logo -->
{#if showLogo}
<div class="flex items-center">
<a href="/" class="flex items-center space-x-2">
<div class="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
<Icon name="ticket" size="w-5 h-5" className="text-white" />
</div>
<div class="hidden sm:block">
<h1 class="text-xl font-semibold text-gray-900">ServiceManager</h1>
<p class="text-xs text-gray-500">Mesa de Ayuda</p>
</div>
</a>
</div>
{/if}
<!-- Navigation -->
{#if showNavigation && $auth.isAuthenticated}
<nav class="hidden md:flex space-x-8">
<a href="/tickets" class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium">
Mis Tickets
</a>
<a href="/tickets/new" class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium">
Crear Ticket
</a>
</nav>
{/if}
<!-- User menu -->
<div class="flex items-center space-x-4">
{#if $auth.isAuthenticated}
<div class="relative">
<button
on:click={toggleMenu}
class="flex items-center space-x-2 text-gray-700 hover:text-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded-md p-2"
>
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
<span class="text-primary-600 text-sm font-medium">
{$auth.user?.first_name?.[0]}{$auth.user?.last_name?.[0]}
</span>
</div>
<span class="hidden sm:block text-sm">
{$auth.user?.first_name} {$auth.user?.last_name}
</span>
<Icon name="chevronDown" size="w-4 h-4" />
</button>
{#if isMenuOpen}
<div class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg border border-gray-200 z-50">
<div class="py-1">
<div class="px-4 py-2 text-xs text-gray-500 border-b border-gray-200">
{$auth.user?.email}
</div>
<a
href="/profile"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
on:click={() => isMenuOpen = false}
>
Mi Perfil
</a>
<button
on:click={handleLogout}
class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
Cerrar Sesión
</button>
</div>
</div>
{/if}
</div>
{:else}
<a
href="/login"
class="text-gray-700 hover:text-primary-600 text-sm font-medium"
>
Iniciar Sesión
</a>
{/if}
</div>
</div>
<!-- Mobile navigation -->
{#if showNavigation && $auth.isAuthenticated}
<div class="md:hidden border-t border-gray-200 py-2">
<nav class="flex space-x-4">
<a href="/tickets" class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium">
Mis Tickets
</a>
<a href="/tickets/new" class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium">
Crear Ticket
</a>
</nav>
</div>
{/if}
</div>
</header>
<!-- Backdrop for mobile menu -->
{#if isMenuOpen}
<div
class="fixed inset-0 z-40 md:hidden"
on:click={() => isMenuOpen = false}
></div>
{/if}

View File

@@ -0,0 +1,39 @@
<script lang="ts">
export let name: string;
export let size: string = 'w-5 h-5';
export let className: string = '';
const icons: Record<string, string> = {
home: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6',
ticket: 'M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z',
plus: 'M12 4v16m8-8H4',
user: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
menu: 'M4 6h16M4 12h16M4 18h16',
x: 'M6 18L18 6M6 6l12 12',
bell: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9',
search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
edit: 'M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z',
trash: 'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16',
eye: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z',
clock: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
check: 'M5 13l4 4L19 7',
chevronDown: 'M19 9l-7 7-7-7'
};
$: path = icons[name] || icons.home;
</script>
<svg
class="{size} {className}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={path}
/>
</svg>

View File

@@ -0,0 +1,124 @@
<script lang="ts">
export let ticket: import('$lib/stores/tickets').Ticket;
// Status mapping
const statusConfig = {
NEW: { label: 'Nuevo', class: 'badge-new' },
IN_PROGRESS: { label: 'En Progreso', class: 'badge-in-progress' },
WAITING_FOR_CLIENT: { label: 'Esperando Cliente', class: 'badge-waiting' },
RESOLVED: { label: 'Resuelto', class: 'badge-resolved' },
CLOSED: { label: 'Cerrado', class: 'badge-closed' },
REOPENED: { label: 'Reabierto', class: 'badge-reopened' }
};
// Priority mapping
const priorityConfig = {
LOW: { label: 'Baja', class: 'badge-priority-low' },
MEDIUM: { label: 'Media', class: 'badge-priority-medium' },
HIGH: { label: 'Alta', class: 'badge-priority-high' },
URGENT: { label: 'Urgente', class: 'badge-priority-urgent' }
};
// Format date
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('es-ES', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
// Format relative time
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / (1000 * 60));
if (diffInMinutes < 1) return 'hace un momento';
if (diffInMinutes < 60) return `hace ${diffInMinutes}m`;
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) return `hace ${diffInHours}h`;
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 7) return `hace ${diffInDays}d`;
return formatDate(dateString);
}
</script>
<div class="card hover:shadow-md transition-shadow">
<div class="card-content">
<div class="flex justify-between items-start mb-3">
<h3 class="text-lg font-medium text-gray-900 line-clamp-2">
<a href="/tickets/{ticket.id}" class="hover:text-primary-600">
{ticket.title}
</a>
</h3>
<div class="flex items-center space-x-2 ml-4">
<span class={`${statusConfig[ticket.status].class}`}>
{statusConfig[ticket.status].label}
</span>
<span class={`${priorityConfig[ticket.priority].class}`}>
{priorityConfig[ticket.priority].label}
</span>
</div>
</div>
<p class="text-gray-600 text-sm line-clamp-3 mb-4">
{ticket.description}
</p>
<div class="flex justify-between items-center text-xs text-gray-500">
<div class="flex items-center space-x-4">
<span>#{ticket.id.substring(0, 8)}</span>
{#if ticket.category_name}
<span class="bg-gray-100 text-gray-600 px-2 py-1 rounded">
{ticket.category_name}
</span>
{/if}
{#if ticket.assigned_to_name}
<span class="flex items-center space-x-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span>{ticket.assigned_to_name}</span>
</span>
{/if}
</div>
<div class="flex items-center space-x-3">
{#if ticket.due_date}
<span class="flex items-center space-x-1 {new Date(ticket.due_date) < new Date() ? 'text-red-600' : ''}">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Vence {formatRelativeTime(ticket.due_date)}</span>
</span>
{/if}
<span title={formatDate(ticket.updated_at)}>
Actualizado {formatRelativeTime(ticket.updated_at)}
</span>
</div>
</div>
</div>
</div>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,117 @@
<script lang="ts">
export let type: 'success' | 'error' | 'warning' | 'info' = 'info';
export let message: string;
export let duration: number = 5000;
export let dismissible: boolean = true;
let visible = true;
let timeoutId: NodeJS.Timeout;
// Auto-dismiss after duration
if (duration > 0) {
timeoutId = setTimeout(() => {
visible = false;
}, duration);
}
function dismiss() {
if (timeoutId) clearTimeout(timeoutId);
visible = false;
}
// Cleanup timeout on destroy
import { onDestroy } from 'svelte';
onDestroy(() => {
if (timeoutId) clearTimeout(timeoutId);
});
// Style mapping
const typeStyles = {
success: {
container: 'bg-success-50 border-success-200 text-success-800',
icon: 'text-success-400'
},
error: {
container: 'bg-error-50 border-error-200 text-error-800',
icon: 'text-error-400'
},
warning: {
container: 'bg-warning-50 border-warning-200 text-warning-800',
icon: 'text-warning-400'
},
info: {
container: 'bg-blue-50 border-blue-200 text-blue-800',
icon: 'text-blue-400'
}
};
// Icon mapping
const typeIcons = {
success: 'M5 13l4 4L19 7',
error: 'M6 18L18 6M6 6l12 12',
warning: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z',
info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
};
</script>
{#if visible}
<div class="fixed top-4 right-4 max-w-sm w-full z-50 animate-slide-up">
<div class="rounded-lg border p-4 shadow-lg {typeStyles[type].container}">
<div class="flex items-start">
<div class="flex-shrink-0">
<svg
class="h-5 w-5 {typeStyles[type].icon}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={typeIcons[type]}
/>
</svg>
</div>
<div class="ml-3 flex-1">
<p class="text-sm font-medium">
{message}
</p>
</div>
{#if dismissible}
<div class="ml-4 flex-shrink-0">
<button
type="button"
class="inline-flex rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 {typeStyles[type].icon} hover:opacity-75"
on:click={dismiss}
>
<span class="sr-only">Cerrar</span>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/if}
</div>
</div>
</div>
{/if}
<style>
@keyframes slide-up {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
</style>

View File

@@ -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<AppState> = 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();

View File

@@ -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<AuthState> = 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<void> => {
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();

View File

@@ -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<TicketsState> = 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();

View File

@@ -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<ToastState> = 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();

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import Header from '$lib/components/Header.svelte';
import { toast } from '$lib/stores/toast.js';
import Toast from '$lib/components/Toast.svelte';
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { page } from '$app/stores';
import '../app.css';
onMount(() => {
auth.init();
});
$: showHeader = !$page.url.pathname.startsWith('/login') && !$page.url.pathname.startsWith('/register');
</script>
<div class="min-h-screen bg-gray-50 font-sans">
{#if showHeader}
<Header />
{/if}
<main class="flex-1">
<slot />
</main>
<!-- Toast notifications -->
{#each $toast.toasts as toastMessage (toastMessage.id)}
<Toast
type={toastMessage.type}
message={toastMessage.message}
duration={toastMessage.duration}
on:dismiss={() => toast.dismiss(toastMessage.id)}
/>
{/each}
</div>

View File

@@ -0,0 +1,178 @@
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { tickets } from '$lib/stores/tickets.js';
import { goto } from '$app/navigation';
import Icon from '$lib/components/Icon.svelte';
onMount(() => {
// Redirect if not authenticated
if (!$auth.isAuthenticated) {
goto('/login');
return;
}
// Load user's tickets
tickets.loadTickets();
});
</script>
<svelte:head>
<title>ServiceManager - Mesa de Ayuda</title>
</svelte:head>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Welcome Section -->
<div class="bg-gradient-to-r from-primary-500 to-primary-600 rounded-lg p-8 text-white mb-8">
<div class="max-w-3xl">
<h1 class="text-3xl font-bold mb-2">
Bienvenido, {$auth.user?.first_name} {$auth.user?.last_name}
</h1>
<p class="text-primary-100 text-lg">
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.
</p>
</div>
</div>
<!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<a
href="/tickets/new"
class="card hover:shadow-lg transition-shadow group cursor-pointer"
>
<div class="card-content text-center">
<div class="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center mx-auto mb-4 group-hover:bg-primary-200 transition-colors">
<Icon name="plus" size="w-6 h-6" className="text-primary-600" />
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Crear Ticket</h3>
<p class="text-gray-600 text-sm">
Reporta un problema o solicita soporte técnico
</p>
</div>
</a>
<a
href="/tickets"
class="card hover:shadow-lg transition-shadow group cursor-pointer"
>
<div class="card-content text-center">
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mx-auto mb-4 group-hover:bg-blue-200 transition-colors">
<Icon name="ticket" size="w-6 h-6" className="text-blue-600" />
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Mis Tickets</h3>
<p class="text-gray-600 text-sm">
Consulta el estado de todos tus tickets
</p>
</div>
</a>
<a
href="/profile"
class="card hover:shadow-lg transition-shadow group cursor-pointer"
>
<div class="card-content text-center">
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mx-auto mb-4 group-hover:bg-green-200 transition-colors">
<Icon name="user" size="w-6 h-6" className="text-green-600" />
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Mi Perfil</h3>
<p class="text-gray-600 text-sm">
Actualiza tu información personal
</p>
</div>
</a>
</div>
<!-- Recent Tickets -->
<div class="card">
<div class="card-header">
<h2 class="text-xl font-semibold text-gray-900">Tickets Recientes</h2>
<p class="text-gray-600 mt-1">Últimos tickets que has creado o actualizado</p>
</div>
<div class="card-content">
{#if $tickets.isLoading}
<div class="text-center py-8">
<div class="spinner w-8 h-8 mx-auto mb-4"></div>
<p class="text-gray-600">Cargando tickets...</p>
</div>
{:else if $tickets.error}
<div class="text-center py-8">
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center mx-auto mb-4">
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p class="text-gray-600 mb-4">Error al cargar los tickets</p>
<button
on:click={() => tickets.loadTickets()}
class="btn-primary px-4 py-2"
>
Reintentar
</button>
</div>
{:else if $tickets.tickets.length === 0}
<div class="text-center py-8">
<div class="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4">
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<p class="text-gray-600 mb-4">No tienes tickets creados</p>
<a href="/tickets/new" class="btn-primary px-4 py-2">
Crear tu primer ticket
</a>
</div>
{:else}
<div class="space-y-4">
{#each $tickets.tickets.slice(0, 5) as ticket (ticket.id)}
<div class="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors">
<div class="flex justify-between items-start">
<div class="flex-1">
<h3 class="font-medium text-gray-900 mb-1">
<a href="/tickets/{ticket.id}" class="hover:text-primary-600">
{ticket.title}
</a>
</h3>
<p class="text-gray-600 text-sm line-clamp-2 mb-2">
{ticket.description}
</p>
<div class="flex items-center space-x-4 text-xs text-gray-500">
<span>#{ticket.id.substring(0, 8)}</span>
<span>{new Date(ticket.created_at).toLocaleDateString('es-ES')}</span>
</div>
</div>
<div class="ml-4">
<span class="badge-{ticket.status.toLowerCase().replace('_', '-')}">
{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'}
</span>
</div>
</div>
</div>
{/each}
{#if $tickets.tickets.length > 5}
<div class="text-center pt-4 border-t border-gray-200">
<a href="/tickets" class="btn-secondary px-4 py-2">
Ver todos los tickets ({$tickets.tickets.length})
</a>
</div>
{/if}
</div>
{/if}
</div>
</div>
</div>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,244 @@
<script lang="ts">
import { auth } from '$lib/stores/auth.js';
import { toast } from '$lib/stores/toast.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import Icon from '$lib/components/Icon.svelte';
let email = '';
let password = '';
let totpCode = '';
let isLoading = false;
let showTwoFactor = false;
let errorMessage = '';
let showPassword = false;
onMount(() => {
// Redirect if already authenticated
if ($auth.isAuthenticated) {
goto('/');
}
});
async function handleLogin() {
if (!email || !password) {
errorMessage = 'Por favor completa todos los campos';
return;
}
isLoading = true;
errorMessage = '';
try {
await auth.login({
email,
password,
tenant_slug: 'aduanasoft', // Default tenant for now
totp_code: totpCode || undefined
});
toast.success('¡Bienvenido! Has iniciado sesión correctamente');
goto('/');
} catch (error: any) {
console.error('Login error:', error);
// Check if 2FA is required
if (error.message.includes('two-factor') || error.message.includes('2FA')) {
showTwoFactor = true;
errorMessage = 'Introduce el código de tu aplicación de autenticación';
} else {
errorMessage = error.message || 'Error al iniciar sesión';
toast.error(errorMessage);
}
} finally {
isLoading = false;
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter') {
handleLogin();
}
}
</script>
<div class="min-h-screen flex font-sans bg-white overflow-hidden">
<!-- Left Side: Hero Image & Overlay (55% width) -->
<div class="hidden lg:flex w-[55%] relative bg-gray-900">
<!-- Background Image -->
<div
class="absolute inset-0 bg-cover bg-center z-0"
style="background-image: url('/images/SOPORTE.webp'); opacity: 1;"
></div>
<!-- Gradient Overlay -->
<div class="absolute inset-0 bg-gradient-to-br from-[#1e3a8a]/75 to-[#172554]/75 z-10"></div>
<div class="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent z-10"></div>
<!-- Content -->
<div class="relative z-20 w-full h-full flex flex-col justify-between p-16 text-white">
<!-- Top Logo (Left) -->
<div class="flex flex-col">
<img src="/images/Logo%20AS%20blanco(1).png" alt="AduanaSoft" class="h-32 w-auto object-contain self-start drop-shadow-lg" />
</div>
<!-- Main Hero Text -->
<div class="space-y-4 mb-12">
<h2 class="text-5xl font-extrabold tracking-tight drop-shadow-xl leading-tight">
Control Total <br/>
de Servicios de TI
</h2>
<p class="text-lg text-blue-100/90 font-light max-w-lg leading-relaxed drop-shadow-md">
Portal de atención a clientes. Genere tickets de soporte técnico para nuestros sistemas y reciba asistencia especializada para garantizar la continuidad de su operación.
</p>
</div>
<!-- Bottom Footer -->
<div class="text-xs font-bold tracking-[0.2em] text-blue-200/60 uppercase">
ServiceManager Enterprise Platform
</div>
</div>
</div>
<!-- Right Side: Login Form (45% width) -->
<div class="w-full lg:w-[45%] flex flex-col justify-center items-center p-8 lg:p-16 bg-white relative">
<div class="w-full max-w-md space-y-8">
<!-- Logo & Header -->
<div class="text-center space-y-2">
<h2 class="text-3xl font-bold text-gray-900">Bienvenido</h2>
<p class="text-gray-500 text-sm">Ingrese a su cuenta corporativa</p>
</div>
<!-- Form -->
<form on:submit|preventDefault={handleLogin} class="space-y-6 mt-8">
{#if errorMessage}
<div class="p-3 rounded-md bg-red-50 border border-red-100 flex items-center gap-3 animate-fade-in text-sm text-red-600">
<Icon name="alert-circle" class="w-4 h-4 flex-shrink-0" />
{errorMessage}
</div>
{/if}
{#if !showTwoFactor}
<div class="space-y-5">
<!-- Email Input -->
<div class="space-y-1.5">
<label for="email" class="block text-sm font-semibold text-gray-700">Correo Electrónico</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon name="mail" class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors" />
</div>
<input
id="email"
type="email"
bind:value={email}
on:keydown={handleKeyDown}
class="block w-full pl-10 pr-3 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm"
placeholder="admin@aduanasoft.com"
required
disabled={isLoading}
/>
</div>
</div>
<!-- Password Input -->
<div class="space-y-1.5">
<label for="password" class="block text-sm font-semibold text-gray-700">Contraseña</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon name="lock" class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors" />
</div>
{#if showPassword}
<input
id="password"
type="text"
bind:value={password}
on:keydown={handleKeyDown}
class="block w-full pl-10 pr-10 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm"
placeholder="••••••••"
required
disabled={isLoading}
/>
{:else}
<input
id="password"
type="password"
bind:value={password}
on:keydown={handleKeyDown}
class="block w-full pl-10 pr-10 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm"
placeholder="••••••••"
required
disabled={isLoading}
/>
{/if}
<button
type="button"
class="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer text-gray-400 hover:text-gray-600 focus:outline-none"
on:click={() => showPassword = !showPassword}
>
<Icon name={showPassword ? 'eye-off' : 'eye'} class="w-5 h-5" />
</button>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<input id="remember-me" name="remember-me" type="checkbox" class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded cursor-pointer">
<label for="remember-me" class="ml-2 block text-sm text-gray-500 cursor-pointer select-none">Recordar en este equipo</label>
</div>
<a href="/forgot-password" class="text-sm font-medium text-blue-600 hover:text-blue-500">
Olvide mi clave
</a>
</div>
</div>
{:else}
<!-- 2FA Input -->
<div class="space-y-4 animate-slide-up">
<label for="code" class="block text-sm font-medium text-gray-700 text-center">Código de Verificación (2FA)</label>
<p class="text-xs text-center text-gray-500 mb-4">Ingrese el código de 6 dígitos</p>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon name="shield-check" class="w-5 h-5 text-blue-500" />
</div>
<input
id="code"
type="text"
bind:value={totpCode}
on:keydown={handleKeyDown}
class="block w-full pl-10 py-3 text-center tracking-[0.5em] font-mono text-lg border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent"
placeholder="000000"
maxlength="6"
required
disabled={isLoading}
autofocus
/>
</div>
</div>
{/if}
<div class="pt-2">
<button
type="submit"
class="w-full flex justify-center py-3.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-bold text-white bg-blue-700 hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
disabled={isLoading}
>
{#if isLoading}
<Icon name="loader-2" class="w-5 h-5 animate-spin mr-2" />
Procesando...
{:else}
{showTwoFactor ? 'Verificar Acceso' : 'Acceder al Portal'}
{/if}
</button>
</div>
<div class="mt-8 text-center text-xs text-gray-400">
© 2026 Aduanasoft. Acceso exclusivo autorizado.
</div>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,392 @@
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { toast } from '$lib/stores/toast.js';
import { goto } from '$app/navigation';
let currentPassword = '';
let newPassword = '';
let confirmPassword = '';
let firstName = '';
let lastName = '';
let isUpdatingProfile = false;
let isChangingPassword = false;
let profileErrors: Record<string, string> = {};
let passwordErrors: Record<string, string> = {};
onMount(() => {
// Redirect if not authenticated
if (!$auth.isAuthenticated) {
goto('/login');
return;
}
// Initialize form with user data
if ($auth.user) {
firstName = $auth.user.first_name;
lastName = $auth.user.last_name;
}
});
function validateProfileForm() {
profileErrors = {};
if (!firstName.trim()) {
profileErrors.firstName = 'El nombre es requerido';
}
if (!lastName.trim()) {
profileErrors.lastName = 'El apellido es requerido';
}
return Object.keys(profileErrors).length === 0;
}
function validatePasswordForm() {
passwordErrors = {};
if (!currentPassword) {
passwordErrors.currentPassword = 'La contraseña actual es requerida';
}
if (!newPassword) {
passwordErrors.newPassword = 'La nueva contraseña es requerida';
} else if (newPassword.length < 8) {
passwordErrors.newPassword = 'La contraseña debe tener al menos 8 caracteres';
}
if (!confirmPassword) {
passwordErrors.confirmPassword = 'Confirma la nueva contraseña';
} else if (newPassword !== confirmPassword) {
passwordErrors.confirmPassword = 'Las contraseñas no coinciden';
}
return Object.keys(passwordErrors).length === 0;
}
async function handleProfileUpdate() {
if (!validateProfileForm()) return;
isUpdatingProfile = true;
try {
const response = await fetch('/api/v1/auth/profile', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${$auth.token}`
},
body: JSON.stringify({
first_name: firstName.trim(),
last_name: lastName.trim()
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Error al actualizar perfil');
}
const updatedUser = await response.json();
auth.updateUser(updatedUser);
toast.success('Perfil actualizado exitosamente');
} catch (error: any) {
toast.error(error.message || 'Error al actualizar perfil');
} finally {
isUpdatingProfile = false;
}
}
async function handlePasswordChange() {
if (!validatePasswordForm()) return;
isChangingPassword = true;
try {
const response = await fetch('/api/v1/auth/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${$auth.token}`
},
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Error al cambiar contraseña');
}
// Clear form
currentPassword = '';
newPassword = '';
confirmPassword = '';
toast.success('Contraseña cambiada exitosamente');
} catch (error: any) {
toast.error(error.message || 'Error al cambiar contraseña');
} finally {
isChangingPassword = false;
}
}
</script>
<svelte:head>
<title>Mi Perfil - ServiceManager</title>
</svelte:head>
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Mi Perfil</h1>
<p class="text-gray-600 mt-2">
Gestiona tu información personal y configuración de seguridad
</p>
</div>
<div class="space-y-8">
<!-- Profile Information -->
<div class="card">
<div class="card-header">
<h2 class="text-xl font-semibold text-gray-900">Información Personal</h2>
<p class="text-gray-600 mt-1">Actualiza tu información básica</p>
</div>
<div class="card-content">
<form on:submit|preventDefault={handleProfileUpdate} class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="first-name" class="form-label">
Nombre <span class="text-red-500">*</span>
</label>
<input
id="first-name"
type="text"
class="form-input {profileErrors.firstName ? 'border-red-300' : ''}"
bind:value={firstName}
disabled={isUpdatingProfile}
/>
{#if profileErrors.firstName}
<p class="form-error">{profileErrors.firstName}</p>
{/if}
</div>
<div>
<label for="last-name" class="form-label">
Apellido <span class="text-red-500">*</span>
</label>
<input
id="last-name"
type="text"
class="form-input {profileErrors.lastName ? 'border-red-300' : ''}"
bind:value={lastName}
disabled={isUpdatingProfile}
/>
{#if profileErrors.lastName}
<p class="form-error">{profileErrors.lastName}</p>
{/if}
</div>
</div>
<div>
<label for="email" class="form-label">Correo Electrónico</label>
<input
id="email"
type="email"
class="form-input bg-gray-50"
value={$auth.user?.email || ''}
disabled
/>
<p class="text-xs text-gray-500 mt-1">
El correo electrónico no se puede cambiar. Contacta con soporte si necesitas actualizarlo.
</p>
</div>
<div class="flex justify-end">
<button
type="submit"
class="btn-primary px-6 py-2"
disabled={isUpdatingProfile}
>
{#if isUpdatingProfile}
<div class="flex items-center space-x-2">
<div class="spinner w-4 h-4"></div>
<span>Guardando...</span>
</div>
{:else}
Guardar Cambios
{/if}
</button>
</div>
</form>
</div>
</div>
<!-- Account Security -->
<div class="card">
<div class="card-header">
<h2 class="text-xl font-semibold text-gray-900">Seguridad de la Cuenta</h2>
<p class="text-gray-600 mt-1">Gestiona tu contraseña y configuración de seguridad</p>
</div>
<div class="card-content space-y-6">
<!-- Two-Factor Authentication Status -->
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<h3 class="font-medium text-gray-900">Autenticación de dos factores (2FA)</h3>
<p class="text-sm text-gray-600">
{$auth.user?.is_two_factor_enabled
? 'La autenticación de dos factores está habilitada'
: 'Mejora la seguridad habilitando 2FA'}
</p>
</div>
<div>
{#if $auth.user?.is_two_factor_enabled}
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Habilitado
</span>
{:else}
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
Deshabilitado
</span>
{/if}
</div>
</div>
<!-- Change Password Form -->
<form on:submit|preventDefault={handlePasswordChange} class="space-y-6">
<h3 class="text-lg font-medium text-gray-900">Cambiar Contraseña</h3>
<div>
<label for="current-password" class="form-label">
Contraseña Actual <span class="text-red-500">*</span>
</label>
<input
id="current-password"
type="password"
class="form-input {passwordErrors.currentPassword ? 'border-red-300' : ''}"
bind:value={currentPassword}
disabled={isChangingPassword}
/>
{#if passwordErrors.currentPassword}
<p class="form-error">{passwordErrors.currentPassword}</p>
{/if}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="new-password" class="form-label">
Nueva Contraseña <span class="text-red-500">*</span>
</label>
<input
id="new-password"
type="password"
class="form-input {passwordErrors.newPassword ? 'border-red-300' : ''}"
bind:value={newPassword}
disabled={isChangingPassword}
/>
{#if passwordErrors.newPassword}
<p class="form-error">{passwordErrors.newPassword}</p>
{/if}
<p class="text-xs text-gray-500 mt-1">
Mínimo 8 caracteres
</p>
</div>
<div>
<label for="confirm-password" class="form-label">
Confirmar Nueva Contraseña <span class="text-red-500">*</span>
</label>
<input
id="confirm-password"
type="password"
class="form-input {passwordErrors.confirmPassword ? 'border-red-300' : ''}"
bind:value={confirmPassword}
disabled={isChangingPassword}
/>
{#if passwordErrors.confirmPassword}
<p class="form-error">{passwordErrors.confirmPassword}</p>
{/if}
</div>
</div>
<div class="flex justify-end">
<button
type="submit"
class="btn-primary px-6 py-2"
disabled={isChangingPassword}
>
{#if isChangingPassword}
<div class="flex items-center space-x-2">
<div class="spinner w-4 h-4"></div>
<span>Cambiando...</span>
</div>
{:else}
Cambiar Contraseña
{/if}
</button>
</div>
</form>
</div>
</div>
<!-- Account Information -->
<div class="card">
<div class="card-header">
<h2 class="text-xl font-semibold text-gray-900">Información de la Cuenta</h2>
<p class="text-gray-600 mt-1">Detalles sobre tu cuenta y organización</p>
</div>
<div class="card-content">
<dl class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<dt class="text-sm font-medium text-gray-500">ID de Usuario</dt>
<dd class="text-sm text-gray-900 font-mono mt-1">#{$auth.user?.id.substring(0, 8)}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Rol</dt>
<dd class="text-sm text-gray-900 mt-1">
{$auth.user?.role === 'CLIENT_ADMIN' ? 'Administrador de Cliente' : 'Usuario de Cliente'}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Estado de la Cuenta</dt>
<dd class="text-sm mt-1">
{#if $auth.user?.is_active}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Activa
</span>
{:else}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Inactiva
</span>
{/if}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Miembro desde</dt>
<dd class="text-sm text-gray-900 mt-1">
{$auth.user?.created_at ? new Date($auth.user.created_at).toLocaleDateString('es-ES', {
day: '2-digit',
month: 'long',
year: 'numeric'
}) : 'N/A'}
</dd>
</div>
</dl>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,270 @@
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { tickets } from '$lib/stores/tickets.js';
import { goto } from '$app/navigation';
import TicketCard from '$lib/components/TicketCard.svelte';
let searchQuery = '';
let statusFilter = '';
let priorityFilter = '';
let filteredTickets: any[] = [];
onMount(() => {
// Redirect if not authenticated
if (!$auth.isAuthenticated) {
goto('/login');
return;
}
// Load tickets
tickets.loadTickets();
});
// Filter tickets based on search and filters
$: {
filteredTickets = $tickets.tickets.filter(ticket => {
const matchesSearch = !searchQuery ||
ticket.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
ticket.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
ticket.id.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = !statusFilter || ticket.status === statusFilter;
const matchesPriority = !priorityFilter || ticket.priority === priorityFilter;
return matchesSearch && matchesStatus && matchesPriority;
});
}
// Get status counts
$: statusCounts = $tickets.tickets.reduce((acc, ticket) => {
acc[ticket.status] = (acc[ticket.status] || 0) + 1;
return acc;
}, {} as Record<string, number>);
function clearFilters() {
searchQuery = '';
statusFilter = '';
priorityFilter = '';
}
</script>
<svelte:head>
<title>Mis Tickets - ServiceManager</title>
</svelte:head>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="flex justify-between items-center mb-8">
<div>
<h1 class="text-3xl font-bold text-gray-900">Mis Tickets</h1>
<p class="text-gray-600 mt-2">
Gestiona y da seguimiento a todos tus tickets de soporte
</p>
</div>
<a
href="/tickets/new"
class="btn-primary px-4 py-2 inline-flex items-center space-x-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span>Crear Ticket</span>
</a>
</div>
<!-- Stats -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="card">
<div class="card-content">
<div class="flex items-center">
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-500">Total</p>
<p class="text-2xl font-semibold text-gray-900">{$tickets.tickets.length}</p>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-content">
<div class="flex items-center">
<div class="w-8 h-8 bg-yellow-100 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-500">En Progreso</p>
<p class="text-2xl font-semibold text-gray-900">
{statusCounts['IN_PROGRESS'] || 0}
</p>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-content">
<div class="flex items-center">
<div class="w-8 h-8 bg-orange-100 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-500">Esperando</p>
<p class="text-2xl font-semibold text-gray-900">
{statusCounts['WAITING_FOR_CLIENT'] || 0}
</p>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-content">
<div class="flex items-center">
<div class="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-500">Resueltos</p>
<p class="text-2xl font-semibold text-gray-900">
{(statusCounts['RESOLVED'] || 0) + (statusCounts['CLOSED'] || 0)}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="card mb-8">
<div class="card-content">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label for="search" class="form-label">Buscar</label>
<input
id="search"
type="text"
class="form-input"
placeholder="Buscar por título, descripción o ID..."
bind:value={searchQuery}
/>
</div>
<div>
<label for="status-filter" class="form-label">Estado</label>
<select
id="status-filter"
class="form-input"
bind:value={statusFilter}
>
<option value="">Todos los estados</option>
<option value="NEW">Nuevo</option>
<option value="IN_PROGRESS">En Progreso</option>
<option value="WAITING_FOR_CLIENT">Esperando Cliente</option>
<option value="RESOLVED">Resuelto</option>
<option value="CLOSED">Cerrado</option>
<option value="REOPENED">Reabierto</option>
</select>
</div>
<div>
<label for="priority-filter" class="form-label">Prioridad</label>
<select
id="priority-filter"
class="form-input"
bind:value={priorityFilter}
>
<option value="">Todas las prioridades</option>
<option value="LOW">Baja</option>
<option value="MEDIUM">Media</option>
<option value="HIGH">Alta</option>
<option value="URGENT">Urgente</option>
</select>
</div>
<div class="flex items-end">
<button
on:click={clearFilters}
class="btn-secondary px-4 py-2 w-full"
>
Limpiar Filtros
</button>
</div>
</div>
</div>
</div>
<!-- Tickets List -->
<div class="space-y-6">
{#if $tickets.isLoading}
<div class="text-center py-12">
<div class="spinner w-8 h-8 mx-auto mb-4"></div>
<p class="text-gray-600">Cargando tickets...</p>
</div>
{:else if $tickets.error}
<div class="text-center py-12">
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center mx-auto mb-4">
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Error al cargar tickets</h3>
<p class="text-gray-600 mb-4">{$tickets.error}</p>
<button
on:click={() => tickets.loadTickets()}
class="btn-primary px-4 py-2"
>
Reintentar
</button>
</div>
{:else if filteredTickets.length === 0}
<div class="text-center py-12">
<div class="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4">
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">
{$tickets.tickets.length === 0 ? 'No tienes tickets' : 'No se encontraron tickets'}
</h3>
<p class="text-gray-600 mb-4">
{$tickets.tickets.length === 0
? 'Crea tu primer ticket para comenzar'
: 'Intenta ajustar los filtros de búsqueda'}
</p>
{#if $tickets.tickets.length === 0}
<a href="/tickets/new" class="btn-primary px-4 py-2">
Crear Ticket
</a>
{:else}
<button on:click={clearFilters} class="btn-secondary px-4 py-2">
Limpiar Filtros
</button>
{/if}
</div>
{:else}
<div class="space-y-4">
{#each filteredTickets as ticket (ticket.id)}
<TicketCard {ticket} />
{/each}
</div>
{#if filteredTickets.length !== $tickets.tickets.length}
<div class="text-center py-4 text-sm text-gray-500">
Mostrando {filteredTickets.length} de {$tickets.tickets.length} tickets
</div>
{/if}
{/if}
</div>
</div>

View File

@@ -0,0 +1,501 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { auth } from '$lib/stores/auth.js';
import { tickets } from '$lib/stores/tickets.js';
import { toast } from '$lib/stores/toast.js';
import { goto } from '$app/navigation';
let ticketId: string;
let newComment = '';
let isSubmittingComment = false;
let isClosingTicket = false;
let showCloseDialog = false;
let closeResolution = '';
let fileInput: HTMLInputElement;
let isUploading = false;
onMount(() => {
// Redirect if not authenticated
if (!$auth.isAuthenticated) {
goto('/login');
return;
}
ticketId = $page.params.id;
if (ticketId) {
tickets.loadTicket(ticketId);
}
});
// Format date
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleString('es-ES', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
// Status mapping
const statusConfig = {
NEW: { label: 'Nuevo', class: 'badge-new' },
IN_PROGRESS: { label: 'En Progreso', class: 'badge-in-progress' },
WAITING_FOR_CLIENT: { label: 'Esperando Cliente', class: 'badge-waiting' },
RESOLVED: { label: 'Resuelto', class: 'badge-resolved' },
CLOSED: { label: 'Cerrado', class: 'badge-closed' },
REOPENED: { label: 'Reabierto', class: 'badge-reopened' }
};
// Priority mapping
const priorityConfig = {
LOW: { label: 'Baja', class: 'badge-priority-low' },
MEDIUM: { label: 'Media', class: 'badge-priority-medium' },
HIGH: { label: 'Alta', class: 'badge-priority-high' },
URGENT: { label: 'Urgente', class: 'badge-priority-urgent' }
};
async function handleAddComment() {
if (!newComment.trim()) return;
isSubmittingComment = true;
try {
await tickets.addComment(ticketId, newComment.trim());
newComment = '';
toast.success('Comentario agregado');
} catch (error: any) {
toast.error(error.message || 'Error al agregar comentario');
} finally {
isSubmittingComment = false;
}
}
async function handleFileUpload(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
// Validate file size (max 10MB)
if (file.size > 10 * 1024 * 1024) {
toast.error('El archivo es demasiado grande. Máximo 10MB');
target.value = '';
return;
}
// Validate file type
const allowedTypes = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf', 'text/plain', 'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
];
if (!allowedTypes.includes(file.type)) {
toast.error('Tipo de archivo no permitido');
target.value = '';
return;
}
isUploading = true;
try {
await tickets.uploadAttachment(ticketId, file);
toast.success('Archivo adjuntado correctamente');
target.value = '';
} catch (error: any) {
toast.error(error.message || 'Error al subir archivo');
} finally {
isUploading = false;
}
}
function handleCloseTicket() {
showCloseDialog = true;
}
async function confirmCloseTicket() {
isClosingTicket = true;
try {
await tickets.closeTicket(ticketId, closeResolution.trim() || undefined);
showCloseDialog = false;
closeResolution = '';
toast.success('Ticket cerrado exitosamente');
} catch (error: any) {
toast.error(error.message || 'Error al cerrar ticket');
} finally {
isClosingTicket = false;
}
}
function cancelCloseTicket() {
showCloseDialog = false;
closeResolution = '';
}
// Check if user can close ticket
$: canClose = $tickets.currentTicket &&
['RESOLVED', 'WAITING_FOR_CLIENT'].includes($tickets.currentTicket.status);
</script>
<svelte:head>
<title>
{$tickets.currentTicket ? `Ticket: ${$tickets.currentTicket.title}` : 'Cargando...'} - ServiceManager
</title>
</svelte:head>
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{#if $tickets.isLoading}
<div class="text-center py-12">
<div class="spinner w-8 h-8 mx-auto mb-4"></div>
<p class="text-gray-600">Cargando ticket...</p>
</div>
{:else if $tickets.error}
<div class="text-center py-12">
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center mx-auto mb-4">
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Error al cargar ticket</h3>
<p class="text-gray-600 mb-4">{$tickets.error}</p>
<button
on:click={() => tickets.loadTicket(ticketId)}
class="btn-primary px-4 py-2"
>
Reintentar
</button>
</div>
{:else if $tickets.currentTicket}
<!-- Breadcrumb -->
<div class="flex items-center space-x-2 text-sm text-gray-500 mb-6">
<a href="/tickets" class="hover:text-primary-600">Mis Tickets</a>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span>#{$tickets.currentTicket.id.substring(0, 8)}</span>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Main Content -->
<div class="lg:col-span-2 space-y-6">
<!-- Ticket Header -->
<div class="card">
<div class="card-header">
<div class="flex justify-between items-start">
<div class="flex-1">
<h1 class="text-2xl font-bold text-gray-900 mb-2">
{$tickets.currentTicket.title}
</h1>
<div class="flex items-center space-x-3">
<span class={statusConfig[$tickets.currentTicket.status].class}>
{statusConfig[$tickets.currentTicket.status].label}
</span>
<span class={priorityConfig[$tickets.currentTicket.priority].class}>
{priorityConfig[$tickets.currentTicket.priority].label}
</span>
<span class="text-sm text-gray-500">
Creado {formatDate($tickets.currentTicket.created_at)}
</span>
</div>
</div>
{#if canClose}
<button
on:click={handleCloseTicket}
class="btn-success px-4 py-2"
disabled={isClosingTicket}
>
Cerrar Ticket
</button>
{/if}
</div>
</div>
<div class="card-content">
<div class="prose max-w-none">
<p class="whitespace-pre-wrap text-gray-700">
{$tickets.currentTicket.description}
</p>
</div>
{#if $tickets.currentTicket.resolution}
<div class="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<h4 class="font-medium text-green-900 mb-2">Resolución:</h4>
<p class="text-green-800 whitespace-pre-wrap">
{$tickets.currentTicket.resolution}
</p>
</div>
{/if}
</div>
</div>
<!-- Attachments -->
{#if $tickets.attachments.length > 0}
<div class="card">
<div class="card-header">
<h3 class="text-lg font-semibold text-gray-900">Archivos Adjuntos</h3>
</div>
<div class="card-content">
<div class="space-y-3">
{#each $tickets.attachments as attachment}
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-gray-200 rounded flex items-center justify-center">
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
</div>
<div>
<p class="text-sm font-medium text-gray-900">
{attachment.original_filename}
</p>
<p class="text-xs text-gray-500">
{Math.round(attachment.size_bytes / 1024)} KB •
Subido por {attachment.uploaded_by_name}
{formatDate(attachment.uploaded_at)}
</p>
</div>
</div>
<a
href="/api/v1/tickets/{ticketId}/attachments/{attachment.id}/download"
class="btn-ghost p-2"
target="_blank"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</a>
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- Comments -->
<div class="card">
<div class="card-header">
<h3 class="text-lg font-semibold text-gray-900">Conversación</h3>
</div>
<div class="card-content">
{#if $tickets.comments.length === 0}
<p class="text-gray-500 text-center py-4">
No hay comentarios aún. ¡Sé el primero en comentar!
</p>
{:else}
<div class="space-y-4">
{#each $tickets.comments as comment}
<div class="flex space-x-3">
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center flex-shrink-0">
<span class="text-primary-600 text-xs font-medium">
{comment.user_name.split(' ').map(n => n[0]).join('')}
</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center space-x-2 mb-1">
<span class="text-sm font-medium text-gray-900">
{comment.user_name}
</span>
<span class="text-xs text-gray-500">
{formatDate(comment.created_at)}
</span>
{#if comment.is_internal}
<span class="bg-red-100 text-red-700 text-xs px-2 py-0.5 rounded">
Interno
</span>
{/if}
</div>
<p class="text-gray-700 whitespace-pre-wrap">
{comment.content}
</p>
</div>
</div>
{/each}
</div>
{/if}
<!-- Add Comment Form -->
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="space-y-4">
<textarea
rows="4"
class="form-input"
placeholder="Escribe tu comentario o respuesta..."
bind:value={newComment}
disabled={isSubmittingComment}
></textarea>
<div class="flex justify-between items-center">
<div class="flex items-center space-x-4">
<input
type="file"
bind:this={fileInput}
on:change={handleFileUpload}
class="hidden"
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.txt,.doc,.docx,.xls,.xlsx"
disabled={isUploading}
/>
<button
type="button"
on:click={() => fileInput.click()}
class="btn-ghost p-2 flex items-center space-x-2"
disabled={isUploading}
>
{#if isUploading}
<div class="spinner w-4 h-4"></div>
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
{/if}
<span class="text-sm">Adjuntar archivo</span>
</button>
</div>
<button
on:click={handleAddComment}
class="btn-primary px-4 py-2"
disabled={isSubmittingComment || !newComment.trim()}
>
{#if isSubmittingComment}
<div class="flex items-center space-x-2">
<div class="spinner w-4 h-4"></div>
<span>Enviando...</span>
</div>
{:else}
Enviar Comentario
{/if}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Ticket Info -->
<div class="card">
<div class="card-header">
<h3 class="text-lg font-semibold text-gray-900">Información</h3>
</div>
<div class="card-content space-y-4">
<div>
<dt class="text-sm font-medium text-gray-500">ID del Ticket</dt>
<dd class="text-sm text-gray-900 font-mono">#{$tickets.currentTicket.id.substring(0, 8)}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Categoría</dt>
<dd class="text-sm text-gray-900">{$tickets.currentTicket.category_name || 'Sin categoría'}</dd>
</div>
{#if $tickets.currentTicket.assigned_to_name}
<div>
<dt class="text-sm font-medium text-gray-500">Asignado a</dt>
<dd class="text-sm text-gray-900">{$tickets.currentTicket.assigned_to_name}</dd>
</div>
{/if}
<div>
<dt class="text-sm font-medium text-gray-500">Creado</dt>
<dd class="text-sm text-gray-900">{formatDate($tickets.currentTicket.created_at)}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Última actualización</dt>
<dd class="text-sm text-gray-900">{formatDate($tickets.currentTicket.updated_at)}</dd>
</div>
{#if $tickets.currentTicket.due_date}
<div>
<dt class="text-sm font-medium text-gray-500">Fecha límite</dt>
<dd class="text-sm text-gray-900 {new Date($tickets.currentTicket.due_date) < new Date() ? 'text-red-600' : ''}">
{formatDate($tickets.currentTicket.due_date)}
{#if new Date($tickets.currentTicket.due_date) < new Date()}
<span class="block text-xs text-red-500">¡Vencido!</span>
{/if}
</dd>
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}
</div>
<!-- Close Ticket Dialog -->
{#if showCloseDialog}
<div class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity" on:click={cancelCloseTicket}>
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-green-100 sm:mx-0 sm:h-10 sm:w-10">
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 class="text-lg leading-6 font-medium text-gray-900">
Cerrar Ticket
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">
¿Estás seguro de que quieres cerrar este ticket? Esta acción indica que el problema ha sido resuelto satisfactoriamente.
</p>
</div>
<div class="mt-4">
<label for="close-resolution" class="form-label">
Comentario de cierre (opcional)
</label>
<textarea
id="close-resolution"
rows="3"
class="form-input"
placeholder="Describe cómo se resolvió el problema o agrega comentarios finales..."
bind:value={closeResolution}
disabled={isClosingTicket}
></textarea>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
class="w-full inline-flex justify-center btn-success px-4 py-2 sm:ml-3 sm:w-auto disabled:opacity-50"
disabled={isClosingTicket}
on:click={confirmCloseTicket}
>
{#if isClosingTicket}
<div class="flex items-center space-x-2">
<div class="spinner w-4 h-4"></div>
<span>Cerrando...</span>
</div>
{:else}
Cerrar Ticket
{/if}
</button>
<button
type="button"
class="mt-3 w-full inline-flex justify-center btn-secondary px-4 py-2 sm:mt-0 sm:w-auto"
disabled={isClosingTicket}
on:click={cancelCloseTicket}
>
Cancelar
</button>
</div>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,241 @@
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { tickets } from '$lib/stores/tickets.js';
import { app } from '$lib/stores/app.js';
import { toast } from '$lib/stores/toast.js';
import { goto } from '$app/navigation';
let title = '';
let description = '';
let categoryId = '';
let priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT' = 'MEDIUM';
let isSubmitting = false;
let errors: Record<string, string> = {};
onMount(() => {
// Redirect if not authenticated
if (!$auth.isAuthenticated) {
goto('/login');
return;
}
// Load categories for the form
app.loadCategories();
});
function validateForm() {
errors = {};
if (!title.trim()) {
errors.title = 'El título es requerido';
} else if (title.trim().length < 10) {
errors.title = 'El título debe tener al menos 10 caracteres';
}
if (!description.trim()) {
errors.description = 'La descripción es requerida';
} else if (description.trim().length < 20) {
errors.description = 'La descripción debe tener al menos 20 caracteres';
}
if (!categoryId) {
errors.categoryId = 'Debes seleccionar una categoría';
}
return Object.keys(errors).length === 0;
}
async function handleSubmit() {
if (!validateForm()) return;
isSubmitting = true;
try {
const newTicket = await tickets.createTicket({
title: title.trim(),
description: description.trim(),
category_id: categoryId,
priority
});
toast.success('Ticket creado exitosamente');
goto(`/tickets/${newTicket.id}`);
} catch (error: any) {
console.error('Create ticket error:', error);
toast.error(error.message || 'Error al crear el ticket');
} finally {
isSubmitting = false;
}
}
</script>
<svelte:head>
<title>Crear Ticket - ServiceManager</title>
</svelte:head>
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center space-x-2 text-sm text-gray-500 mb-4">
<a href="/tickets" class="hover:text-primary-600">Mis Tickets</a>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span>Crear Ticket</span>
</div>
<h1 class="text-3xl font-bold text-gray-900">Crear Nuevo Ticket</h1>
<p class="text-gray-600 mt-2">
Describe tu problema o solicitud de soporte con el mayor detalle posible
</p>
</div>
<!-- Form -->
<form on:submit|preventDefault={handleSubmit} class="space-y-6">
<div class="card">
<div class="card-content space-y-6">
<!-- Title -->
<div>
<label for="title" class="form-label">
Título del Ticket <span class="text-red-500">*</span>
</label>
<input
id="title"
type="text"
class="form-input {errors.title ? 'border-red-300' : ''}"
placeholder="Describe brevemente el problema..."
bind:value={title}
disabled={isSubmitting}
maxlength="200"
/>
{#if errors.title}
<p class="form-error">{errors.title}</p>
{/if}
<p class="text-xs text-gray-500 mt-1">
{title.length}/200 caracteres
</p>
</div>
<!-- Category -->
<div>
<label for="category" class="form-label">
Categoría <span class="text-red-500">*</span>
</label>
<select
id="category"
class="form-input {errors.categoryId ? 'border-red-300' : ''}"
bind:value={categoryId}
disabled={isSubmitting || $app.isLoading}
>
<option value="">Selecciona una categoría</option>
{#each $app.categories as category}
<option value={category.id}>{category.name}</option>
{/each}
</select>
{#if errors.categoryId}
<p class="form-error">{errors.categoryId}</p>
{/if}
</div>
<!-- Priority -->
<div>
<label for="priority" class="form-label">
Prioridad
</label>
<select
id="priority"
class="form-input"
bind:value={priority}
disabled={isSubmitting}
>
<option value="LOW">Baja - No es urgente, puede esperar</option>
<option value="MEDIUM">Media - Problema normal de trabajo</option>
<option value="HIGH">Alta - Afecta el trabajo significativamente</option>
<option value="URGENT">Urgente - Bloquea el trabajo completamente</option>
</select>
</div>
<!-- Description -->
<div>
<label for="description" class="form-label">
Descripción del Problema <span class="text-red-500">*</span>
</label>
<textarea
id="description"
rows="8"
class="form-input {errors.description ? 'border-red-300' : ''}"
placeholder="Describe el problema con el mayor detalle posible. Incluye:
- Qué estabas haciendo cuando ocurrió el problema
- Qué esperabas que pasara
- Qué pasó en realidad
- Pasos para reproducir el problema
- Cualquier mensaje de error
- Información adicional relevante"
bind:value={description}
disabled={isSubmitting}
maxlength="2000"
></textarea>
{#if errors.description}
<p class="form-error">{errors.description}</p>
{/if}
<p class="text-xs text-gray-500 mt-1">
{description.length}/2000 caracteres
</p>
</div>
</div>
</div>
<!-- Help Tips -->
<div class="card bg-blue-50 border-blue-200">
<div class="card-content">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800">
Tips para un mejor soporte
</h3>
<div class="mt-2 text-sm text-blue-700">
<ul class="list-disc pl-5 space-y-1">
<li>Sé específico y detallado en tu descripción</li>
<li>Incluye capturas de pantalla si es posible (puedes adjuntarlas después)</li>
<li>Menciona qué navegador/sistema operativo estás usando</li>
<li>Indica si el problema es recurrente o fue la primera vez</li>
<li>Si hay mensajes de error, cópialos exactamente</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex justify-between items-center pt-6">
<a
href="/tickets"
class="btn-secondary px-6 py-2"
>
Cancelar
</a>
<button
type="submit"
class="btn-primary px-6 py-2 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSubmitting}
>
{#if isSubmitting}
<div class="flex items-center space-x-2">
<div class="spinner w-4 h-4"></div>
<span>Creando...</span>
</div>
{:else}
Crear Ticket
{/if}
</button>
</div>
</form>
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -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;

View File

@@ -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'),
],
};

View File

@@ -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'
}
});

View File

@@ -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/**"
]
}

View File

@@ -0,0 +1,11 @@
.svelte-kit
build
dist
.env
.env.*
!.env.example
node_modules
.DS_Store
*.log
coverage
.nyc_output

View File

@@ -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"
}
}
]
}

View File

@@ -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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="description" content="ServiceManager - Mesa de Ayuda Empresarial - Portal Interno" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,89 @@
<script lang="ts">
import { auth } from '$lib/stores/auth.js';
export let toggleSidebar: () => void;
function handleLogout() {
auth.logout();
}
let isMenuOpen = false;
function toggleMenu() {
isMenuOpen = !isMenuOpen;
}
</script>
<header class="bg-white shadow-sm border-b border-gray-200">
<div class="flex justify-between items-center px-4 py-3">
<!-- Left side -->
<div class="flex items-center space-x-4">
<button
on:click={toggleSidebar}
class="p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500 lg:hidden"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div class="hidden lg:block">
<h1 class="text-lg font-semibold text-gray-900">ServiceManager Internal</h1>
</div>
</div>
<!-- Right side -->
<div class="flex items-center space-x-4">
<!-- User menu -->
<div class="relative">
<button
on:click={toggleMenu}
class="flex items-center space-x-2 text-gray-700 hover:text-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded-md p-2"
>
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
<span class="text-primary-600 text-sm font-medium">
{$auth.user?.first_name?.[0]}{$auth.user?.last_name?.[0]}
</span>
</div>
<span class="hidden sm:block text-sm">
{$auth.user?.first_name} {$auth.user?.last_name}
</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if isMenuOpen}
<div class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg border border-gray-200 z-50">
<div class="py-1">
<div class="px-4 py-2 text-xs text-gray-500 border-b border-gray-200">
{$auth.user?.email}
</div>
<a
href="/profile"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
on:click={() => isMenuOpen = false}
>
Mi Perfil
</a>
<button
on:click={handleLogout}
class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
Cerrar Sesión
</button>
</div>
</div>
{/if}
</div>
</div>
</div>
</header>
<!-- Backdrop for mobile menu -->
{#if isMenuOpen}
<div
class="fixed inset-0 z-40 lg:hidden"
on:click={() => isMenuOpen = false}
></div>
{/if}

View File

@@ -0,0 +1,43 @@
<script lang="ts">
export let name: string;
export let size: string = 'w-5 h-5';
export let className: string = '';
const icons: Record<string, string> = {
home: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6',
ticket: 'M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z',
plus: 'M12 4v16m8-8H4',
user: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
menu: 'M4 6h16M4 12h16M4 18h16',
x: 'M6 18L18 6M6 6l12 12',
bell: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9',
search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
edit: 'M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z',
trash: 'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16',
eye: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z',
clock: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
check: 'M5 13l4 4L19 7',
chevronDown: 'M19 9l-7 7-7-7',
dashboard: 'M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z',
users: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z',
chart: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
settings: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z'
};
$: path = icons[name] || icons.home;
</script>
<svg
class="{size} {className}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={path}
/>
</svg>

View File

@@ -0,0 +1,44 @@
<script>
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
export let open = false;
export let title = '';
const dispatch = createEventDispatcher();
function close() {
dispatch('close');
}
function handleKeydown(e) {
if (e.key === 'Escape' && open) {
close();
}
}
</script>
<svelte:window on:keydown={handleKeydown}/>
{#if open}
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" aria-hidden="true" on:click={close}></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block px-4 pt-5 pb-4 overflow-hidden text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
<h3 class="text-lg leading-6 font-medium text-gray-900" id="modal-title">
{title}
</h3>
<div class="mt-2 text-sm text-gray-500">
<slot />
</div>
</div>
</div>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,153 @@
<script lang="ts">
import { auth } from '$lib/stores/auth.js';
import { page } from '$app/stores';
export let open = false;
// Navigation items based on user role
$: navigation = getNavigationForRole($auth.user?.role);
function getNavigationForRole(role: string | undefined) {
const baseNavigation = [
{
name: 'Dashboard',
href: '/',
icon: 'M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z'
},
{
name: 'Tickets',
href: '/tickets',
icon: 'M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2'
}
];
if (role === 'ADMIN' || role === 'SUPPORT_MANAGER') {
baseNavigation.push(
{
name: 'Clientes',
href: '/tenants',
icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4'
},
{
name: 'Usuarios',
href: '/users',
icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z'
},
{
name: 'Categorías',
href: '/categories',
icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10'
},
{
name: 'Sistemas',
href: '/systems',
icon: 'M5 12a1 1 0 102 0V6.414l1.293 1.293a1 1 0 001.414-1.414l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L5 6.414V12zm14 0a1 1 0 10-2 0v5.586l-1.293-1.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L19 17.586V12z'
}
);
}
if (role === 'ADMIN') {
baseNavigation.push(
{
name: 'SLA Management',
href: '/sla',
icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z'
},
{
name: 'Reportes',
href: '/reports',
icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z'
},
{
name: 'Auditoría',
href: '/audit',
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z'
}
);
}
return baseNavigation;
}
function isCurrentPage(href: string) {
return $page.url.pathname === href ||
($page.url.pathname.startsWith(href) && href !== '/');
}
</script>
<!-- Mobile sidebar backdrop -->
{#if open}
<div class="fixed inset-0 z-40 lg:hidden">
<div class="fixed inset-0 bg-gray-600 bg-opacity-75" on:click={() => open = false}></div>
</div>
{/if}
<!-- Sidebar -->
<div class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform {open ? 'translate-x-0' : '-translate-x-full'} transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0">
<div class="flex flex-col h-full">
<!-- Logo -->
<div class="flex items-center justify-between h-16 px-6 bg-primary-600">
<div class="flex items-center space-x-2">
<div class="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192L5.636 18.364M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
<div class="text-white">
<h1 class="text-sm font-semibold">ServiceManager</h1>
<p class="text-xs text-primary-100">Panel Interno</p>
</div>
</div>
<button
on:click={() => open = false}
class="p-2 rounded-md text-primary-100 hover:text-white hover:bg-primary-500 lg:hidden"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Navigation -->
<nav class="flex-1 px-4 py-6 space-y-2">
{#each navigation as item}
<a
href={item.href}
class="flex items-center space-x-3 px-3 py-2 rounded-md text-sm font-medium transition-colors {
isCurrentPage(item.href)
? 'bg-primary-100 text-primary-700'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
}"
on:click={() => open = false}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={item.icon} />
</svg>
<span>{item.name}</span>
</a>
{/each}
</nav>
<!-- User info -->
<div class="px-4 py-4 border-t border-gray-200">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
<span class="text-primary-600 text-sm font-medium">
{$auth.user?.first_name?.[0]}{$auth.user?.last_name?.[0]}
</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">
{$auth.user?.first_name} {$auth.user?.last_name}
</p>
<p class="text-xs text-gray-500 truncate">
{$auth.user?.role === 'ADMIN' ? 'Administrador' :
$auth.user?.role === 'SUPPORT_MANAGER' ? 'Gerente de Soporte' :
$auth.user?.role === 'AGENT' ? 'Agente' : 'Auditor'}
</p>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,117 @@
<script lang="ts">
export let type: 'success' | 'error' | 'warning' | 'info' = 'info';
export let message: string;
export let duration: number = 5000;
export let dismissible: boolean = true;
let visible = true;
let timeoutId: NodeJS.Timeout;
// Auto-dismiss after duration
if (duration > 0) {
timeoutId = setTimeout(() => {
visible = false;
}, duration);
}
function dismiss() {
if (timeoutId) clearTimeout(timeoutId);
visible = false;
}
// Cleanup timeout on destroy
import { onDestroy } from 'svelte';
onDestroy(() => {
if (timeoutId) clearTimeout(timeoutId);
});
// Style mapping
const typeStyles = {
success: {
container: 'bg-success-50 border-success-200 text-success-800',
icon: 'text-success-400'
},
error: {
container: 'bg-error-50 border-error-200 text-error-800',
icon: 'text-error-400'
},
warning: {
container: 'bg-warning-50 border-warning-200 text-warning-800',
icon: 'text-warning-400'
},
info: {
container: 'bg-blue-50 border-blue-200 text-blue-800',
icon: 'text-blue-400'
}
};
// Icon mapping
const typeIcons = {
success: 'M5 13l4 4L19 7',
error: 'M6 18L18 6M6 6l12 12',
warning: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z',
info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
};
</script>
{#if visible}
<div class="fixed top-4 right-4 max-w-sm w-full z-50 animate-slide-up">
<div class="rounded-lg border p-4 shadow-lg {typeStyles[type].container}">
<div class="flex items-start">
<div class="flex-shrink-0">
<svg
class="h-5 w-5 {typeStyles[type].icon}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={typeIcons[type]}
/>
</svg>
</div>
<div class="ml-3 flex-1">
<p class="text-sm font-medium">
{message}
</p>
</div>
{#if dismissible}
<div class="ml-4 flex-shrink-0">
<button
type="button"
class="inline-flex rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 {typeStyles[type].icon} hover:opacity-75"
on:click={dismiss}
>
<span class="sr-only">Cerrar</span>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/if}
</div>
</div>
</div>
{/if}
<style>
@keyframes slide-up {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
</style>

View File

@@ -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<AuthState>(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<void> => {
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<void> => {
// Need to get current state to access refresh token, logic simplified
let currentRefreshToken: string | null = null;
if (typeof window !== 'undefined') {
currentRefreshToken = localStorage.getItem('internal_auth_refresh_token');
}
if (!currentRefreshToken) {
throw new Error("No refresh token available");
}
update (state => ({ ...state, isLoading: true }));
try {
const response = await fetch('/api/v1/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refresh_token: currentRefreshToken })
});
if (!response.ok) {
// If refresh fails, logout
if (response.status === 401 || response.status === 403) {
auth.logout();
}
const error = await response.json();
throw new Error(error.detail || 'Refresh failed');
}
const data: TokenResponse = await response.json();
// Update token in storage and state
if (typeof window !== 'undefined') {
localStorage.setItem('internal_auth_token', data.access_token);
}
update(state => ({
...state,
token: data.access_token,
isLoading: false
}));
} catch (error) {
update(state => ({ ...state, isLoading: false }));
throw error;
}
},
// Logout
logout: () => {
if (typeof window !== 'undefined') {
localStorage.removeItem('internal_auth_token');
localStorage.removeItem('internal_auth_refresh_token');
localStorage.removeItem('internal_auth_user');
}
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();

View File

@@ -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<ToastState> = 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();

View File

@@ -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<string, string>;
}
async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
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: <T>(endpoint: string, params?: Record<string, string>) =>
request<T>(endpoint, { method: 'GET', params }),
post: <T>(endpoint: string, body: any) =>
request<T>(endpoint, { method: 'POST', body: JSON.stringify(body) }),
put: <T>(endpoint: string, body: any) =>
request<T>(endpoint, { method: 'PUT', body: JSON.stringify(body) }),
patch: <T>(endpoint: string, body: any) =>
request<T>(endpoint, { method: 'PATCH', body: JSON.stringify(body) }),
delete: <T>(endpoint: string) =>
request<T>(endpoint, { method: 'DELETE' })
};

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import Header from '$lib/components/Header.svelte';
import Sidebar from '$lib/components/Sidebar.svelte';
import Toast from '$lib/components/Toast.svelte';
import { toast } from '$lib/stores/toast.js';
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import '../app.css';
let sidebarOpen = false;
onMount(() => {
auth.init();
});
function toggleSidebar() {
sidebarOpen = !sidebarOpen;
}
</script>
<div class="min-h-screen bg-gray-50">
{#if $auth.isAuthenticated}
<!-- Internal Layout with Sidebar -->
<div class="flex h-screen overflow-hidden">
<!-- Sidebar -->
<Sidebar bind:open={sidebarOpen} />
<!-- Main content -->
<div class="flex-1 flex flex-col overflow-hidden">
<Header {toggleSidebar} />
<main class="flex-1 overflow-auto">
<slot />
</main>
</div>
</div>
{:else}
<!-- Login Layout -->
<main class="flex-1">
<slot />
</main>
{/if}
<!-- Toast notifications -->
{#each $toast.toasts as toastMessage (toastMessage.id)}
<Toast
type={toastMessage.type}
message={toastMessage.message}
duration={toastMessage.duration}
on:dismiss={() => toast.dismiss(toastMessage.id)}
/>
{/each}
</div>

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { goto } from '$app/navigation';
import Icon from '$lib/components/Icon.svelte';
onMount(() => {
if (!$auth.isAuthenticated) {
goto('/login');
}
});
const cards = [
{
title: 'Clientes',
description: 'Gestión de organizaciones y tenants',
icon: 'users',
href: '/tenants',
color: 'bg-blue-500'
},
{
title: 'Usuarios',
description: 'Administración de usuarios y roles',
icon: 'user-plus',
href: '/users',
color: 'bg-green-500'
},
{
title: 'Sistemas',
description: 'Catálogo de sistemas soportados',
icon: 'server',
href: '/systems',
color: 'bg-purple-500'
},
{
title: 'Categorías',
description: 'Clasificación de tickets',
icon: 'tag',
href: '/categories',
color: 'bg-orange-500'
}
];
</script>
<svelte:head>
<title>Dashboard Admin - ServiceManager</title>
</svelte:head>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="md:flex md:items-center md:justify-between">
<div class="flex-1 min-w-0">
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">
Panel de Administración
</h2>
<p class="mt-1 text-sm text-gray-500">
Bienvenido al sistema de gestión interna.
</p>
</div>
</div>
<div class="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
{#each cards as card}
<a href={card.href} class="bg-white overflow-hidden shadow rounded-lg hover:shadow-md transition-shadow duration-200 cursor-pointer group">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="{card.color} rounded-md p-3">
<!-- Simple SVG Icon placeholder since Icon component might expect specific names that map to SVGs -->
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
{card.title}
</dt>
<dd>
<div class="text-xs text-gray-900 font-light mt-1">
{card.description}
</div>
</dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="text-sm">
<span class="font-medium text-cyan-700 hover:text-cyan-900">
Ver detalles
</span>
</div>
</div>
</a>
{/each}
</div>
</div>

View File

@@ -0,0 +1,184 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
import Modal from '$lib/components/Modal.svelte';
let categories = [];
let tenants = [];
let isLoading = false;
let showModal = false;
let editingCategory = null;
let formData = {
name: '',
description: '',
tenant_id: '',
is_active: true
};
async function loadData() {
isLoading = true;
try {
const [categoriesData, tenantsData] = await Promise.all([
api.get('/categories/'),
api.get('/tenants/')
]);
categories = categoriesData;
tenants = tenantsData;
} catch (e) {
toast.error('Error cargando datos');
} finally {
isLoading = false;
}
}
function openCreateModal() {
editingCategory = null;
formData = { name: '', description: '', tenant_id: '', is_active: true };
showModal = true;
}
function openEditModal(category) {
editingCategory = category;
formData = {
name: category.name,
description: category.description,
tenant_id: category.tenant_id || '',
is_active: category.is_active
};
showModal = true;
}
async function handleSubmit() {
try {
const payload = { ...formData };
if (!payload.tenant_id) payload.tenant_id = null;
if (editingCategory) {
await api.put(`/categories/${editingCategory.id}`, payload);
toast.success('Categoría actualizada');
} else {
await api.post('/categories/', payload);
toast.success('Categoría creada');
}
showModal = false;
loadData();
} catch (e) {
toast.error(e.message || 'Error guardando categoría');
}
}
function getTenantName(id) {
if (!id) return 'Global';
const t = tenants.find(t => t.id === id);
return t ? t.name : id;
}
onMount(loadData);
</script>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">Categorías de Tickets</h1>
<p class="mt-2 text-sm text-gray-700">Gestión de categorías para clasificación de tickets.</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<button
type="button"
on:click={openCreateModal}
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
>
Nueva Categoría
</button>
</div>
</div>
<div class="mt-8 flex flex-col">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Nombre</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Descripción</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Tipo (Cliente)</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Estado</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">Acciones</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#if isLoading}
<tr><td colspan="5" class="text-center py-4">Cargando...</td></tr>
{:else if categories.length === 0}
<tr><td colspan="5" class="text-center py-4">No hay categorías registradas</td></tr>
{:else}
{#each categories as category}
<tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">{category.name}</td>
<td class="px-3 py-4 text-sm text-gray-500 max-w-xs truncate">{category.description || '-'}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<span class:bg-blue-100={!category.tenant_id} class:text-blue-800={!category.tenant_id} class:bg-gray-100={category.tenant_id} class:text-gray-800={category.tenant_id} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
{getTenantName(category.tenant_id)}
</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<span class:bg-green-100={category.is_active} class:text-green-800={category.is_active} class:bg-red-100={!category.is_active} class:text-red-800={!category.is_active} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
{category.is_active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<button on:click={() => openEditModal(category)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<Modal open={showModal} title={editingCategory ? 'Editar Categoría' : 'Nueva Categoría'} on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700">Descripción</label>
<textarea id="description" bind:value={formData.description} rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"></textarea>
</div>
<div>
<label for="tenant" class="block text-sm font-medium text-gray-700">Cliente (Opcional - Específico para un cliente)</label>
<select id="tenant" bind:value={formData.tenant_id} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
<option value="">-- Global (Para todos) --</option>
{#each tenants as tenant}
<option value={tenant.id}>{tenant.name}</option>
{/each}
</select>
</div>
<div class="flex items-center">
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm">
Guardar
</button>
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
Cancelar
</button>
</div>
</form>
</Modal>

View File

@@ -0,0 +1,211 @@
<script lang="ts">
import { auth } from '$lib/stores/auth.js';
import { toast } from '$lib/stores/toast.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import Icon from '$lib/components/Icon.svelte';
let email = '';
let password = '';
let totpCode = '';
let isLoading = false;
let showTwoFactor = false;
let errorMessage = '';
onMount(() => {
// Redirect if already authenticated
if ($auth.isAuthenticated) {
goto('/');
}
});
async function handleLogin() {
if (!email || !password) {
errorMessage = 'Por favor completa todos los campos';
return;
}
isLoading = true;
errorMessage = '';
try {
await auth.login({
email,
password,
tenant_slug: 'system-admin',
totp_code: totpCode || undefined
});
toast.success('¡Bienvenido! Has iniciado sesión correctamente');
goto('/');
} catch (error: any) {
console.error('Login error:', error);
// Check if 2FA is required
if (error.message.includes('two-factor') || error.message.includes('2FA')) {
showTwoFactor = true;
errorMessage = 'Introduce el código de tu aplicación de autenticación';
} else {
errorMessage = error.message || 'Error al iniciar sesión';
toast.error(errorMessage);
}
} finally {
isLoading = false;
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter') {
handleLogin();
}
}
</script>
<svelte:head>
<title>Acceso Admin - ServiceManager</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-950 p-4 font-sans">
<div class="w-full max-w-5xl grid grid-cols-1 md:grid-cols-2 bg-white dark:bg-gray-900 rounded-lg shadow-xl overflow-hidden border border-gray-200 dark:border-gray-800">
<!-- Left Side: Internal Branding -->
<div class="hidden md:flex flex-col justify-between p-12 bg-gray-900 text-white relative overflow-hidden">
<!-- Grid pattern overlay -->
<div class="absolute inset-0 opacity-10" style="background-image: radial-gradient(white 1px, transparent 1px); background-size: 30px 30px;"></div>
<div class="relative z-10">
<div class="flex items-center space-x-3 mb-6">
<div class="p-2 bg-blue-500/20 rounded border border-blue-500/30">
<Icon name="server" class="w-6 h-6 text-blue-400" />
</div>
<span class="text-sm font-mono tracking-wider text-blue-400">INTERNAL_ACCESS_V2</span>
</div>
<h1 class="text-3xl font-bold tracking-tight mb-4">
Panel de Administración
</h1>
<p class="text-gray-400 text-sm leading-relaxed max-w-sm">
Plataforma de gestión de servicios, monitoreo de tickets y administración de usuarios. Acceso restringido únicamente a personal autorizado.
</p>
</div>
<div class="relative z-10 mt-12">
<div class="space-y-3">
<div class="flex items-center space-x-3 text-xs text-gray-400 font-mono">
<Icon name="check-circle" class="w-4 h-4 text-green-500" />
<span>System Status: Operational</span>
</div>
<div class="flex items-center space-x-3 text-xs text-gray-400 font-mono">
<Icon name="shield" class="w-4 h-4 text-blue-500" />
<span>256-bit Encryption Enabled</span>
</div>
</div>
</div>
</div>
<!-- Right Side: Login Form -->
<div class="p-8 md:p-12 flex flex-col justify-center">
<div class="max-w-sm mx-auto w-full">
<div class="mb-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">Identifíquese</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Acceso al sistema central</p>
</div>
<form on:submit|preventDefault={handleLogin} class="space-y-5">
{#if errorMessage}
<div class="p-3 rounded-md bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-900 flex items-start gap-3">
<Icon name="alert-triangle" class="w-5 h-5 text-red-600 dark:text-red-500 flex-shrink-0 mt-0.5" />
<p class="text-sm text-red-600 dark:text-red-500">{errorMessage}</p>
</div>
{/if}
{#if !showTwoFactor}
<div class="space-y-4">
<div>
<label for="email" class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">Usuario / Correo</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors">
<Icon name="user" class="w-5 h-5" />
</div>
<input
id="email"
type="email"
bind:value={email}
on:keydown={handleKeyDown}
class="form-input w-full pl-10 py-2.5 bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all font-mono text-sm"
placeholder="admin@aduanasoft.com"
required
disabled={isLoading}
/>
</div>
</div>
<div>
<label for="password" class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">Clave de Acceso</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors">
<Icon name="lock" class="w-5 h-5" />
</div>
<input
id="password"
type="password"
bind:value={password}
on:keydown={handleKeyDown}
class="form-input w-full pl-10 py-2.5 bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all font-mono text-sm"
placeholder="••••••••••••"
required
disabled={isLoading}
/>
</div>
</div>
</div>
{:else}
<!-- 2FA Input -->
<div class="bg-blue-50 dark:bg-blue-900/10 p-4 rounded-lg border border-blue-100 dark:border-blue-800/30">
<label for="code" class="block text-xs font-semibold uppercase tracking-wider text-blue-800 dark:text-blue-300 mb-2 text-center">Verificación de Seguridad</label>
<div class="relative">
<input
id="code"
type="text"
bind:value={totpCode}
on:keydown={handleKeyDown}
class="form-input w-full py-3 rounded border-blue-300 dark:border-blue-700 focus:ring-blue-500 focus:border-blue-500 text-center tracking-[0.5em] font-mono text-lg bg-white dark:bg-gray-800"
placeholder="000000"
maxlength="6"
required
disabled={isLoading}
autofocus
/>
</div>
<p class="text-xs text-blue-600 dark:text-blue-400 mt-2 text-center">
Consulte su dispositivo autenticador
</p>
</div>
{/if}
<div class="pt-4">
<button
type="submit"
class="w-full flex justify-center py-2.5 px-4 rounded bg-gray-900 dark:bg-gray-700 text-white font-medium hover:bg-gray-800 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
disabled={isLoading}
>
{#if isLoading}
<Icon name="loader" class="w-4 h-4 animate-spin mr-2" />
Autenticando...
{:else}
{showTwoFactor ? 'Verificar Token' : 'Entrar al Panel'}
{/if}
</button>
</div>
</form>
</div>
<div class="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800">
<p class="text-[10px] text-gray-400 text-center uppercase tracking-widest">Aduanasoft Internal Systems © 2024</p>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,147 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
import Modal from '$lib/components/Modal.svelte';
let systems = [];
let isLoading = false;
let showModal = false;
let editingSystem = null;
let formData = {
name: '',
description: '',
is_active: true
};
async function loadSystems() {
isLoading = true;
try {
systems = await api.get('/systems/');
} catch (e) {
toast.error('Error cargando sistemas');
} finally {
isLoading = false;
}
}
function openCreateModal() {
editingSystem = null;
formData = { name: '', description: '', is_active: true };
showModal = true;
}
function openEditModal(system) {
editingSystem = system;
formData = { ...system };
showModal = true;
}
async function handleSubmit() {
try {
if (editingSystem) {
await api.put(`/systems/${editingSystem.id}`, formData);
toast.success('Sistema actualizado');
} else {
await api.post('/systems/', formData);
toast.success('Sistema creado');
}
showModal = false;
loadSystems();
} catch (e) {
toast.error(e.message || 'Error guardando sistema');
}
}
onMount(loadSystems);
</script>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">Sistemas</h1>
<p class="mt-2 text-sm text-gray-700">Catálogo de sistemas informáticos gestionados.</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<button
type="button"
on:click={openCreateModal}
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
>
Nuevo Sistema
</button>
</div>
</div>
<div class="mt-8 flex flex-col">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Nombre</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Descripción</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Estado</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">Acciones</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#if isLoading}
<tr><td colspan="4" class="text-center py-4">Cargando...</td></tr>
{:else if systems.length === 0}
<tr><td colspan="4" class="text-center py-4">No hay sistemas registrados</td></tr>
{:else}
{#each systems as system}
<tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">{system.name}</td>
<td class="px-3 py-4 text-sm text-gray-500 max-w-xs truncate">{system.description || '-'}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<span class:bg-green-100={system.is_active} class:text-green-800={system.is_active} class:bg-red-100={!system.is_active} class:text-red-800={!system.is_active} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
{system.is_active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<button on:click={() => openEditModal(system)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<Modal open={showModal} title={editingSystem ? 'Editar Sistema' : 'Nuevo Sistema'} on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700">Descripción</label>
<textarea id="description" bind:value={formData.description} rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"></textarea>
</div>
<div class="flex items-center">
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm">
Guardar
</button>
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
Cancelar
</button>
</div>
</form>
</Modal>

View File

@@ -0,0 +1,157 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
import Modal from '$lib/components/Modal.svelte';
import Icon from '$lib/components/Icon.svelte';
let tenants = [];
let isLoading = false;
let showModal = false;
let editingTenant = null;
let formData = {
name: '',
slug: '',
domain: '',
is_active: true
};
async function loadTenants() {
isLoading = true;
try {
tenants = await api.get('/tenants/');
} catch (e) {
toast.error('Error cargando clientes');
} finally {
isLoading = false;
}
}
function openCreateModal() {
editingTenant = null;
formData = { name: '', slug: '', domain: '', is_active: true };
showModal = true;
}
function openEditModal(tenant) {
editingTenant = tenant;
formData = { ...tenant };
showModal = true;
}
async function handleSubmit() {
try {
if (editingTenant) {
await api.put(`/tenants/${editingTenant.id}`, formData);
toast.success('Cliente actualizado');
} else {
await api.post('/tenants/', formData);
toast.success('Cliente creado');
}
showModal = false;
loadTenants();
} catch (e) {
toast.error(e.message || 'Error guardando cliente');
}
}
onMount(loadTenants);
</script>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">Clientes</h1>
<p class="mt-2 text-sm text-gray-700">Lista de todas las organizaciones/clientes registrados en el sistema.</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<button
type="button"
on:click={openCreateModal}
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
>
Nuevo Cliente
</button>
</div>
</div>
<div class="mt-8 flex flex-col">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Nombre</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Slug</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Dominio</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Estado</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">Acciones</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#if isLoading}
<tr><td colspan="5" class="text-center py-4">Cargando...</td></tr>
{:else if tenants.length === 0}
<tr><td colspan="5" class="text-center py-4">No hay clientes registrados</td></tr>
{:else}
{#each tenants as tenant}
<tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">{tenant.name}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{tenant.slug}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{tenant.domain || '-'}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<span class:bg-green-100={tenant.is_active} class:text-green-800={tenant.is_active} class:bg-red-100={!tenant.is_active} class:text-red-800={!tenant.is_active} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
{tenant.is_active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<button on:click={() => openEditModal(tenant)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<Modal open={showModal} title={editingTenant ? 'Editar Cliente' : 'Nuevo Cliente'} on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
<input type="text" id="name" bind:value={formData.name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
</div>
<div>
<label for="slug" class="block text-sm font-medium text-gray-700">Slug (Identificador)</label>
<input type="text" id="slug" bind:value={formData.slug} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
<p class="text-xs text-gray-500 mt-1">Usado en URLs y subdominios.</p>
</div>
<div>
<label for="domain" class="block text-sm font-medium text-gray-700">Dominio Personalizado</label>
<input type="text" id="domain" bind:value={formData.domain} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
</div>
<div class="flex items-center">
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm">
Guardar
</button>
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
Cancelar
</button>
</div>
</form>
</Modal>

View File

@@ -0,0 +1,231 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
import Modal from '$lib/components/Modal.svelte';
let users = [];
let tenants = [];
let isLoading = false;
let showModal = false;
let editingUser = null;
let formData = {
email: '',
password: '',
first_name: '',
last_name: '',
role: 'AGENT',
tenant_id: '',
is_active: true
};
const ROLES = [
{ value: 'ADMIN', label: 'Administrador (Global)' },
{ value: 'SUPPORT_MANAGER', label: 'Gerente de Soporte' },
{ value: 'AGENT', label: 'Agente de Soporte' },
{ value: 'AUDITOR', label: 'Auditor' },
{ value: 'CLIENT_ADMIN', label: 'Admin Cliente' },
{ value: 'CLIENT_USER', label: 'Usuario Cliente' }
];
async function loadData() {
isLoading = true;
try {
const [usersData, tenantsData] = await Promise.all([
api.get('/users/'),
api.get('/tenants/')
]);
users = usersData;
tenants = tenantsData;
} catch (e) {
toast.error('Error cargando datos');
} finally {
isLoading = false;
}
}
function openCreateModal() {
editingUser = null;
formData = {
email: '',
password: '',
first_name: '',
last_name: '',
role: 'AGENT',
tenant_id: '',
is_active: true
};
showModal = true;
}
function openEditModal(user) {
editingUser = user;
formData = {
email: user.email,
password: '', // Don't show password
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
tenant_id: user.tenant_id || '',
is_active: user.is_active
};
showModal = true;
}
async function handleSubmit() {
try {
const payload = { ...formData };
if (!payload.password) delete payload.password; // Don't send empty password on edit
if (!payload.tenant_id) payload.tenant_id = null; // Send null if empty string
if (editingUser) {
await api.put(`/users/${editingUser.id}`, payload);
toast.success('Usuario actualizado');
} else {
if (!payload.password) {
toast.error('La contraseña es requerida para nuevos usuarios');
return;
}
await api.post('/users/', payload);
toast.success('Usuario creado');
}
showModal = false;
loadData();
} catch (e) {
toast.error(e.message || 'Error guardando usuario');
}
}
function getTenantName(id) {
if (!id) return '-';
const t = tenants.find(t => t.id === id);
return t ? t.name : id;
}
onMount(loadData);
</script>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">Usuarios</h1>
<p class="mt-2 text-sm text-gray-700">Gestión de usuarios internos y de clientes.</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<button
type="button"
on:click={openCreateModal}
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
>
Nuevo Usuario
</button>
</div>
</div>
<div class="mt-8 flex flex-col">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Usuario</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Rol</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Cliente (Tenant)</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Estado</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">Acciones</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#if isLoading}
<tr><td colspan="5" class="text-center py-4">Cargando...</td></tr>
{:else if users.length === 0}
<tr><td colspan="5" class="text-center py-4">No hay usuarios registrados</td></tr>
{:else}
{#each users as user}
<tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm sm:pl-6">
<div class="font-medium text-gray-900">{user.first_name} {user.last_name}</div>
<div class="text-gray-500">{user.email}</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{user.role}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{getTenantName(user.tenant_id)}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<span class:bg-green-100={user.is_active} class:text-green-800={user.is_active} class:bg-red-100={!user.is_active} class:text-red-800={!user.is_active} class="inline-flex rounded-full px-2 text-xs font-semibold leading-5">
{user.is_active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<button on:click={() => openEditModal(user)} class="text-indigo-600 hover:text-indigo-900">Editar</button>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<Modal open={showModal} title={editingUser ? 'Editar Usuario' : 'Nuevo Usuario'} on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label for="first_name" class="block text-sm font-medium text-gray-700">Nombre</label>
<input type="text" id="first_name" bind:value={formData.first_name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
</div>
<div>
<label for="last_name" class="block text-sm font-medium text-gray-700">Apellido</label>
<input type="text" id="last_name" bind:value={formData.last_name} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
</div>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input type="email" id="email" bind:value={formData.email} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Contraseña {editingUser ? '(dejar en blanco para mantener)' : ''}</label>
<input type="password" id="password" bind:value={formData.password} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
</div>
<div>
<label for="role" class="block text-sm font-medium text-gray-700">Rol</label>
<select id="role" bind:value={formData.role} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
{#each ROLES as role}
<option value={role.value}>{role.label}</option>
{/each}
</select>
</div>
<div>
<label for="tenant" class="block text-sm font-medium text-gray-700">Cliente (Opcional - solo para usuarios externos)</label>
<select id="tenant" bind:value={formData.tenant_id} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2">
<option value="">-- Ninguno (Usuario Interno) --</option>
{#each tenants as tenant}
<option value={tenant.id}>{tenant.name}</option>
{/each}
</select>
</div>
<div class="flex items-center">
<input type="checkbox" id="is_active" bind:checked={formData.is_active} class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<label for="is_active" class="ml-2 block text-sm text-gray-900">Activo</label>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm">
Guardar
</button>
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
Cancelar
</button>
</div>
</form>
</Modal>

View File

@@ -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;

View File

@@ -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: [],
}

View File

@@ -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'
}
});

104
scripts/README.md Normal file
View File

@@ -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).

165
scripts/setup-dev.sh Normal file
View File

@@ -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 "$@"

279
workers/README.md Normal file
View File

@@ -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", "<h1>Test</h1>"]'
```
## 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
```

129
workers/app/celery.py Normal file
View File

@@ -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()

117
workers/app/core/config.py Normal file
View File

@@ -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()

122
workers/app/core/logging.py Normal file
View File

@@ -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)

View File

@@ -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": """
<h2>Nuevo ticket creado</h2>
<p>Hola {{ user_name }},</p>
<p>Se ha creado un nuevo ticket:</p>
<ul>
<li><strong>Número:</strong> #{{ ticket_number }}</li>
<li><strong>Asunto:</strong> {{ subject }}</li>
<li><strong>Prioridad:</strong> {{ priority }}</li>
</ul>
<p><a href="{{ ticket_url }}">Ver ticket</a></p>
<p>Saludos,<br>Equipo de Soporte</p>
""",
"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": """
<h2>Ticket asignado</h2>
<p>Hola {{ agent_name }},</p>
<p>Se te ha asignado el ticket:</p>
<ul>
<li><strong>Número:</strong> #{{ ticket_number }}</li>
<li><strong>Asunto:</strong> {{ subject }}</li>
<li><strong>Cliente:</strong> {{ customer_name }}</li>
<li><strong>Prioridad:</strong> {{ priority }}</li>
</ul>
<p><a href="{{ ticket_url }}">Ver ticket</a></p>
""",
"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
}

Some files were not shown because too many files have changed in this diff Show More