Compare commits

..

16 Commits

Author SHA1 Message Date
6687802f19 fix: ADMIN puede ver tickets de cualquier tenant, fixes seguridad tickets.py, logout interno 2026-03-19 08:55:08 -06:00
b76fcb1390 Permisos tickets 2026-03-18 13:53:33 -06:00
80d41c9487 Permisos en progreso 2026-03-17 13:49:23 -06:00
b67a384923 Asuntos Internal Implementado 2026-03-17 11:48:15 -06:00
d691b71c44 Bckn funcion 2026-03-17 11:09:30 -06:00
5e58d53416 Implementacion modal ticket 1 2026-03-11 13:48:22 -06:00
1a301409de usuarios en cliente 2026-03-11 11:36:11 -06:00
12cbbd1b80 Adm global 2026-03-11 09:26:36 -06:00
3f7b166767 Sistema limpio 2026-03-10 13:46:30 -06:00
6bc5145b9c fix: tenant dinamico desde BD - sin hardcodeo en frontend cliente 2026-03-10 13:45:23 -06:00
597286fff0 fix: corrige tenant_slug hardcodeado en login de ambos frontends 2026-03-10 13:26:52 -06:00
e141701567 limpieza de scipts 2026-03-09 13:47:20 -06:00
7003e5cd80 limpieza de scipts 2026-03-09 13:40:28 -06:00
d8232fda7c tenant arreglado 2026-03-05 14:04:04 -07:00
16b3dc5d47 Sesiones correctas 2026-03-04 13:31:38 -07:00
3b46f48655 Roles 2026-03-03 14:02:18 -07:00
67 changed files with 5038 additions and 1325 deletions

View File

@@ -0,0 +1,48 @@
import asyncio
from app.core.database import AsyncSessionLocal, Base, engine
from app.models.ticket import Ticket
from sqlalchemy import String, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import GUID
import uuid
# Agregar modelo al archivo ticket.py
new_model = '''
class TicketParticipant(Base):
"""Participantes asignados a un ticket"""
__tablename__ = "ticket_participants"
ticket_id: Mapped[uuid.UUID] = mapped_column(
GUID(),
ForeignKey("tickets.id", ondelete="CASCADE"),
nullable=False
)
user_id: Mapped[uuid.UUID] = mapped_column(
GUID(),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False
)
role: Mapped[str] = mapped_column(String(50), nullable=False, default="participant")
ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="participants")
user: Mapped["User"] = relationship("User")
__table_args__ = (
UniqueConstraint("ticket_id", "user_id", name="uq_ticket_participant"),
)
def __repr__(self):
return f"<TicketParticipant(ticket={self.ticket_id}, user={self.user_id})>"
'''
with open("/app/app/models/ticket.py", "r") as f:
content = f.read()
if "TicketParticipant" in content:
print("Ya existe TicketParticipant")
else:
content += new_model
with open("/app/app/models/ticket.py", "w") as f:
f.write(content)
print("OK: TicketParticipant agregado")

21
backend/add_relation.py Normal file
View File

@@ -0,0 +1,21 @@
with open("/app/app/models/ticket.py", "r") as f:
content = f.read()
old = ' def __repr__(self) -> str:\n return f"<Ticket(id={self.id}, number=\'{self.ticket_number}\', status={self.status})>"'
new = ''' participants: Mapped[list["TicketParticipant"]] = relationship(
"TicketParticipant",
back_populates="ticket",
cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<Ticket(id={self.id}, number=\'{self.ticket_number}\', status={self.status})>"'''
if old in content:
content = content.replace(old, new)
print("OK: relacion participants agregada")
else:
print("ERROR: bloque no encontrado")
with open("/app/app/models/ticket.py", "w") as f:
f.write(content)

View File

@@ -89,9 +89,10 @@ async def get_current_user(
raise HTTPException(status_code=400, detail="Inactive user")
# Enforce that tenant header (if present) matches the authenticated user's tenant.
# Prevents cross-tenant header impersonation.
# Roles globales (is_global) pueden operar en cualquier tenant → omitir chequeo.
# Roles de cliente (is_client) deben coincidir con su propio tenant.
request_tenant_id = getattr(getattr(request, "state", None), "tenant_id", None)
if request_tenant_id and str(user.tenant_id) != str(request_tenant_id):
if request_tenant_id and not user.role.is_global and str(user.tenant_id) != str(request_tenant_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Tenant header does not match authenticated user",

View File

@@ -1,7 +1,7 @@
"""
Auth Schemas - ServiceManagerWeb
Pydantic schemas para autenticación y autorización.
Pydantic schemas para autenticación y autorización.
"""
from pydantic import BaseModel, EmailStr
@@ -12,7 +12,7 @@ class LoginRequest(BaseModel):
"""Schema para solicitud de login."""
email: EmailStr
password: str
tenant_slug: str
tenant_slug: Optional[str] = None
totp_code: Optional[str] = None
@@ -53,28 +53,28 @@ class TwoFactorSetupResponse(BaseModel):
class TwoFactorEnableRequest(BaseModel):
"""Código TOTP para confirmar y activar 2FA."""
"""Código TOTP para confirmar y activar 2FA."""
totp_code: str
class TwoFactorEnableResponse(BaseModel):
"""Resultado al habilitar 2FA: incluye los códigos de respaldo."""
"""Resultado al habilitar 2FA: incluye los códigos de respaldo."""
enabled: bool
backup_codes: List[str]
class TwoFactorDisableRequest(BaseModel):
"""Deshabilitar 2FA verificando con TOTP o código de respaldo."""
"""Deshabilitar 2FA verificando con TOTP o código de respaldo."""
totp_code: Optional[str] = None
backup_code: Optional[str] = None
# ============================================================
# Cambio de contraseña
# Cambio de contraseña
# ============================================================
class ChangePasswordRequest(BaseModel):
"""Schema para cambio de contraseña del usuario autenticado."""
"""Schema para cambio de contraseña del usuario autenticado."""
current_password: str
new_password: str
@@ -82,15 +82,15 @@ class ChangePasswordRequest(BaseModel):
# ============================================================
# Recuperación de contraseña
# Recuperación de contraseña
# ============================================================
class ForgotPasswordRequest(BaseModel):
"""Solicitar enlace de reseteo de contraseña por email."""
"""Solicitar enlace de reseteo de contraseña por email."""
email: EmailStr
class ResetPasswordRequest(BaseModel):
"""Aplicar nueva contraseña usando token de reseteo."""
"""Aplicar nueva contraseña usando token de reseteo."""
token: str
new_password: str

View File

@@ -11,13 +11,14 @@ import uuid
class CategoryCreate(BaseModel):
"""Schema para crear categoría. No incluye tenant_id (se asigna automáticamente)."""
"""Schema para crear categoría. tenant_id opcional para ADMIN global."""
name: str
description: Optional[str] = None
color: Optional[str] = None
sla_response_hours: int = 24
sla_resolution_hours: int = 72
auto_assign_to: Optional[uuid.UUID] = None
tenant_id: Optional[uuid.UUID] = None
class CategoryUpdate(BaseModel):

View File

@@ -0,0 +1,53 @@
"""Schemas para TicketIssue"""
from pydantic import BaseModel, field_validator
from typing import Optional, List
from datetime import datetime
import uuid
class TaggedUserBasic(BaseModel):
id: uuid.UUID
full_name: str
email: str
class Config:
from_attributes = True
class IssueCreate(BaseModel):
content: str
priority: str = "MEDIUM"
tagged_user_ids: List[uuid.UUID] = []
@field_validator("priority")
@classmethod
def validate_priority(cls, v: str) -> str:
valid = {"LOW", "MEDIUM", "HIGH", "URGENT"}
if v.upper() not in valid:
raise ValueError(f"priority debe ser uno de: {valid}")
return v.upper()
@field_validator("content")
@classmethod
def validate_content(cls, v: str) -> str:
if not v.strip():
raise ValueError("content no puede estar vacío")
return v.strip()
class IssueResponse(BaseModel):
id: uuid.UUID
ticket_id: uuid.UUID
tenant_id: uuid.UUID
content: str
priority: str
created_by: uuid.UUID
created_by_name: str
tagged_users: List[TaggedUserBasic] = []
attachment_filename: Optional[str] = None
attachment_mime_type: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

View File

@@ -1,7 +1,7 @@
"""
Authentication Endpoints - ServiceManagerWeb
Endpoints para autenticación y autorización
Endpoints para autenticación y autorización
"""
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
@@ -28,7 +28,7 @@ CLIENT_ROLES = {"CLIENT_ADMIN", "CLIENT_USER"}
def _cookie_name_for_role(role: str) -> str:
"""Devuelve el nombre de cookie según el rol del usuario."""
"""Devuelve el nombre de cookie según el rol del usuario."""
return "client_access_token" if role in CLIENT_ROLES else "internal_access_token"
from app.api.schemas.auth import (
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
@@ -88,25 +88,23 @@ async def login(
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
)
# 1. Validar tenant
tenant_result = await db.execute(
select(Tenant).where(Tenant.slug == login_data.tenant_slug)
)
tenant = tenant_result.scalar_one_or_none()
if tenant is None:
logger.warning(
"Login failed - tenant not found",
email=login_data.email,
tenant_slug=login_data.tenant_slug,
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found",
# 1. Validar tenant - por slug si viene, sino buscar por email
if login_data.tenant_slug:
tenant_result = await db.execute(
select(Tenant).where(Tenant.slug == login_data.tenant_slug)
)
tenant = tenant_result.scalar_one_or_none()
if tenant is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found",
)
else:
tenant = None
# Rate limiting (best-effort): by (tenant,email) to slow brute force.
ident_key = None
if settings.RATE_LIMIT_ENABLED and not settings.TESTING:
if settings.RATE_LIMIT_ENABLED and not settings.TESTING and tenant:
email_norm = login_data.email.strip().lower()
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)
ident_count = await cache.incr(ident_key, 1)
@@ -142,22 +140,25 @@ async def login(
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
)
# 2. Buscar usuario en base de datos (aislado por tenant)
query = select(User).where(
User.email == login_data.email,
User.tenant_id == tenant.id,
)
# 2. Buscar usuario - filtrar por tenant si se detecto, sino buscar por email
if tenant:
query = select(User).where(
User.email == login_data.email,
User.tenant_id == tenant.id,
)
else:
query = select(User).where(User.email == login_data.email)
result = await db.execute(query)
user = result.scalar_one_or_none()
# 3. Verificar usuario y contraseña
# 3. Verificar usuario y contraseña
if not user or not security.verify_password(login_data.password, user.password_hash):
logger.warning(
"Login failed - invalid credentials",
email=login_data.email
)
# Registrar intento fallido en auditoría (si el usuario existe)
# Registrar intento fallido en auditoría (si el usuario existe)
if user:
try:
await AuditService.log(
@@ -178,7 +179,7 @@ async def login(
detail="Invalid credentials",
)
# 4. Verificar si está activo
# 4. Verificar si está activo
if not user.is_active:
logger.warning(
"Login failed - user inactive",
@@ -189,19 +190,19 @@ async def login(
detail="User inactive",
)
# 5. Verificar 2FA si está habilitado
# 5. Verificar 2FA si está habilitado
if user.totp_enabled:
if not login_data.totp_code:
# Indicar al frontend que debe pedir el código TOTP
# Indicar al frontend que debe pedir el código TOTP
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Se requiere autenticación de dos factores (2FA). Ingresa tu código."
detail="Se requiere autenticación de dos factores (2FA). Ingresa tu código."
)
if not security.verify_totp(user.totp_secret, login_data.totp_code):
logger.warning("Login failed - invalid 2FA code", email=login_data.email)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Código 2FA inválido o expirado"
detail="Código 2FA inválido o expirado"
)
# Create tokens
@@ -233,7 +234,7 @@ async def login(
detail="Service temporarily unavailable",
)
# Registrar login exitoso en auditoría
# Registrar login exitoso en auditoría
try:
await AuditService.log(
db=db,
@@ -283,6 +284,7 @@ async def login(
"last_name": user.last_name,
"role": user.role,
"tenant_id": str(user.tenant_id),
"tenant_slug": tenant.slug if tenant else str(user.tenant_id),
"is_active": user.is_active,
"is_two_factor_enabled": user.totp_enabled or False,
"created_at": user.created_at.isoformat() if user.created_at else None
@@ -392,7 +394,7 @@ async def logout(
except Exception as e:
logger.warning("Failed to revoke refresh tokens on logout", error=str(e))
# Registrar logout en auditoría
# Registrar logout en auditoría
try:
import uuid
user_id = uuid.UUID(payload["sub"])
@@ -413,7 +415,7 @@ async def logout(
logger.info("Logout successful", user_id=payload["sub"])
# Borrar la cookie correcta según el rol del usuario
# Borrar la cookie correcta según el rol del usuario
cookie_name = _cookie_name_for_role(payload.get("role", ""))
response.delete_cookie(key=cookie_name)
return {"message": "Successfully logged out"}
@@ -479,6 +481,7 @@ async def get_current_user(
"role": user.role.value if hasattr(user.role, 'value') else user.role,
"tenant_id": str(user.tenant_id),
"tenant_name": user.tenant.name if user.tenant else None,
"tenant_slug": user.tenant.slug if user.tenant else None,
"is_active": user.is_active,
"is_two_factor_enabled": user.totp_secret is not None,
"last_login": user.last_login.isoformat() if user.last_login else None,
@@ -502,7 +505,7 @@ async def get_2fa_status(
current_user: User = Depends(get_current_user),
):
"""
Consultar si el 2FA está habilitado para el usuario actual.
Consultar si el 2FA está habilitado para el usuario actual.
Returns:
Estado de 2FA del usuario autenticado.
@@ -516,10 +519,10 @@ async def setup_2fa(
db: AsyncSession = Depends(get_db),
):
"""
Iniciar configuración de 2FA: genera un nuevo TOTP secret y QR URI.
Iniciar configuración de 2FA: genera un nuevo TOTP secret y QR URI.
El secret se guarda en BD pero 2FA NO se activa todavía.
Se necesita llamar a /2fa/enable con un código válido para activarlo.
El secret se guarda en BD pero 2FA NO se activa todavía.
Se necesita llamar a /2fa/enable con un código válido para activarlo.
Returns:
Secret y QR URI para escanear con la app autenticadora.
@@ -527,7 +530,7 @@ async def setup_2fa(
new_secret = security.generate_totp_secret()
qr_uri = security.generate_totp_uri(new_secret, current_user.email)
# Guardar el secret (sin habilitar aún)
# Guardar el secret (sin habilitar aún)
current_user.totp_secret = new_secret
await db.commit()
@@ -543,29 +546,29 @@ async def enable_2fa(
db: AsyncSession = Depends(get_db),
):
"""
Activar 2FA verificando que el usuario escaneó correctamente el QR.
Activar 2FA verificando que el usuario escaneó correctamente el QR.
Requiere que /2fa/setup haya sido llamado previamente.
Args:
data: Código TOTP generado por la app autenticadora.
data: Código TOTP generado por la app autenticadora.
Returns:
Confirmación y lista de códigos de respaldo.
Confirmación y lista de códigos de respaldo.
"""
if not current_user.totp_secret:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Primero inicia el proceso de configuración con /2fa/setup"
detail="Primero inicia el proceso de configuración con /2fa/setup"
)
if not security.verify_totp(current_user.totp_secret, data.totp_code):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Código TOTP inválido. Verifica la hora de tu dispositivo e intenta de nuevo."
detail="Código TOTP inválido. Verifica la hora de tu dispositivo e intenta de nuevo."
)
# Activar 2FA y generar códigos de respaldo
# Activar 2FA y generar códigos de respaldo
backup_codes = security.generate_backup_codes()
current_user.totp_enabled = True
current_user.backup_codes = backup_codes
@@ -593,21 +596,21 @@ async def disable_2fa(
db: AsyncSession = Depends(get_db),
):
"""
Deshabilitar 2FA verificando con código TOTP o código de respaldo.
Deshabilitar 2FA verificando con código TOTP o código de respaldo.
Args:
data: totp_code o backup_code para verificar identidad.
Returns:
Mensaje de confirmación.
Mensaje de confirmación.
"""
if not current_user.totp_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="El 2FA no está habilitado en esta cuenta"
detail="El 2FA no está habilitado en esta cuenta"
)
# Verificar con TOTP o código de respaldo
# Verificar con TOTP o código de respaldo
verified = False
if data.totp_code:
@@ -615,7 +618,7 @@ async def disable_2fa(
elif data.backup_code and current_user.backup_codes:
if data.backup_code in current_user.backup_codes:
verified = True
# Invalidar el código de respaldo usado
# Invalidar el código de respaldo usado
current_user.backup_codes = [
c for c in current_user.backup_codes if c != data.backup_code
]
@@ -623,7 +626,7 @@ async def disable_2fa(
if not verified:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Verificación fallida. Proporciona un código TOTP o un código de respaldo válido."
detail="Verificación fallida. Proporciona un código TOTP o un código de respaldo válido."
)
# Deshabilitar 2FA
@@ -644,7 +647,7 @@ async def disable_2fa(
logger.info("2FA disabled", user_id=str(current_user.id))
return {"message": "Autenticación de dos factores deshabilitada correctamente"}
return {"message": "Autenticación de dos factores deshabilitada correctamente"}
@router.post("/change-password", status_code=status.HTTP_200_OK)
@@ -654,32 +657,32 @@ async def change_password(
db: AsyncSession = Depends(get_db),
):
"""
Cambiar la contraseña del usuario autenticado.
Cambiar la contraseña del usuario autenticado.
Verifica la contraseña actual antes de actualizar.
Requiere autenticación activa.
Verifica la contraseña actual antes de actualizar.
Requiere autenticación activa.
"""
from datetime import datetime
# Validar longitud mínima
# Validar longitud mínima
if len(data.new_password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="La nueva contraseña debe tener al menos 8 caracteres"
detail="La nueva contraseña debe tener al menos 8 caracteres"
)
# Verificar que la contraseña actual sea correcta
# Verificar que la contraseña actual sea correcta
if not security.verify_password(data.current_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="La contraseña actual es incorrecta"
detail="La contraseña actual es incorrecta"
)
# No permitir que la nueva sea igual a la actual
if security.verify_password(data.new_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="La nueva contraseña no puede ser igual a la actual"
detail="La nueva contraseña no puede ser igual a la actual"
)
current_user.password_hash = security.hash_password(data.new_password)
@@ -697,11 +700,11 @@ async def change_password(
await db.commit()
logger.info("Password changed", user_id=str(current_user.id))
return {"message": "Contraseña actualizada correctamente"}
return {"message": "Contraseña actualizada correctamente"}
# ============================================================
# Recuperación de contraseña (forgot / reset)
# Recuperación de contraseña (forgot / reset)
# ============================================================
_RESET_TOKEN_TTL = 1800 # 30 minutos en segundos
@@ -716,10 +719,10 @@ async def forgot_password(
db: AsyncSession = Depends(get_db),
):
"""
Solicitar reseteo de contraseña.
Solicitar reseteo de contraseña.
Siempre retorna 200 aunque el email no exista, para no revelar
si una dirección está registrada en el sistema.
si una dirección está registrada en el sistema.
"""
import secrets
from redis.asyncio import from_url as redis_from_url
@@ -735,9 +738,9 @@ async def forgot_password(
user = result.scalar_one_or_none()
if not user:
# Respuesta idéntica no revelar existencia
# Respuesta idéntica — no revelar existencia
logger.info("Forgot password: email not found", email=data.email)
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
# Generar token seguro
token = secrets.token_urlsafe(32)
@@ -757,7 +760,7 @@ async def forgot_password(
await send_email(
to_email=user.email,
subject="Restablece tu contraseña — ServiceManager",
subject="Restablece tu contraseña — ServiceManager",
html_content=html,
text_content=text,
)
@@ -774,7 +777,7 @@ async def forgot_password(
await db.commit()
logger.info("Password reset email sent", user_id=str(user.id))
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
@router.post("/reset-password", status_code=status.HTTP_200_OK)
@@ -785,7 +788,7 @@ async def reset_password(
db: AsyncSession = Depends(get_db),
):
"""
Aplicar nueva contraseña usando el token recibido por email.
Aplicar nueva contraseña usando el token recibido por email.
El token es de un solo uso: se elimina de Redis al usarse.
"""
@@ -796,7 +799,7 @@ async def reset_password(
if len(data.new_password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="La contraseña debe tener al menos 8 caracteres"
detail="La contraseña debe tener al menos 8 caracteres"
)
redis_key = f"{_RESET_KEY_PREFIX}{data.token}"
@@ -807,7 +810,7 @@ async def reset_password(
if not user_id_str:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="El enlace de reseteo es inválido o ya expiró. Solicita uno nuevo."
detail="El enlace de reseteo es inválido o ya expiró. Solicita uno nuevo."
)
# Eliminar token inmediatamente (un solo uso)
@@ -838,4 +841,4 @@ async def reset_password(
await db.commit()
logger.info("Password reset completed", user_id=str(user.id))
return {"message": "Contraseña actualizada correctamente. Ya puedes iniciar sesión."}
return {"message": "Contraseña actualizada correctamente. Ya puedes iniciar sesión."}

View File

@@ -79,11 +79,16 @@ async def create_category(
✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
"""
# ✅ CORREGIDO: Asignar tenant_id del usuario actual
db_category = Category(
**category.model_dump(),
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
)
# ADMIN global puede especificar tenant_id; otros usan el suyo
from app.models.user import UserRole as _R
data = category.model_dump()
if current_user.role == _R.ADMIN and data.get("tenant_id"):
target_tenant_id = data["tenant_id"]
else:
target_tenant_id = current_user.tenant_id
data["tenant_id"] = target_tenant_id
db_category = Category(**data)
db.add(db_category)
await db.commit()

View File

@@ -0,0 +1,130 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
import uuid
from app.api import deps
from app.core.database import get_db
from app.models.ticket import Ticket, TicketParticipant
from app.models.user import User
from pydantic import BaseModel
router = APIRouter()
class ParticipantAdd(BaseModel):
user_id: uuid.UUID
class ParticipantResponse(BaseModel):
id: uuid.UUID
user_id: uuid.UUID
ticket_id: uuid.UUID
role: str
user_email: str
user_name: str
class Config:
from_attributes = True
@router.get("/{ticket_id}/participants", response_model=list[ParticipantResponse])
async def list_participants(
ticket_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
):
result = await db.execute(
select(TicketParticipant)
.where(TicketParticipant.ticket_id == ticket_id)
.options(selectinload(TicketParticipant.user))
)
participants = result.scalars().all()
return [
ParticipantResponse(
id=p.id,
user_id=p.user_id,
ticket_id=p.ticket_id,
role=p.role,
user_email=p.user.email,
user_name=f"{p.user.first_name or ''} {p.user.last_name or ''}".strip()
)
for p in participants
]
@router.post("/{ticket_id}/participants", response_model=ParticipantResponse)
async def add_participant(
ticket_id: uuid.UUID,
data: ParticipantAdd,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
):
# Verificar que el ticket existe
ticket_result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
ticket = ticket_result.scalar_one_or_none()
if not ticket:
raise HTTPException(status_code=404, detail="Ticket not found")
# Solo el creador puede agregar participantes
if ticket.created_by != current_user.id and not current_user.role.is_global:
raise HTTPException(status_code=403, detail="Only the ticket creator can add participants")
# Verificar que el usuario existe
user_result = await db.execute(select(User).where(User.id == data.user_id))
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Verificar que no sea duplicado
existing = await db.execute(
select(TicketParticipant).where(
TicketParticipant.ticket_id == ticket_id,
TicketParticipant.user_id == data.user_id
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="User is already a participant")
participant = TicketParticipant(
ticket_id=ticket_id,
user_id=data.user_id,
role="participant"
)
db.add(participant)
await db.commit()
await db.refresh(participant)
return ParticipantResponse(
id=participant.id,
user_id=participant.user_id,
ticket_id=participant.ticket_id,
role=participant.role,
user_email=user.email,
user_name=f"{user.first_name or ''} {user.last_name or ''}".strip()
)
@router.delete("/{ticket_id}/participants/{user_id}", status_code=204)
async def remove_participant(
ticket_id: uuid.UUID,
user_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
):
ticket_result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
ticket = ticket_result.scalar_one_or_none()
if not ticket:
raise HTTPException(status_code=404, detail="Ticket not found")
if ticket.created_by != current_user.id and not current_user.role.is_global:
raise HTTPException(status_code=403, detail="Only the ticket creator can remove participants")
result = await db.execute(
select(TicketParticipant).where(
TicketParticipant.ticket_id == ticket_id,
TicketParticipant.user_id == user_id
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=404, detail="Participant not found")
await db.delete(participant)
await db.commit()

View File

@@ -28,14 +28,36 @@ async def create_tenant(
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser)
):
from app.models.permission import TenantPermission, DEFAULT_PERMISSIONS
from datetime import datetime
# Check existing slug
query = select(Tenant).where(Tenant.slug == tenant.slug)
result = await db.execute(query)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Tenant slug already exists")
db_tenant = Tenant(**tenant.model_dump())
data = tenant.model_dump()
data['slug'] = data['slug'].lower().strip()
db_tenant = Tenant(**data)
db.add(db_tenant)
await db.flush() # genera el id sin hacer commit
# Inicializar permisos por defecto para el nuevo tenant
now = datetime.utcnow()
for role, perms in DEFAULT_PERMISSIONS.items():
for permission, granted in perms.items():
db.add(TenantPermission(
id=uuid.uuid4(),
tenant_id=db_tenant.id,
user_id=None,
role=role,
permission=permission,
granted=granted,
created_at=now,
updated_at=now,
))
await db.commit()
await db.refresh(db_tenant)
return db_tenant

View File

@@ -7,11 +7,13 @@ from sqlalchemy.orm import selectinload
from typing import List, Optional
from datetime import datetime, timedelta
import uuid
from app.models.issue import TicketIssue, ticket_issue_tagged_users
from app.api.schemas.issue import IssueCreate, IssueResponse
from app.core.database import get_db
from app.api.deps import get_current_user, get_current_tenant
from app.models.ticket import Ticket, TicketStatus, TicketPriority
from app.models.user import User
from app.models.user import User, UserRole
from app.models.tenant import Tenant
from app.models.category import Category
from app.models.system import System
@@ -19,7 +21,7 @@ from app.models.comment import TicketComment
from app.models.attachment import TicketAttachment
from app.api.schemas.attachment import AttachmentResponse
from app.api.schemas.ticket import (
TicketCreate, TicketUpdate, TicketResponse,
TicketCreate, TicketUpdate, TicketResponse,
TicketCloseRequest, CommentCreate, CommentResponse
)
from app.core.file_handler import file_handler
@@ -28,100 +30,33 @@ from app.api.v1.helpers import (
safe_audit_log, generate_next_ticket_number, calculate_sla_deadlines, ticket_to_dict
)
from app.services.audit_service import AuditService
from app.services.ticket_service import TicketService, get_ticket_service
router = APIRouter()
@router.post("/", response_model=TicketResponse, status_code=status.HTTP_201_CREATED)
async def create_ticket(ticket: TicketCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
async def create_ticket(
ticket: TicketCreate,
current_user: User = Depends(get_current_user),
ticket_service: TicketService = Depends(get_ticket_service),
):
"""Crear un nuevo ticket"""
max_retries = 3
last_error = None
for attempt in range(max_retries):
try:
ticket_number = await generate_next_ticket_number(db, current_user.tenant_id)
category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None
system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None
category = None
if category_uuid:
category = await db.get(Category, category_uuid)
if not category:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"La categoría con ID {ticket.category_id} no existe.")
# ✅ SECURITY: Validate category belongs to current tenant (prevents cross-tenant category injection)
if category.tenant_id != current_user.tenant_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"La categoría con ID {ticket.category_id} no existe.")
if system_uuid:
system = await db.get(System, system_uuid)
if not system:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"El sistema con ID {ticket.affected_system_id} no existe.")
# ✅ SECURITY: Validate system belongs to current tenant (prevents cross-tenant system injection)
if system.tenant_id != current_user.tenant_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"El sistema con ID {ticket.affected_system_id} no existe.")
sla_response_due, sla_resolution_due = calculate_sla_deadlines(category)
assigned_to_user = category.auto_assign_to if category and category.auto_assign_to else None
db_ticket = Ticket(
id=uuid.uuid4(), tenant_id=current_user.tenant_id, ticket_number=ticket_number,
subject=ticket.subject, description=ticket.description, category_id=category_uuid,
affected_system_id=system_uuid, priority=TicketPriority[ticket.priority.upper()],
created_by=current_user.id, assigned_to=assigned_to_user, status=TicketStatus.NEW,
sla_response_due=sla_response_due, sla_resolution_due=sla_resolution_due,
created_at=datetime.utcnow(), updated_at=datetime.utcnow()
)
db.add(db_ticket)
await db.commit()
await db.refresh(db_ticket)
await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
action="ticket.create", resource_type="ticket", resource_id=db_ticket.id,
new_values={"ticket_number": db_ticket.ticket_number, "subject": db_ticket.subject,
"priority": db_ticket.priority.value, "status": db_ticket.status.value})
return {
"id": str(db_ticket.id), "ticket_number": db_ticket.ticket_number, "subject": db_ticket.subject,
"title": db_ticket.subject, "description": db_ticket.description, "status": db_ticket.status.value,
"priority": db_ticket.priority.value, "category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
"system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
"contact_email": ticket.contact_email,
"contact_phone": ticket.contact_phone,
"created_by": str(db_ticket.created_by), "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
"created_at": db_ticket.created_at, "updated_at": db_ticket.updated_at,
"sla_response_due": db_ticket.sla_response_due,
"sla_resolution_due": db_ticket.sla_resolution_due,
"first_response_at": db_ticket.first_response_at,
"resolved_at": db_ticket.resolved_at,
}
except ValueError as e:
await db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid UUID format: {str(e)}")
except HTTPException:
await db.rollback()
raise
except Exception as e:
await db.rollback()
last_error = e
if "duplicate key" in str(e).lower() and "ticket_number" in str(e).lower():
if attempt < max_retries - 1:
continue
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Error creating ticket: {str(e)}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"No se pudo crear el ticket después de {max_retries} intentos: {str(last_error)}")
return await ticket_service.create_ticket(ticket, current_user.tenant_id, current_user.id)
@router.get("/", response_model=List[TicketResponse])
async def get_tickets(skip: int = 0, limit: int = 100, status: Optional[str] = None, priority: Optional[str] = None,
db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
async def get_tickets(
skip: int = 0, limit: int = 100,
status: Optional[str] = None, priority: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Obtener tickets con filtros opcionales"""
query = select(Ticket).where(Ticket.tenant_id == current_user.tenant_id)
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
if current_user.role == UserRole.CLIENT_USER:
query = query.where(Ticket.created_by == current_user.id)
query = apply_enum_filter(query, Ticket.status, status, TicketStatus, "status")
query = apply_enum_filter(query, Ticket.priority, priority, TicketPriority, "priority")
query = query.options(
@@ -130,26 +65,29 @@ async def get_tickets(skip: int = 0, limit: int = 100, status: Optional[str] = N
selectinload(Ticket.assigned_to_user)
)
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
tickets = result.scalars().all()
return [ticket_to_dict(t) for t in tickets]
@router.get("/admin/all", response_model=List[dict])
async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter: Optional[str] = None,
priority_filter: Optional[str] = None, tenant_id_filter: Optional[str] = None, category_filter: Optional[str] = None,
assigned_to_filter: Optional[str] = None, search: Optional[str] = None, date_from: Optional[str] = None,
date_to: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
async def get_all_tickets_admin(
skip: int = 0, limit: int = 100,
status_filter: Optional[str] = None, priority_filter: Optional[str] = None,
tenant_id_filter: Optional[str] = None, category_filter: Optional[str] = None,
assigned_to_filter: Optional[str] = None, search: Optional[str] = None,
date_from: Optional[str] = None, date_to: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Obtener todos los tickets del tenant del administrador (ADMIN/SUPPORT_MANAGER)."""
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
if current_user.role not in (UserRole.ADMIN, UserRole.SUPPORT_MANAGER):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No tienes permisos para acceder a esta función")
query = select(Ticket, Tenant, User).join(Tenant, Ticket.tenant_id == Tenant.id).join(User, Ticket.created_by == User.id)
# SUPPORT_MANAGER solo ve su propio tenant.
# ADMIN ve todos los tenants (es el administrador de la plataforma).
if current_user.role == "SUPPORT_MANAGER":
if current_user.role == UserRole.SUPPORT_MANAGER:
query = query.where(Ticket.tenant_id == current_user.tenant_id)
query = apply_enum_filter(query, Ticket.status, status_filter, TicketStatus, "status")
@@ -175,12 +113,11 @@ async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter:
query = query.where(Ticket.created_at < date_to_parsed)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid date_to format. Use YYYY-MM-DD")
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
rows = result.all()
# Cargar categorías en un solo query para evitar N+1
category_ids = list({ticket.category_id for ticket, _, _ in rows if ticket.category_id})
categories_map = {}
if category_ids:
@@ -196,44 +133,66 @@ async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter:
"category_name": categories_map.get(ticket.category_id) if ticket.category_id else None,
"created_by": str(ticket.created_by), "creator_name": f"{creator.first_name} {creator.last_name}",
"creator_email": creator.email, "assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
"created_at": ticket.created_at, "updated_at": ticket.updated_at, "sla_response_due": ticket.sla_response_due,
"sla_resolution_due": ticket.sla_resolution_due, "first_response_at": ticket.first_response_at,
"resolved_at": ticket.resolved_at}
"created_at": ticket.created_at, "updated_at": ticket.updated_at,
"sla_response_due": ticket.sla_response_due, "sla_resolution_due": ticket.sla_resolution_due,
"first_response_at": ticket.first_response_at, "resolved_at": ticket.resolved_at}
for ticket, tenant, creator in rows
]
@router.get("/{ticket_id}", response_model=TicketResponse)
async def get_ticket(ticket_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
async def get_ticket(
ticket_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Obtener un ticket por ID"""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
query = select(Ticket).where(Ticket.id == ticket_uuid)
if current_user.role != UserRole.ADMIN:
query = query.where(Ticket.tenant_id == current_user.tenant_id)
if current_user.role.is_client:
query = query.where(Ticket.created_by == current_user.id)
query = query.options(selectinload(Ticket.category), selectinload(Ticket.affected_system), selectinload(Ticket.assigned_to_user))
query = query.options(
selectinload(Ticket.category),
selectinload(Ticket.affected_system),
selectinload(Ticket.assigned_to_user)
)
result = await db.execute(query)
db_ticket = result.scalars().first()
if not db_ticket:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
return ticket_to_dict(db_ticket)
@router.patch("/{ticket_id}", response_model=TicketResponse)
async def update_ticket(ticket_id: str, ticket: TicketUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
async def update_ticket(
ticket_id: str, ticket: TicketUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Actualizar un ticket"""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
query = select(Ticket).where(Ticket.id == ticket_uuid)
if current_user.role != UserRole.ADMIN:
query = query.where(Ticket.tenant_id == current_user.tenant_id)
if current_user.role.is_client:
query = query.where(Ticket.created_by == current_user.id)
result = await db.execute(query)
db_ticket = result.scalars().first()
if not db_ticket:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
old_values = {"status": db_ticket.status.value, "priority": db_ticket.priority.value, "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None}
old_values = {
"status": db_ticket.status.value,
"priority": db_ticket.priority.value,
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None
}
update_data = ticket.dict(exclude_unset=True)
for field, value in update_data.items():
if field == "status" and value:
@@ -244,37 +203,48 @@ async def update_ticket(ticket_id: str, ticket: TicketUpdate, db: AsyncSession =
setattr(db_ticket, field, uuid.UUID(value))
elif value is not None:
setattr(db_ticket, field, value)
db_ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(db_ticket, ["category", "affected_system", "assigned_to_user"])
new_values = {"status": db_ticket.status.value, "priority": db_ticket.priority.value, "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None}
await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
new_values = {
"status": db_ticket.status.value,
"priority": db_ticket.priority.value,
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None
}
await safe_audit_log(
db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
action="ticket.update", resource_type="ticket", resource_id=db_ticket.id,
old_values=old_values, new_values=new_values)
old_values=old_values, new_values=new_values
)
return ticket_to_dict(db_ticket)
@router.patch("/{ticket_id}/close", response_model=TicketResponse)
async def close_ticket(ticket_id: str, close_request: TicketCloseRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
async def close_ticket(
ticket_id: str, close_request: TicketCloseRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Cerrar un ticket"""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
result = await db.execute(query)
db_ticket = result.scalars().first()
if not db_ticket:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
if db_ticket.status in [TicketStatus.CLOSED, TicketStatus.RESOLVED]:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ticket ya está cerrado o resuelto")
old_status = db_ticket.status.value
db_ticket.status = TicketStatus.CLOSED
db_ticket.resolved_at = datetime.utcnow()
db_ticket.updated_at = datetime.utcnow()
if close_request.resolution_notes:
comment = TicketComment(
id=uuid.uuid4(), ticket_id=ticket_uuid, author_id=current_user.id,
@@ -282,203 +252,430 @@ async def close_ticket(ticket_id: str, close_request: TicketCloseRequest, db: As
is_internal=False, created_at=datetime.utcnow(), updated_at=datetime.utcnow()
)
db.add(comment)
await db.commit()
await db.refresh(db_ticket, ["category", "affected_system", "assigned_to_user"])
await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
await safe_audit_log(
db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
action="ticket.close", resource_type="ticket", resource_id=db_ticket.id,
old_values={"status": old_status}, new_values={"status": db_ticket.status.value, "resolution_notes": close_request.resolution_notes})
old_values={"status": old_status},
new_values={"status": db_ticket.status.value, "resolution_notes": close_request.resolution_notes}
)
return ticket_to_dict(db_ticket)
@router.get("/{ticket_id}/comments", response_model=List[CommentResponse])
async def get_ticket_comments(ticket_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
async def get_ticket_comments(
ticket_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Obtener comentarios de un ticket"""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
query = select(Ticket).where(Ticket.id == ticket_uuid)
if current_user.role != UserRole.ADMIN:
query = query.where(Ticket.tenant_id == current_user.tenant_id)
if current_user.role.is_client:
query = query.where(Ticket.created_by == current_user.id)
result = await db.execute(query)
ticket_obj = result.scalars().first()
if not ticket_obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
comments_query = select(TicketComment).where(TicketComment.ticket_id == ticket_uuid).options(selectinload(TicketComment.author)).order_by(TicketComment.created_at.desc())
comments_query = (
select(TicketComment)
.where(TicketComment.ticket_id == ticket_uuid)
.options(selectinload(TicketComment.author))
.order_by(TicketComment.created_at.desc())
)
result = await db.execute(comments_query)
comments = result.scalars().all()
return [
{"id": str(c.id), "ticket_id": str(c.ticket_id), "author_id": str(c.author_id),
"author_name": f"{c.author.first_name} {c.author.last_name}" if c.author else "Unknown",
"content": c.content, "is_internal": c.is_internal, "created_at": c.created_at, "updated_at": c.updated_at}
"content": c.content, "is_internal": c.is_internal,
"created_at": c.created_at, "updated_at": c.updated_at}
for c in comments
]
@router.post("/{ticket_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
async def create_comment(ticket_id: str, comment: CommentCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
async def create_comment(
ticket_id: str, comment: CommentCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Crear un comentario en un ticket"""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
if current_user.role.is_client:
query = query.where(Ticket.created_by == current_user.id)
result = await db.execute(query)
ticket_obj = result.scalars().first()
if not ticket_obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
# Fix is_internal — clientes no pueden crear comentarios internos
if comment.is_internal and current_user.role.is_client:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Clientes no pueden crear comentarios internos"
)
new_comment = TicketComment(
id=uuid.uuid4(), ticket_id=ticket_uuid, author_id=current_user.id,
content=comment.content, is_internal=comment.is_internal,
created_at=datetime.utcnow(), updated_at=datetime.utcnow()
)
db.add(new_comment)
staff_roles = ["ADMIN", "SUPPORT_MANAGER", "AGENT"]
if current_user.role in staff_roles and not comment.is_internal and ticket_obj.first_response_at is None:
ticket_obj.first_response_at = datetime.utcnow()
ticket_obj.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(new_comment)
return {
"id": str(new_comment.id), "ticket_id": str(new_comment.ticket_id), "author_id": str(new_comment.author_id),
"id": str(new_comment.id), "ticket_id": str(new_comment.ticket_id),
"author_id": str(new_comment.author_id),
"author_name": f"{current_user.first_name} {current_user.last_name}",
"content": new_comment.content, "is_internal": new_comment.is_internal,
"created_at": new_comment.created_at, "updated_at": new_comment.updated_at
}
@router.delete("/{ticket_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_ticket(ticket_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
async def delete_ticket(
ticket_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Eliminar un ticket (solo admin/manager)"""
if current_user.role not in (UserRole.ADMIN, UserRole.SUPPORT_MANAGER):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No tienes permisos para eliminar tickets"
)
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
result = await db.execute(query)
db_ticket = result.scalars().first()
if not db_ticket:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
old_values = {"ticket_number": db_ticket.ticket_number, "subject": db_ticket.subject,
"status": db_ticket.status.value, "priority": db_ticket.priority.value}
old_values = {
"ticket_number": db_ticket.ticket_number, "subject": db_ticket.subject,
"status": db_ticket.status.value, "priority": db_ticket.priority.value
}
await db.delete(db_ticket)
await db.commit()
await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
action="ticket.delete", resource_type="ticket", resource_id=ticket_uuid, old_values=old_values)
await safe_audit_log(
db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
action="ticket.delete", resource_type="ticket", resource_id=ticket_uuid,
old_values=old_values
)
return {"message": "Ticket deleted successfully"}
@router.get("/{ticket_id}/attachments", response_model=List[AttachmentResponse])
async def get_ticket_attachments(ticket_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), current_tenant: Tenant = Depends(get_current_tenant)):
async def get_ticket_attachments(
ticket_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
current_tenant: Tenant = Depends(get_current_tenant)
):
"""Obtener adjuntos de un ticket"""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
result = await db.execute(select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id))
query = select(Ticket).where(Ticket.id == ticket_uuid)
if current_user.role != UserRole.ADMIN:
query = query.where(Ticket.tenant_id == current_tenant.id)
result = await db.execute(query)
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(status_code=404, detail="Ticket no encontrado")
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id:
if current_user.role.is_client and ticket.created_by != current_user.id:
raise HTTPException(status_code=404, detail="Ticket no encontrado")
result = await db.execute(select(TicketAttachment).where(TicketAttachment.ticket_id == ticket_uuid).options(selectinload(TicketAttachment.uploaded_by_user)).order_by(TicketAttachment.created_at.desc()))
result = await db.execute(
select(TicketAttachment)
.where(TicketAttachment.ticket_id == ticket_uuid)
.options(selectinload(TicketAttachment.uploaded_by_user))
.order_by(TicketAttachment.created_at.desc())
)
attachments = result.scalars().all()
return [
AttachmentResponse(
id=att.id, ticket_id=att.ticket_id, comment_id=att.comment_id, uploaded_by=att.uploaded_by,
filename=att.filename, original_filename=att.original_filename, mime_type=att.mime_type,
id=att.id, ticket_id=att.ticket_id, comment_id=att.comment_id,
uploaded_by=att.uploaded_by, filename=att.filename,
original_filename=att.original_filename, mime_type=att.mime_type,
file_size=att.file_size, file_path=att.file_path,
uploaded_by_name=f"{att.uploaded_by_user.first_name} {att.uploaded_by_user.last_name}" if att.uploaded_by_user else "Unknown",
created_at=att.created_at, download_url=f"/api/v1/tickets/{ticket_id}/attachments/{att.id}/download"
created_at=att.created_at,
download_url=f"/api/v1/tickets/{ticket_id}/attachments/{att.id}/download"
) for att in attachments
]
@router.post("/{ticket_id}/attachments", status_code=status.HTTP_201_CREATED)
async def upload_attachment(ticket_id: str, file: UploadFile = File(...), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_tenant: Tenant = Depends(get_current_tenant)):
async def upload_attachment(
ticket_id: str, file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
current_tenant: Tenant = Depends(get_current_tenant)
):
"""Subir un archivo adjunto a un ticket"""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
result = await db.execute(select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id))
query = select(Ticket).where(Ticket.id == ticket_uuid)
if current_user.role != UserRole.ADMIN:
query = query.where(Ticket.tenant_id == current_tenant.id)
result = await db.execute(query)
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(status_code=404, detail="Ticket no encontrado")
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id:
if current_user.role.is_client and ticket.created_by != current_user.id:
raise HTTPException(status_code=404, detail="Ticket no encontrado")
file_metadata = await file_handler.save_upload(file, current_tenant.id, ticket_uuid)
# Para el file_handler usamos el tenant real del ticket
upload_tenant_id = ticket.tenant_id
file_metadata = await file_handler.save_upload(file, upload_tenant_id, ticket_uuid)
attachment = TicketAttachment(
id=uuid.uuid4(), ticket_id=ticket_uuid, uploaded_by=current_user.id, filename=file_metadata["filename"],
original_filename=file_metadata["original_filename"], mime_type=file_metadata["mime_type"],
file_size=file_metadata["file_size"], file_path=file_metadata["file_path"],
md5_hash=file_metadata["md5_hash"], sha256_hash=file_metadata["sha256_hash"], created_at=datetime.utcnow()
id=uuid.uuid4(), ticket_id=ticket_uuid, uploaded_by=current_user.id,
filename=file_metadata["filename"], original_filename=file_metadata["original_filename"],
mime_type=file_metadata["mime_type"], file_size=file_metadata["file_size"],
file_path=file_metadata["file_path"], md5_hash=file_metadata["md5_hash"],
sha256_hash=file_metadata["sha256_hash"], created_at=datetime.utcnow()
)
db.add(attachment)
await db.commit()
await db.refresh(attachment, ["uploaded_by_user"])
return {
"success": True, "message": "Archivo subido exitosamente",
"data": AttachmentResponse(
id=attachment.id, ticket_id=attachment.ticket_id, comment_id=attachment.comment_id,
uploaded_by=attachment.uploaded_by, filename=attachment.filename, original_filename=attachment.original_filename,
mime_type=attachment.mime_type, file_size=attachment.file_size, file_path=attachment.file_path,
uploaded_by=attachment.uploaded_by, filename=attachment.filename,
original_filename=attachment.original_filename, mime_type=attachment.mime_type,
file_size=attachment.file_size, file_path=attachment.file_path,
uploaded_by_name=f"{attachment.uploaded_by_user.first_name} {attachment.uploaded_by_user.last_name}",
created_at=attachment.created_at, download_url=f"/api/v1/tickets/{ticket_id}/attachments/{attachment.id}/download"
created_at=attachment.created_at,
download_url=f"/api/v1/tickets/{ticket_id}/attachments/{attachment.id}/download"
)
}
@router.get("/{ticket_id}/attachments/{attachment_id}/download")
async def download_attachment(ticket_id: str, attachment_id: str, db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_tenant: Tenant = Depends(get_current_tenant)):
async def download_attachment(
ticket_id: str, attachment_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
current_tenant: Tenant = Depends(get_current_tenant)
):
"""Descargar un archivo adjunto"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"Download request - ticket_id: {ticket_id}, attachment_id: {attachment_id}")
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
attachment_uuid = validate_uuid_param(attachment_id, "attachment ID")
result = await db.execute(select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id))
query = select(Ticket).where(Ticket.id == ticket_uuid)
if current_user.role != UserRole.ADMIN:
query = query.where(Ticket.tenant_id == current_tenant.id)
result = await db.execute(query)
ticket = result.scalar_one_or_none()
if not ticket:
logger.error(f"Ticket not found - ticket_id: {ticket_id}")
raise HTTPException(status_code=404, detail="Ticket no encontrado")
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id:
if current_user.role.is_client and ticket.created_by != current_user.id:
raise HTTPException(status_code=404, detail="Ticket no encontrado")
result = await db.execute(select(TicketAttachment).where(TicketAttachment.id == attachment_uuid, TicketAttachment.ticket_id == ticket_uuid))
result = await db.execute(
select(TicketAttachment).where(
TicketAttachment.id == attachment_uuid,
TicketAttachment.ticket_id == ticket_uuid
)
)
attachment = result.scalar_one_or_none()
if not attachment:
logger.error(f"Attachment not found - attachment_id: {attachment_id}")
raise HTTPException(status_code=404, detail="Adjunto no encontrado")
logger.info(f"Attachment found - file_path: {attachment.file_path}, original_filename: {attachment.original_filename}")
try:
file_path = file_handler.get_file_path(attachment.file_path)
logger.info(f"Absolute file path: {file_path}")
if not file_path.exists():
logger.error(f"File does not exist at path: {file_path}")
raise HTTPException(status_code=404, detail="Archivo no encontrado en el sistema")
except Exception as e:
logger.error(f"Error getting file path: {str(e)}")
raise
logger.info(f"Returning file: {attachment.original_filename}")
return FileResponse(path=file_path, filename=attachment.original_filename, media_type=attachment.mime_type)
return FileResponse(
path=file_path,
filename=attachment.original_filename,
media_type=attachment.mime_type
)
@router.get("/{ticket_id}/issues", response_model=List[IssueResponse])
async def get_ticket_issues(
ticket_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Obtener asuntos de un ticket."""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
query = select(Ticket).where(Ticket.id == ticket_uuid)
if current_user.role != UserRole.ADMIN:
query = query.where(Ticket.tenant_id == current_user.tenant_id)
if current_user.role.is_client:
query = query.where(Ticket.created_by == current_user.id)
result = await db.execute(query)
ticket_obj = result.scalars().first()
if not ticket_obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ticket no encontrado")
issues_query = (
select(TicketIssue)
.where(TicketIssue.ticket_id == ticket_uuid)
.options(
selectinload(TicketIssue.created_by_user),
selectinload(TicketIssue.tagged_users),
)
.order_by(TicketIssue.created_at.desc())
)
result = await db.execute(issues_query)
issues = result.scalars().all()
return [
IssueResponse(
id=issue.id, ticket_id=issue.ticket_id, tenant_id=issue.tenant_id,
content=issue.content, priority=issue.priority.value,
created_by=issue.created_by,
created_by_name=f"{issue.created_by_user.first_name} {issue.created_by_user.last_name}",
tagged_users=[
{"id": u.id, "full_name": f"{u.first_name} {u.last_name}", "email": u.email}
for u in issue.tagged_users
],
attachment_filename=issue.attachment_filename,
attachment_mime_type=issue.attachment_mime_type,
created_at=issue.created_at, updated_at=issue.updated_at,
)
for issue in issues
]
@router.post("/{ticket_id}/issues", response_model=IssueResponse, status_code=status.HTTP_201_CREATED)
async def create_ticket_issue(
ticket_id: str,
issue: IssueCreate,
file: Optional[UploadFile] = File(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
current_tenant: Tenant = Depends(get_current_tenant),
):
"""Crear un asunto de escalación en un ticket."""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
result = await db.execute(
select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id,
)
)
ticket_obj = result.scalars().first()
if not ticket_obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ticket no encontrado")
is_ticket_owner = ticket_obj.created_by == current_user.id
is_client_admin = current_user.role == UserRole.CLIENT_ADMIN
is_staff = current_user.role.is_global
if not is_ticket_owner and not is_client_admin and not is_staff:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo el creador del ticket o el administrador pueden crear asuntos",
)
tagged_users = []
if issue.tagged_user_ids:
result = await db.execute(
select(User).where(
User.id.in_(issue.tagged_user_ids),
User.is_active == True,
)
)
tagged_users = result.scalars().all()
found_ids = {u.id for u in tagged_users}
missing = [str(uid) for uid in issue.tagged_user_ids if uid not in found_ids]
if missing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Usuarios no encontrados: {missing}",
)
attachment_path = None
attachment_filename = None
attachment_mime_type = None
if file and file.filename:
file_metadata = await file_handler.save_upload(file, current_tenant.id, ticket_uuid)
attachment_path = file_metadata["file_path"]
attachment_filename = file_metadata["original_filename"]
attachment_mime_type = file_metadata["mime_type"]
new_issue = TicketIssue(
id=uuid.uuid4(), ticket_id=ticket_uuid,
tenant_id=current_user.tenant_id, created_by=current_user.id,
content=issue.content, priority=TicketPriority[issue.priority],
attachment_path=attachment_path, attachment_filename=attachment_filename,
attachment_mime_type=attachment_mime_type,
created_at=datetime.utcnow(), updated_at=datetime.utcnow(),
)
new_issue.tagged_users = tagged_users
db.add(new_issue)
await db.commit()
await db.refresh(new_issue, ["created_by_user", "tagged_users"])
await safe_audit_log(
db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
action="ticket.issue.create", resource_type="ticket_issue", resource_id=new_issue.id,
new_values={
"ticket_id": str(ticket_uuid), "priority": issue.priority,
"tagged_users": [str(uid) for uid in issue.tagged_user_ids],
},
)
return IssueResponse(
id=new_issue.id, ticket_id=new_issue.ticket_id, tenant_id=new_issue.tenant_id,
content=new_issue.content, priority=new_issue.priority.value,
created_by=new_issue.created_by,
created_by_name=f"{new_issue.created_by_user.first_name} {new_issue.created_by_user.last_name}",
tagged_users=[
{"id": u.id, "full_name": f"{u.first_name} {u.last_name}", "email": u.email}
for u in new_issue.tagged_users
],
attachment_filename=new_issue.attachment_filename,
attachment_mime_type=new_issue.attachment_mime_type,
created_at=new_issue.created_at, updated_at=new_issue.updated_at,
)

View File

@@ -45,8 +45,12 @@ async def read_users(
- role: filtrar por rol
- is_active: filtrar por estado activo
"""
# ✅ CORREGIDO: Filtrar por tenant_id
query = select(User).where(User.tenant_id == current_user.tenant_id)
# ADMIN global ve todos los tenants; el resto solo ve su propio tenant
from app.models.user import UserRole as _UserRole
if current_user.role != _UserRole.ADMIN:
query = select(User).where(User.tenant_id == current_user.tenant_id)
else:
query = select(User)
# Aplicar filtros opcionales
if role:
@@ -144,10 +148,13 @@ async def read_user(
✅ Implementa multi-tenancy: solo permite acceso a usuarios del propio tenant.
"""
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id # ✅ Seguridad multi-tenant
)
if current_user.role.value == 'ADMIN':
query = select(User).where(User.id == user_id)
else:
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
user = result.scalar_one_or_none()
@@ -183,11 +190,14 @@ async def update_user(
detail="You don't have permission to update users"
)
# Buscar usuario
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
# Buscar usuario - ADMIN global puede editar cualquier tenant
if current_user.role.value == "ADMIN":
query = select(User).where(User.id == user_id)
else:
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
db_user = result.scalar_one_or_none()
@@ -276,8 +286,9 @@ async def delete_user(
- No se puede eliminar a sí mismo
- No se puede eliminar el último ADMIN del tenant
"""
# Verificar permisos - solo ADMIN puede eliminar
if current_user.role != UserRole.ADMIN:
# Verificar permisos - ADMIN global o CLIENT_ADMIN pueden eliminar
allowed = [UserRole.ADMIN, UserRole.CLIENT_ADMIN]
if current_user.role not in allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can delete users"
@@ -290,11 +301,14 @@ async def delete_user(
detail="You cannot delete yourself"
)
# Buscar usuario
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
# Buscar usuario - ADMIN global puede editar cualquier tenant
if current_user.role.value == "ADMIN":
query = select(User).where(User.id == user_id)
else:
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
db_user = result.scalar_one_or_none()
@@ -321,8 +335,8 @@ async def delete_user(
detail="Cannot delete the last active admin of the tenant"
)
# Soft delete
db_user.is_active = False
# Hard delete
await db.delete(db_user)
await db.commit()
# Registrar eliminación en auditoría
@@ -367,11 +381,14 @@ async def activate_user(
detail="You don't have permission to activate users"
)
# Buscar usuario
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
# Buscar usuario - ADMIN global puede editar cualquier tenant
if current_user.role.value == "ADMIN":
query = select(User).where(User.id == user_id)
else:
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
db_user = result.scalar_one_or_none()

View File

@@ -68,11 +68,11 @@ async def generate_next_ticket_number(db: AsyncSession, tenant_id: uuid.UUID) ->
last_ticket_number = result.scalar_one_or_none()
if last_ticket_number:
last_number = int(last_ticket_number.split('-')[1])
last_number = int(last_ticket_number.split('-')[-1])
next_number = last_number + 1
else:
next_number = 1
return f"TK-{next_number:06d}"

View File

@@ -6,7 +6,7 @@ Router principal para la API v1
from fastapi import APIRouter
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports, participants
api_router = APIRouter()
@@ -53,6 +53,12 @@ api_router.include_router(
prefix="/tickets",
tags=["tickets"]
)
# Participants routes (nested under tickets)
api_router.include_router(
participants.router,
prefix="/tickets",
tags=["participants"]
)
# Client Profile routes
api_router.include_router(

View File

@@ -35,6 +35,7 @@ from app.middleware.tenant import TenantMiddleware
from app.middleware.correlation_id import CorrelationIDMiddleware
from app.core.cache import cache
from app.core.limiter import limiter
from app.models.issue import TicketIssue, ticket_issue_tagged_users
settings = get_settings()
setup_logging()

View File

@@ -3,6 +3,7 @@
from .user import User
from .tenant import Tenant
from .ticket import Ticket
from .issue import TicketIssue, ticket_issue_tagged_users
from .comment import TicketComment
from .system import System
from .category import Category
@@ -10,12 +11,15 @@ from .client_profile import ClientProfile
from .attachment import TicketAttachment
from .audit import AuditLog
from .refresh_token import RefreshToken
from .permission import TenantPermission
__all__ = [
"User",
"Tenant",
"Tenant",
"Ticket",
"TicketIssue",
"TicketComment",
"TenantPermission",
"System",
"Category",
"ClientProfile",

102
backend/app/models/issue.py Normal file
View File

@@ -0,0 +1,102 @@
"""
TicketIssue Model - ServiceManagerWeb
Asuntos de escalación asociados a tickets.
Creados por el dueño del ticket o el CLIENT_ADMIN del tenant.
"""
from sqlalchemy import String, ForeignKey, Text, Table, Column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import Optional, List
from datetime import datetime
import uuid
from app.core.database import Base, GUID
from app.models.ticket import TicketPriority
from sqlalchemy import Enum as SAEnum
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM
# Tabla de relación N:M entre TicketIssue y User (usuarios etiquetados)
ticket_issue_tagged_users = Table(
"ticket_issue_tagged_users",
Base.metadata,
Column(
"issue_id",
GUID(),
ForeignKey("ticket_issues.id", ondelete="CASCADE"),
primary_key=True,
nullable=False,
),
Column(
"user_id",
GUID(),
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
nullable=False,
),
)
class TicketIssue(Base):
"""
Asunto de escalación de un ticket.
Solo puede crearlo:
- El usuario que creó el ticket (created_by del Ticket)
- El CLIENT_ADMIN del mismo tenant
"""
__tablename__ = "ticket_issues"
# Multi-tenancy — siempre filtrar por esto
tenant_id: Mapped[uuid.UUID] = mapped_column(
GUID(),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
)
# Relación con el ticket padre
ticket_id: Mapped[uuid.UUID] = mapped_column(
GUID(),
ForeignKey("tickets.id", ondelete="CASCADE"),
nullable=False,
)
# Quién lo creó
created_by: Mapped[uuid.UUID] = mapped_column(
GUID(),
ForeignKey("users.id"),
nullable=False,
)
# Contenido
content: Mapped[str] = mapped_column(Text, nullable=False)
priority: Mapped[TicketPriority] = mapped_column(
SAEnum(TicketPriority, name="ticket_priority_enum", native_enum=False).with_variant(
PG_ENUM(TicketPriority, name="ticket_priority_enum", create_type=True),
"postgresql",
),
default=TicketPriority.MEDIUM,
nullable=False,
)
# Adjunto opcional (reutiliza file_handler igual que TicketAttachment)
attachment_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
attachment_filename: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
attachment_mime_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
# Relationships
ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="issues")
created_by_user: Mapped["User"] = relationship(
"User",
foreign_keys=[created_by],
)
tagged_users: Mapped[List["User"]] = relationship(
"User",
secondary=ticket_issue_tagged_users,
)
def __repr__(self) -> str:
return f"<TicketIssue(id={self.id}, ticket={self.ticket_id}, priority={self.priority})>"

View File

@@ -0,0 +1,67 @@
"""
TenantPermission Model - ServiceManagerWeb
Permisos dinámicos por tenant.
- Si user_id es None → aplica al rol completo (default del tenant)
- Si user_id tiene valor → override individual para ese usuario
"""
from sqlalchemy import String, Boolean, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import Optional
import uuid
from app.core.database import Base, GUID
CLIENT_PERMISSIONS = [
"view_tickets",
"create_tickets",
"close_tickets",
"view_reports",
"manage_tenant_users",
"create_issues",
]
DEFAULT_PERMISSIONS = {
"CLIENT_ADMIN": {p: True for p in CLIENT_PERMISSIONS},
"CLIENT_USER": {
"view_tickets": True,
"create_tickets": True,
"close_tickets": False,
"view_reports": False,
"manage_tenant_users": False,
"create_issues": False,
}
}
class TenantPermission(Base):
__tablename__ = "tenant_permissions"
tenant_id: Mapped[uuid.UUID] = mapped_column(
GUID(),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
)
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
GUID(),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=True,
)
role: Mapped[str] = mapped_column(String(50), nullable=False)
permission: Mapped[str] = mapped_column(String(100), nullable=False)
granted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
tenant: Mapped["Tenant"] = relationship("Tenant")
user: Mapped[Optional["User"]] = relationship("User")
__table_args__ = (
UniqueConstraint(
"tenant_id", "role", "user_id", "permission",
name="uq_tenant_permission"
),
)
def __repr__(self) -> str:
scope = f"user:{self.user_id}" if self.user_id else f"role:{self.role}"
return f"<TenantPermission({scope} {self.permission}={'' if self.granted else ''})>"

View File

@@ -0,0 +1,63 @@
"""
Definición y helpers de roles para el sistema multi-tenant.
Fuente única: UserRole en app.models.user.
Este módulo expone conjuntos de roles y helpers de verificación
para usarse en deps.py y en los endpoints.
Roles globales (staff interno — alcance multi-tenant):
ADMIN → control total sobre todos los tenants
SUPPORT_MANAGER → gestiona equipos y SLAs de todos los tenants
AGENT → atiende tickets de cualquier tenant
AUDITOR → auditoría de solo lectura en todos los tenants
Roles de cliente (alcance limitado al propio tenant):
CLIENT_ADMIN → administra organización: usuarios, configuración, tickets
CLIENT_USER → crea y sigue sus propios tickets
"""
from app.models.user import UserRole
# ── Conjuntos de roles ──────────────────────────────────────────────────────
GLOBAL_ROLES: frozenset[UserRole] = frozenset({
UserRole.ADMIN,
UserRole.SUPPORT_MANAGER,
UserRole.AGENT,
UserRole.AUDITOR,
})
CLIENT_ROLES: frozenset[UserRole] = frozenset({
UserRole.CLIENT_ADMIN,
UserRole.CLIENT_USER,
})
# ── Permisos por rol ────────────────────────────────────────────────────────
ROLE_PERMISSIONS: dict[UserRole, list[str]] = {
# Staff global
UserRole.ADMIN: ["manage_all", "view_all", "audit_all"],
UserRole.SUPPORT_MANAGER: ["manage_teams", "view_all_tickets", "manage_sla"],
UserRole.AGENT: ["view_all_tickets", "update_any_ticket"],
UserRole.AUDITOR: ["view_all", "audit_all"],
# Clientes (acotados al tenant)
UserRole.CLIENT_ADMIN: ["manage_tenant", "manage_tenant_users", "view_tenant_tickets"],
UserRole.CLIENT_USER: ["create_ticket", "view_own_tickets"],
}
# ── Helpers ─────────────────────────────────────────────────────────────────
def is_global_staff(role: UserRole) -> bool:
"""Retorna True si el rol tiene alcance global (staff interno)."""
return role.is_global
def is_client_role(role: UserRole) -> bool:
"""Retorna True si el rol está acotado al tenant del usuario."""
return role.is_client
def has_permission(role: UserRole, permission: str) -> bool:
"""Verifica si un rol tiene un permiso específico."""
return permission in ROLE_PERMISSIONS.get(role, [])

View File

@@ -157,5 +157,43 @@ class Ticket(Base):
CheckConstraint('rating >= 1 AND rating <= 5', name='check_rating_range'),
)
participants: Mapped[list["TicketParticipant"]] = relationship(
"TicketParticipant",
back_populates="ticket",
cascade="all, delete-orphan"
)
issues: Mapped[list["TicketIssue"]] = relationship(
"TicketIssue",
back_populates="ticket",
cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<Ticket(id={self.id}, number='{self.ticket_number}', status={self.status})>"
return f"<Ticket(id={self.id}, number='{self.ticket_number}', status={self.status})>"
class TicketParticipant(Base):
"""Participantes asignados a un ticket"""
__tablename__ = "ticket_participants"
ticket_id: Mapped[uuid.UUID] = mapped_column(
GUID(),
ForeignKey("tickets.id", ondelete="CASCADE"),
nullable=False
)
user_id: Mapped[uuid.UUID] = mapped_column(
GUID(),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False
)
role: Mapped[str] = mapped_column(String(50), nullable=False, default="participant")
ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="participants")
user: Mapped["User"] = relationship("User")
__table_args__ = (
UniqueConstraint("ticket_id", "user_id", name="uq_ticket_participant"),
)
def __repr__(self):
return f"<TicketParticipant(ticket={self.ticket_id}, user={self.user_id})>"

View File

@@ -27,6 +27,24 @@ class UserRole(str, enum.Enum):
CLIENT_ADMIN = "CLIENT_ADMIN" # Admin de organización cliente
CLIENT_USER = "CLIENT_USER" # Usuario final cliente
@property
def is_global(self) -> bool:
"""True si el rol tiene alcance global (staff interno cross-tenant)."""
return self in (
UserRole.ADMIN,
UserRole.SUPPORT_MANAGER,
UserRole.AGENT,
UserRole.AUDITOR,
)
@property
def is_client(self) -> bool:
"""True si el rol está acotado al tenant del usuario."""
return self in (
UserRole.CLIENT_ADMIN,
UserRole.CLIENT_USER,
)
class User(Base):
"""Modelo de Usuario."""
@@ -113,11 +131,8 @@ class User(Base):
@property
def is_client(self) -> bool:
"""Check if user is a client."""
return self.role in [
UserRole.CLIENT_ADMIN,
UserRole.CLIENT_USER
]
"""Check if user is a client (rol acotado al propio tenant)."""
return self.role.is_client
@property
def can_manage_users(self) -> bool:
@@ -125,7 +140,7 @@ class User(Base):
return self.role in [
UserRole.ADMIN,
UserRole.SUPPORT_MANAGER,
UserRole.CLIENT_ADMIN
UserRole.CLIENT_ADMIN,
]
@property
@@ -134,7 +149,7 @@ class User(Base):
return self.role in [
UserRole.ADMIN,
UserRole.SUPPORT_MANAGER,
UserRole.AGENT
UserRole.AGENT,
]
@property

View File

@@ -0,0 +1,170 @@
"""
Ticket Service - ServiceManagerWeb
Lógica de negocio para creación y gestión de tickets.
Inyectable vía Depends() en los endpoints de FastAPI.
"""
import uuid
from datetime import datetime
from fastapi import Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.models.ticket import Ticket, TicketStatus, TicketPriority
from app.models.category import Category
from app.models.system import System
from app.api.schemas.ticket import TicketCreate
from app.api.v1.helpers import (
generate_next_ticket_number,
calculate_sla_deadlines,
safe_audit_log,
)
class TicketService:
"""Servicio de tickets: encapsula lógica de negocio fuera del router."""
def __init__(self, db: AsyncSession = Depends(get_db)):
self.db = db
async def create_ticket(
self,
ticket: TicketCreate,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> dict:
"""
Crea un ticket con validación multi-tenant, cálculo de SLA y auto-asignación.
Args:
ticket: Datos del ticket a crear.
tenant_id: Tenant del usuario autenticado.
user_id: ID del usuario que crea el ticket.
Returns:
dict compatible con TicketResponse.
Raises:
HTTPException 400: UUID inválido, categoría/sistema no encontrado o de otro tenant.
HTTPException 500: Fallo persistente tras max_retries.
"""
max_retries = 3
last_error = None
for attempt in range(max_retries):
try:
ticket_number = await generate_next_ticket_number(self.db, tenant_id)
category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None
system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None
category = None
if category_uuid:
category = await self.db.get(Category, category_uuid)
if not category or category.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"La categoría con ID {ticket.category_id} no existe.",
)
if system_uuid:
system = await self.db.get(System, system_uuid)
if not system or system.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"El sistema con ID {ticket.affected_system_id} no existe.",
)
sla_response_due, sla_resolution_due = calculate_sla_deadlines(category)
assigned_to_user = category.auto_assign_to if category and category.auto_assign_to else None
db_ticket = Ticket(
id=uuid.uuid4(),
tenant_id=tenant_id,
ticket_number=ticket_number,
subject=ticket.subject,
description=ticket.description,
category_id=category_uuid,
affected_system_id=system_uuid,
priority=TicketPriority[ticket.priority.upper()],
created_by=user_id,
assigned_to=assigned_to_user,
status=TicketStatus.NEW,
sla_response_due=sla_response_due,
sla_resolution_due=sla_resolution_due,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
self.db.add(db_ticket)
await self.db.commit()
await self.db.refresh(db_ticket)
await safe_audit_log(
db=self.db,
tenant_id=tenant_id,
user_id=user_id,
action="ticket.create",
resource_type="ticket",
resource_id=db_ticket.id,
new_values={
"ticket_number": db_ticket.ticket_number,
"subject": db_ticket.subject,
"priority": db_ticket.priority.value,
"status": db_ticket.status.value,
},
)
return {
"id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number,
"subject": db_ticket.subject,
"title": db_ticket.subject,
"description": db_ticket.description,
"status": db_ticket.status.value,
"priority": db_ticket.priority.value,
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
"system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
"contact_email": ticket.contact_email,
"contact_phone": ticket.contact_phone,
"created_by": str(db_ticket.created_by),
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
"created_at": db_ticket.created_at,
"updated_at": db_ticket.updated_at,
"sla_response_due": db_ticket.sla_response_due,
"sla_resolution_due": db_ticket.sla_resolution_due,
"first_response_at": db_ticket.first_response_at,
"resolved_at": db_ticket.resolved_at,
}
except ValueError as e:
await self.db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid UUID format: {str(e)}",
)
except HTTPException:
await self.db.rollback()
raise
except Exception as e:
await self.db.rollback()
last_error = e
if "duplicate key" in str(e).lower() and "ticket_number" in str(e).lower():
if attempt < max_retries - 1:
continue
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error creating ticket: {str(e)}",
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"No se pudo crear el ticket después de {max_retries} intentos: {str(last_error)}",
)
def get_ticket_service(db: AsyncSession = Depends(get_db)) -> TicketService:
"""Factory function para inyectar TicketService vía Depends()."""
return TicketService(db)

View File

@@ -0,0 +1,373 @@
"""
Integration Test Fixtures - ServiceManagerWeb (Docker / PostgreSQL)
backend/app/tests/conftest.py
Usa la BD Docker existente (servicemanager).
Los fixtures leen datos reales ya seedeados — no crean ni eliminan nada.
Los tests que inserten datos propios quedan aislados por rollback.
Tenant de referencia : aduanasoft
Usuarios de referencia:
admin@aduanasoft.com → ADMIN
manager@aduanasoft.com → SUPPORT_MANAGER
agente@aduanasoft.com → AGENT
auditor1@test.com → AUDITOR (tenant aduanasoft)
admin-cliente@empresa-demo → CLIENT_ADMIN
test_user@aduanasoft.com → CLIENT_USER
"""
import os
import asyncio
import pytest
from typing import AsyncGenerator, Generator
# ============================================================
# ENV VARS — antes de importar la app
# ============================================================
os.environ.setdefault("ENVIRONMENT", "testing")
os.environ.setdefault("TESTING", "true")
os.environ.setdefault("DEBUG", "false")
os.environ.setdefault("SECRET_KEY", "integration-secret-key-32chars!!!!")
os.environ.setdefault("JWT_SECRET_KEY", "integration-jwt-secret-32chars!!!!")
os.environ.setdefault(
"DATABASE_URL",
"postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager",
)
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/14")
os.environ.setdefault("CELERY_BROKER_URL", "redis://localhost:6379/14")
os.environ.setdefault("CELERY_RESULT_BACKEND", "redis://localhost:6379/14")
os.environ.setdefault("CORS_ORIGINS", "http://localhost:3000")
os.environ.setdefault("ALLOWED_FILE_EXTENSIONS", "pdf,jpg,jpeg,png,doc,docx,txt")
# ============================================================
# EVENT LOOP (session-scoped)
# ============================================================
@pytest.fixture(scope="session")
def event_loop() -> Generator:
"""Event loop compartido para toda la sesión de tests."""
policy = asyncio.get_event_loop_policy()
loop = policy.new_event_loop()
yield loop
loop.close()
# ============================================================
# ENGINE (session-scoped — reutiliza el pool toda la sesión)
# ============================================================
@pytest.fixture(scope="session")
async def engine():
"""
Conecta al PostgreSQL Docker existente (servicemanager).
NO crea ni destruye el schema — la BD ya está lista.
"""
from sqlalchemy.ext.asyncio import create_async_engine
import app.models # noqa: F401 — registra todos los modelos
_engine = create_async_engine(os.environ["DATABASE_URL"], echo=False)
yield _engine
await _engine.dispose()
# ============================================================
# DB (function-scoped — rollback para datos creados en el test)
# ============================================================
@pytest.fixture
async def db(engine) -> AsyncGenerator:
"""
Sesión con transacción por test.
Los datos seedeados son visibles (ya están committed).
Cualquier INSERT hecho en el test se revierte al finalizar.
"""
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
await session.begin()
yield session
await session.rollback()
# ============================================================
# TENANT (function-scoped — lee el registro existente)
# ============================================================
@pytest.fixture
async def tenant_a(db):
"""Tenant 'aduanasoft' ya existente en la BD."""
from sqlalchemy import select
from app.models.tenant import Tenant
result = await db.execute(select(Tenant).where(Tenant.slug == "aduanasoft"))
return result.scalar_one()
# ============================================================
# USUARIOS (function-scoped — leen registros existentes)
# ============================================================
@pytest.fixture
async def admin_user(db, tenant_a):
"""ADMIN: admin@aduanasoft.com (tenant aduanasoft)."""
from sqlalchemy import select
from app.models.user import User
result = await db.execute(
select(User)
.where(User.email == "admin@aduanasoft.com")
.where(User.tenant_id == tenant_a.id)
)
return result.scalar_one()
@pytest.fixture
async def manager_user(db, tenant_a):
"""SUPPORT_MANAGER: manager@aduanasoft.com (tenant aduanasoft)."""
from sqlalchemy import select
from app.models.user import User
result = await db.execute(
select(User)
.where(User.email == "manager@aduanasoft.com")
.where(User.tenant_id == tenant_a.id)
)
return result.scalar_one()
@pytest.fixture
async def agent_user(db, tenant_a):
"""AGENT: agente@aduanasoft.com (tenant aduanasoft)."""
from sqlalchemy import select
from app.models.user import User
result = await db.execute(
select(User)
.where(User.email == "agente@aduanasoft.com")
.where(User.tenant_id == tenant_a.id)
)
return result.scalar_one()
@pytest.fixture
async def user_tenant_a(db, tenant_a):
"""CLIENT_USER: test_user@aduanasoft.com (tenant aduanasoft)."""
from sqlalchemy import select
from app.models.user import User
result = await db.execute(
select(User)
.where(User.email == "test_user@aduanasoft.com")
.where(User.tenant_id == tenant_a.id)
)
return result.scalar_one()
# ============================================================
# HTTP CLIENT (function-scoped)
# ============================================================
@pytest.fixture
async def client(db) -> AsyncGenerator:
"""
httpx.AsyncClient contra la app FastAPI en memoria (sin red).
get_db queda sobreescrito para inyectar la sesión de test.
Los cambios del test se revierten al terminar (rollback en db).
"""
import httpx
from httpx import ASGITransport
from app.main import app
from app.core.database import get_db
async def _override_get_db():
yield db
app.dependency_overrides[get_db] = _override_get_db
async with httpx.AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as ac:
yield ac
app.dependency_overrides.pop(get_db, None)
# ============================================================
# FIXTURES DE AISLAMIENTO MULTI-TENANT
# ============================================================
@pytest.fixture
def make_token():
"""Factory de JWT tokens para autenticar clientes HTTP en tests."""
from app.core.security import security
def _make(user):
return security.create_access_token(data={"sub": str(user.id)})
return _make
@pytest.fixture
async def app_with_db(db):
"""
Override de get_db compartido para todos los HTTP clients de un mismo test.
Garantiza que todos los clients usen la misma sesión (y el mismo rollback).
"""
from app.main import app as _app
from app.core.database import get_db
async def _override():
yield db
_app.dependency_overrides[get_db] = _override
yield _app
_app.dependency_overrides.pop(get_db, None)
@pytest.fixture
async def tenant_b(db):
"""Tenant 'empresa-test' creado en la transacción del test (se revierte al final)."""
from app.models.tenant import Tenant
t = Tenant(name="Empresa Test", slug="empresa-test")
db.add(t)
await db.flush()
return t
@pytest.fixture
async def user_b(db, tenant_b):
"""CLIENT_ADMIN en tenant_b — puede gestionar recursos de su tenant."""
from app.models.user import User, UserRole
from app.core.security import security
u = User(
tenant_id=tenant_b.id,
email="admin@empresa-test.com",
first_name="Admin",
last_name="Test",
password_hash=security.hash_password("Test1234!"),
role=UserRole.CLIENT_ADMIN,
is_active=True,
email_verified=True,
)
db.add(u)
await db.flush()
return u
@pytest.fixture
async def client_tenant_a(app_with_db, manager_user, make_token):
"""HTTP client autenticado como SUPPORT_MANAGER de tenant_a (aduanasoft).
Usa manager_user en lugar de admin_user para mantener el aislamiento de
tenant en GET /users/ (el ADMIN global bypasa el filtro de tenant).
"""
import httpx
from httpx import ASGITransport
token = make_token(manager_user)
async with httpx.AsyncClient(
transport=ASGITransport(app=app_with_db),
base_url="http://test",
headers={"Authorization": f"Bearer {token}"},
) as ac:
yield ac
@pytest.fixture
async def client_tenant_b(app_with_db, user_b, make_token):
"""HTTP client autenticado como CLIENT_ADMIN de tenant_b (empresa-test)."""
import httpx
from httpx import ASGITransport
token = make_token(user_b)
async with httpx.AsyncClient(
transport=ASGITransport(app=app_with_db),
base_url="http://test",
headers={"Authorization": f"Bearer {token}"},
) as ac:
yield ac
@pytest.fixture
async def client_admin(app_with_db, admin_user, make_token):
"""HTTP client autenticado como ADMIN global."""
import httpx
from httpx import ASGITransport
token = make_token(admin_user)
async with httpx.AsyncClient(
transport=ASGITransport(app=app_with_db),
base_url="http://test",
headers={"Authorization": f"Bearer {token}"},
) as ac:
yield ac
@pytest.fixture
def create_ticket_tenant_a(client_tenant_a):
"""Factory: crea un ticket en tenant_a vía HTTP y retorna el JSON de respuesta."""
async def _create(subject="Ticket Tenant A", priority="MEDIUM"):
resp = await client_tenant_a.post("/v1/tickets/", json={
"subject": subject,
"description": "Test de aislamiento tenant A",
"priority": priority,
})
assert resp.status_code in (200, 201), f"Error creando ticket A: {resp.text}"
return resp.json()
return _create
@pytest.fixture
def create_ticket_tenant_b(client_tenant_b):
"""Factory: crea un ticket en tenant_b vía HTTP y retorna el JSON de respuesta."""
async def _create(subject="Ticket Tenant B", priority="MEDIUM"):
resp = await client_tenant_b.post("/v1/tickets/", json={
"subject": subject,
"description": "Test de aislamiento tenant B",
"priority": priority,
})
assert resp.status_code in (200, 201), f"Error creando ticket B: {resp.text}"
return resp.json()
return _create
@pytest.fixture
def create_user_tenant_a(client_tenant_a):
"""Factory: crea un usuario en tenant_a vía HTTP y retorna el JSON de respuesta."""
async def _create(email="nuevo_user_a@test.com"):
resp = await client_tenant_a.post("/v1/users/", json={
"email": email,
"first_name": "Usuario",
"last_name": "TenantA",
"password": "Test1234!",
"role": "CLIENT_USER",
})
assert resp.status_code in (200, 201), f"Error creando user A: {resp.text}"
return resp.json()
return _create
@pytest.fixture
def create_user_tenant_b(client_tenant_b):
"""Factory: crea un usuario en tenant_b vía HTTP y retorna el JSON de respuesta."""
async def _create(email="nuevo_user_b@test.com"):
resp = await client_tenant_b.post("/v1/users/", json={
"email": email,
"first_name": "Usuario",
"last_name": "TenantB",
"password": "Test1234!",
"role": "CLIENT_USER",
})
assert resp.status_code in (200, 201), f"Error creando user B: {resp.text}"
return resp.json()
return _create

View File

@@ -0,0 +1,137 @@
"""
Smoke Tests - ServiceManagerWeb
Verifican que el stack completo funciona:
- Conexión a BD Docker
- Fixtures de tenant y usuarios
- Login vía HTTP (httpx + FastAPI en memoria)
- Endpoint protegido con token
"""
import pytest
# ============================================================
# BD + FIXTURES
# ============================================================
@pytest.mark.asyncio
async def test_db_connected(db):
"""La sesión de BD está activa y responde."""
from sqlalchemy import text
result = await db.execute(text("SELECT 1"))
assert result.scalar() == 1
@pytest.mark.asyncio
async def test_tenant_a_existe(tenant_a):
"""El tenant 'aduanasoft' existe y tiene datos válidos."""
assert tenant_a.slug == "aduanasoft"
assert tenant_a.name is not None
@pytest.mark.asyncio
async def test_admin_user_existe(admin_user):
"""El usuario ADMIN existe y pertenece al tenant correcto."""
from app.models.user import UserRole
assert admin_user.email == "admin@aduanasoft.com"
assert admin_user.role == UserRole.ADMIN
assert admin_user.is_active is True
@pytest.mark.asyncio
async def test_manager_user_existe(manager_user):
"""El usuario SUPPORT_MANAGER existe."""
from app.models.user import UserRole
assert manager_user.email == "manager@aduanasoft.com"
assert manager_user.role == UserRole.SUPPORT_MANAGER
@pytest.mark.asyncio
async def test_agent_user_existe(agent_user):
"""El usuario AGENT existe."""
from app.models.user import UserRole
assert agent_user.email == "agente@aduanasoft.com"
assert agent_user.role == UserRole.AGENT
@pytest.mark.asyncio
async def test_client_user_existe(user_tenant_a):
"""El CLIENT_USER existe."""
from app.models.user import UserRole
assert user_tenant_a.email == "test_user@aduanasoft.com"
assert user_tenant_a.role == UserRole.CLIENT_USER
# ============================================================
# HTTP — LOGIN
# ============================================================
@pytest.mark.asyncio
async def test_login_admin_ok(client):
"""Login con credenciales de admin devuelve access_token."""
response = await client.post(
"/v1/auth/login",
json={
"email": "admin@aduanasoft.com",
"password": "admin123",
"tenant_slug": "aduanasoft",
},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
@pytest.mark.asyncio
async def test_login_credenciales_invalidas(client):
"""Login con contraseña incorrecta devuelve 401."""
response = await client.post(
"/v1/auth/login",
json={
"email": "admin@aduanasoft.com",
"password": "wrongpassword",
"tenant_slug": "aduanasoft",
},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_endpoint_sin_token_devuelve_401(client):
"""Acceder a un endpoint protegido sin token devuelve 401."""
response = await client.get(
"/v1/users/me",
headers={"X-Tenant-Slug": "aduanasoft"},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_login_y_me(client):
"""Login exitoso → /users/me devuelve el usuario correcto."""
# Login
login = await client.post(
"/v1/auth/login",
json={
"email": "admin@aduanasoft.com",
"password": "admin123",
"tenant_slug": "aduanasoft",
},
)
assert login.status_code == 200
token = login.json()["access_token"]
# Endpoint protegido
me = await client.get(
"/v1/users/me",
headers={
"Authorization": f"Bearer {token}",
"X-Tenant-Slug": "aduanasoft",
},
)
assert me.status_code == 200
data = me.json()
assert data["email"] == "admin@aduanasoft.com"
assert data["role"] == "ADMIN"

View File

@@ -0,0 +1,123 @@
"""
Pruebas de aislamiento multi-tenant para tickets y usuarios.
Usa solo los fixtures definidos en conftest.py.
Roles en juego:
client_tenant_a → SUPPORT_MANAGER (aduanasoft) — restringido a su tenant
client_tenant_b → CLIENT_ADMIN (empresa-test) — restringido a su tenant
client_admin → ADMIN global (aduanasoft) — acceso a todos los tenants
"""
import pytest
@pytest.mark.asyncio
async def test_tenant_a_cannot_see_tenant_b_tickets(
client_tenant_a, create_ticket_tenant_b
):
"""
El usuario del tenant B crea un ticket.
El usuario del tenant A (SUPPORT_MANAGER) lista sus tickets.
El ticket de tenant B NO debe aparecer en la respuesta.
"""
# El usuario del tenant B crea un ticket
ticket_b = await create_ticket_tenant_b()
ticket_b_id = ticket_b["id"]
# El usuario del tenant A lista sus tickets
response = await client_tenant_a.get("/v1/tickets/")
assert response.status_code == 200
ids_visibles = {t["id"] for t in response.json()}
# El ticket de tenant B no debe ser visible para tenant A
assert ticket_b_id not in ids_visibles, (
f"Fallo de aislamiento: ticket de tenant B ({ticket_b_id}) "
f"visible para usuario de tenant A"
)
@pytest.mark.asyncio
async def test_tenant_b_cannot_edit_tenant_a_ticket(
create_ticket_tenant_a, client_tenant_b
):
"""
El usuario del tenant A crea un ticket.
El usuario del tenant B intenta editar ese ticket vía PATCH.
Debe recibir 403 (prohibido) o 404 (no encontrado).
"""
# El usuario del tenant A crea un ticket
ticket_a = await create_ticket_tenant_a()
ticket_a_id = ticket_a["id"]
# El usuario del tenant B intenta editar el ticket de tenant A
response = await client_tenant_b.patch(
f"/v1/tickets/{ticket_a_id}",
json={"status": "CLOSED"},
)
# Debe recibir 403 o 404 — nunca 200
assert response.status_code in (403, 404), (
f"Fallo de aislamiento: tenant B pudo editar ticket de tenant A "
f"(HTTP {response.status_code})"
)
@pytest.mark.asyncio
async def test_tenant_a_cannot_see_tenant_b_users(
client_tenant_a, create_user_tenant_b
):
"""
El usuario del tenant B crea un usuario nuevo.
El usuario del tenant A (SUPPORT_MANAGER) lista los usuarios.
El usuario de tenant B NO debe aparecer en la respuesta.
"""
# El usuario del tenant B crea un usuario
user_b = await create_user_tenant_b()
user_b_id = user_b["id"]
# El usuario del tenant A lista los usuarios de su tenant
response = await client_tenant_a.get("/v1/users/")
assert response.status_code == 200
ids_visibles = {u["id"] for u in response.json()}
# El usuario de tenant B no debe ser visible para tenant A
assert user_b_id not in ids_visibles, (
f"Fallo de aislamiento: usuario de tenant B ({user_b_id}) "
f"visible para usuario de tenant A"
)
@pytest.mark.asyncio
async def test_admin_sees_all_tenant_data(
client_admin,
create_ticket_tenant_a,
create_ticket_tenant_b,
create_user_tenant_a,
create_user_tenant_b,
):
"""
El ADMIN global debe poder ver tickets y usuarios de TODOS los tenants.
- Tickets: vía /v1/tickets/admin/all (endpoint multi-tenant).
- Usuarios: vía /v1/users/ (ADMIN bypasa el filtro de tenant).
"""
# Crear datos en ambos tenants
ticket_a = await create_ticket_tenant_a()
ticket_b = await create_ticket_tenant_b()
user_a = await create_user_tenant_a()
user_b = await create_user_tenant_b()
# El admin lista todos los tickets (endpoint multi-tenant)
resp_tickets = await client_admin.get("/v1/tickets/admin/all")
assert resp_tickets.status_code == 200
ids_tickets = {t["id"] for t in resp_tickets.json()}
assert ticket_a["id"] in ids_tickets, "El ADMIN no ve el ticket de tenant A"
assert ticket_b["id"] in ids_tickets, "El ADMIN no ve el ticket de tenant B"
# El admin lista todos los usuarios (ADMIN bypasa filtro de tenant)
resp_users = await client_admin.get("/v1/users/")
assert resp_users.status_code == 200
ids_users = {u["id"] for u in resp_users.json()}
assert user_a["id"] in ids_users, "El ADMIN no ve el usuario de tenant A"
assert user_b["id"] in ids_users, "El ADMIN no ve el usuario de tenant B"

3
backend/check_schema.py Normal file
View File

@@ -0,0 +1,3 @@
with open("/app/app/api/schemas/category.py", "r") as f:
content = f.read()
print(content)

19
backend/debug_tenant.py Normal file
View File

@@ -0,0 +1,19 @@
import asyncio
from app.core.database import AsyncSessionLocal
from app.models.user import User
from app.models.tenant import Tenant
from sqlalchemy import select
async def check():
async with AsyncSessionLocal() as db:
result = await db.execute(
select(User, Tenant).join(Tenant).where(User.email == 'javier@ventas.com')
)
user, tenant = result.one()
print(f"user.tenant_id: {user.tenant_id}")
print(f"tenant.id: {tenant.id}")
print(f"tenant.slug: {tenant.slug}")
print(f"user.role: {user.role}")
print(f"role.is_client: {user.role.is_client}")
asyncio.run(check())

28
backend/fix_categories.py Normal file
View File

@@ -0,0 +1,28 @@
with open("/app/app/api/v1/endpoints/categories.py", "r") as f:
content = f.read()
old = ''' # ✅ CORREGIDO: Asignar tenant_id del usuario actual
db_category = Category(
**category.model_dump(),
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
)'''
new = ''' # ADMIN global puede especificar tenant_id; otros usan el suyo
from app.models.user import UserRole as _R
data = category.model_dump()
if current_user.role == _R.ADMIN and data.get("tenant_id"):
target_tenant_id = data["tenant_id"]
else:
target_tenant_id = current_user.tenant_id
data["tenant_id"] = target_tenant_id
db_category = Category(**data)'''
if old in content:
content = content.replace(old, new)
print("OK: fix aplicado")
else:
print("ERROR: bloque no encontrado")
with open("/app/app/api/v1/endpoints/categories.py", "w") as f:
f.write(content)

15
backend/fix_delete.py Normal file
View File

@@ -0,0 +1,15 @@
with open("/app/app/api/v1/endpoints/users.py", "r") as f:
content = f.read()
old = ' # Verificar permisos - solo ADMIN puede eliminar\n if current_user.role != UserRole.ADMIN:\n raise HTTPException(\n status_code=status.HTTP_403_FORBIDDEN,\n detail="Only admins can delete users"\n )'
new = ' # Verificar permisos - ADMIN global o CLIENT_ADMIN pueden eliminar\n allowed = [UserRole.ADMIN, UserRole.CLIENT_ADMIN]\n if current_user.role not in allowed:\n raise HTTPException(\n status_code=status.HTTP_403_FORBIDDEN,\n detail="Only admins can delete users"\n )'
if old in content:
content = content.replace(old, new)
print("OK: permisos delete actualizados")
else:
print("ERROR: bloque no encontrado")
with open("/app/app/api/v1/endpoints/users.py", "w") as f:
f.write(content)

14
backend/fix_delete2.py Normal file
View File

@@ -0,0 +1,14 @@
with open("/app/app/api/v1/endpoints/users.py", "r") as f:
content = f.read()
old = " # Soft delete\n db_user.is_active = False\n await db.commit()"
new = " # Hard delete\n await db.delete(db_user)\n await db.commit()"
if old in content:
content = content.replace(old, new)
print("OK: hard delete aplicado")
else:
print("ERROR: bloque no encontrado")
with open("/app/app/api/v1/endpoints/users.py", "w") as f:
f.write(content)

34
backend/fix_router.py Normal file
View File

@@ -0,0 +1,34 @@
with open("/app/app/api/v1/router.py", "r") as f:
content = f.read()
old = "from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports"
new = "from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports, participants"
old2 = "# Tickets routes\napi_router.include_router(\n tickets.router,\n prefix=\"/tickets\",\n tags=[\"tickets\"]\n)"
new2 = """# Tickets routes
api_router.include_router(
tickets.router,
prefix="/tickets",
tags=["tickets"]
)
# Participants routes (nested under tickets)
api_router.include_router(
participants.router,
prefix="/tickets",
tags=["participants"]
)"""
if old in content:
content = content.replace(old, new)
print("OK: import agregado")
else:
print("ERROR: import no encontrado")
if old2 in content:
content = content.replace(old2, new2)
print("OK: router agregado")
else:
print("ERROR: router no encontrado")
with open("/app/app/api/v1/router.py", "w") as f:
f.write(content)

30
backend/fix_schema.py Normal file
View File

@@ -0,0 +1,30 @@
with open("/app/app/api/schemas/category.py", "r") as f:
content = f.read()
old = '''class CategoryCreate(BaseModel):
"""Schema para crear categoría. No incluye tenant_id (se asigna automáticamente)."""
name: str
description: Optional[str] = None
color: Optional[str] = None
sla_response_hours: int = 24
sla_resolution_hours: int = 72
auto_assign_to: Optional[uuid.UUID] = None'''
new = '''class CategoryCreate(BaseModel):
"""Schema para crear categoría. tenant_id opcional para ADMIN global."""
name: str
description: Optional[str] = None
color: Optional[str] = None
sla_response_hours: int = 24
sla_resolution_hours: int = 72
auto_assign_to: Optional[uuid.UUID] = None
tenant_id: Optional[uuid.UUID] = None'''
if old in content:
content = content.replace(old, new)
print("OK")
else:
print("ERROR: bloque no encontrado")
with open("/app/app/api/schemas/category.py", "w") as f:
f.write(content)

View File

@@ -0,0 +1,282 @@
"""add_tenant_permissions
Revision ID: 0aec0a6e294a
Revises: b2dcb926e091
Create Date: 2026-03-17 19:33:30.229993
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '0aec0a6e294a'
down_revision = 'b2dcb926e091'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('idx_email_templates_tenant_id', table_name='email_templates')
op.drop_table('email_templates')
op.drop_index('idx_ticket_status_history_changed_by', table_name='ticket_status_history')
op.drop_index('idx_ticket_status_history_created_at', table_name='ticket_status_history')
op.drop_index('idx_ticket_status_history_ticket_id', table_name='ticket_status_history')
op.drop_table('ticket_status_history')
op.drop_index('idx_notification_logs_created_at', table_name='notification_logs')
op.drop_index('idx_notification_logs_recipient', table_name='notification_logs')
op.drop_index('idx_notification_logs_status', table_name='notification_logs')
op.drop_index('idx_notification_logs_tenant_id', table_name='notification_logs')
op.drop_index('idx_notification_logs_ticket_id', table_name='notification_logs')
op.drop_table('notification_logs')
op.add_column('audit_logs', sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False))
op.alter_column('audit_logs', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
nullable=False,
existing_server_default=sa.text('now()'))
op.drop_index('idx_audit_logs_action', table_name='audit_logs')
op.drop_index('idx_audit_logs_correlation_id', table_name='audit_logs')
op.drop_index('idx_audit_logs_created_at', table_name='audit_logs')
op.drop_index('idx_audit_logs_tenant_id', table_name='audit_logs')
op.drop_index('idx_audit_logs_user_id', table_name='audit_logs')
op.create_index('idx_audit_logs_tenant_action', 'audit_logs', ['tenant_id', 'action'], unique=False)
op.create_index('idx_audit_logs_user_created', 'audit_logs', ['user_id', 'created_at'], unique=False)
op.create_index(op.f('ix_audit_logs_action'), 'audit_logs', ['action'], unique=False)
op.create_index(op.f('ix_audit_logs_correlation_id'), 'audit_logs', ['correlation_id'], unique=False)
op.create_index(op.f('ix_audit_logs_created_at'), 'audit_logs', ['created_at'], unique=False)
op.create_index(op.f('ix_audit_logs_resource_type'), 'audit_logs', ['resource_type'], unique=False)
op.create_index(op.f('ix_audit_logs_tenant_id'), 'audit_logs', ['tenant_id'], unique=False)
op.create_index(op.f('ix_audit_logs_user_id'), 'audit_logs', ['user_id'], unique=False)
op.drop_constraint('audit_logs_user_id_fkey', 'audit_logs', type_='foreignkey')
op.drop_constraint('audit_logs_tenant_id_fkey', 'audit_logs', type_='foreignkey')
op.create_foreign_key(None, 'audit_logs', 'users', ['user_id'], ['id'], ondelete='SET NULL')
op.create_foreign_key(None, 'audit_logs', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE')
op.drop_table_comment(
'audit_logs',
existing_comment='Bit??cora de acciones para auditor??a y compliance',
schema=None
)
op.drop_index('idx_refresh_tokens_expires', table_name='refresh_tokens')
op.drop_index('idx_refresh_tokens_user_id', table_name='refresh_tokens')
op.create_index('idx_refresh_tokens_user_expires', 'refresh_tokens', ['user_id', 'expires_at'], unique=False)
op.create_index(op.f('ix_refresh_tokens_expires_at'), 'refresh_tokens', ['expires_at'], unique=False)
op.create_index(op.f('ix_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False)
op.drop_index('idx_tenants_domain', table_name='tenants')
op.drop_index('idx_tenants_slug', table_name='tenants')
op.drop_index('idx_tenants_status', table_name='tenants')
op.drop_table_comment(
'tenants',
existing_comment='Organizaciones cliente en el sistema multi-tenant',
schema=None
)
op.drop_index('idx_ticket_attachments_comment_id', table_name='ticket_attachments')
op.drop_index('idx_ticket_attachments_ticket_id', table_name='ticket_attachments')
op.drop_index('idx_ticket_attachments_uploaded_by', table_name='ticket_attachments')
op.drop_table_comment(
'ticket_attachments',
existing_comment='Archivos adjuntos en tickets',
schema=None
)
op.drop_index('idx_ticket_comments_author_id', table_name='ticket_comments')
op.drop_index('idx_ticket_comments_created_at', table_name='ticket_comments')
op.drop_index('idx_ticket_comments_ticket_id', table_name='ticket_comments')
op.drop_table_comment(
'ticket_comments',
existing_comment='Comentarios en tickets',
schema=None
)
op.drop_index('idx_tickets_assigned_to', table_name='tickets', postgresql_where='(assigned_to IS NOT NULL)')
op.drop_index('idx_tickets_category', table_name='tickets')
op.drop_index('idx_tickets_created_at', table_name='tickets')
op.drop_index('idx_tickets_created_by', table_name='tickets')
op.drop_index('idx_tickets_number', table_name='tickets')
op.drop_index('idx_tickets_priority', table_name='tickets')
op.drop_index('idx_tickets_sla_resolution', table_name='tickets')
op.drop_index('idx_tickets_sla_response', table_name='tickets')
op.drop_index('idx_tickets_status', table_name='tickets')
op.drop_index('idx_tickets_tenant_id', table_name='tickets')
op.drop_index('idx_tickets_tenant_status', table_name='tickets')
op.drop_table_comment(
'tickets',
existing_comment='Tickets de soporte - core del negocio',
schema=None
)
op.drop_index('idx_users_active', table_name='users')
op.drop_index('idx_users_email', table_name='users')
op.drop_index('idx_users_role', table_name='users')
op.drop_index('idx_users_tenant_email', table_name='users')
op.drop_index('idx_users_tenant_id', table_name='users')
op.drop_table_comment(
'users',
existing_comment='Usuarios del sistema (internos y clientes)',
schema=None
)
op.drop_column('users', 'backup_codes_temp')
op.drop_column('users', 'password_changed_at')
op.drop_column('users', 'totp_secret_temp')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('totp_secret_temp', sa.VARCHAR(length=32), autoincrement=False, nullable=True))
op.add_column('users', sa.Column('password_changed_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True))
op.add_column('users', sa.Column('backup_codes_temp', postgresql.ARRAY(sa.VARCHAR()), autoincrement=False, nullable=True))
op.create_table_comment(
'users',
'Usuarios del sistema (internos y clientes)',
existing_comment=None,
schema=None
)
op.create_index('idx_users_tenant_id', 'users', ['tenant_id'], unique=False)
op.create_index('idx_users_tenant_email', 'users', ['tenant_id', 'email'], unique=False)
op.create_index('idx_users_role', 'users', ['role'], unique=False)
op.create_index('idx_users_email', 'users', ['email'], unique=False)
op.create_index('idx_users_active', 'users', ['is_active'], unique=False)
op.create_table_comment(
'tickets',
'Tickets de soporte - core del negocio',
existing_comment=None,
schema=None
)
op.create_index('idx_tickets_tenant_status', 'tickets', ['tenant_id', 'status'], unique=False)
op.create_index('idx_tickets_tenant_id', 'tickets', ['tenant_id'], unique=False)
op.create_index('idx_tickets_status', 'tickets', ['status'], unique=False)
op.create_index('idx_tickets_sla_response', 'tickets', ['sla_response_due'], unique=False)
op.create_index('idx_tickets_sla_resolution', 'tickets', ['sla_resolution_due'], unique=False)
op.create_index('idx_tickets_priority', 'tickets', ['priority'], unique=False)
op.create_index('idx_tickets_number', 'tickets', ['ticket_number'], unique=False)
op.create_index('idx_tickets_created_by', 'tickets', ['created_by'], unique=False)
op.create_index('idx_tickets_created_at', 'tickets', ['created_at'], unique=False)
op.create_index('idx_tickets_category', 'tickets', ['category_id'], unique=False)
op.create_index('idx_tickets_assigned_to', 'tickets', ['assigned_to'], unique=False, postgresql_where='(assigned_to IS NOT NULL)')
op.create_table_comment(
'ticket_comments',
'Comentarios en tickets',
existing_comment=None,
schema=None
)
op.create_index('idx_ticket_comments_ticket_id', 'ticket_comments', ['ticket_id'], unique=False)
op.create_index('idx_ticket_comments_created_at', 'ticket_comments', ['created_at'], unique=False)
op.create_index('idx_ticket_comments_author_id', 'ticket_comments', ['author_id'], unique=False)
op.create_table_comment(
'ticket_attachments',
'Archivos adjuntos en tickets',
existing_comment=None,
schema=None
)
op.create_index('idx_ticket_attachments_uploaded_by', 'ticket_attachments', ['uploaded_by'], unique=False)
op.create_index('idx_ticket_attachments_ticket_id', 'ticket_attachments', ['ticket_id'], unique=False)
op.create_index('idx_ticket_attachments_comment_id', 'ticket_attachments', ['comment_id'], unique=False)
op.create_table_comment(
'tenants',
'Organizaciones cliente en el sistema multi-tenant',
existing_comment=None,
schema=None
)
op.create_index('idx_tenants_status', 'tenants', ['status'], unique=False)
op.create_index('idx_tenants_slug', 'tenants', ['slug'], unique=False)
op.create_index('idx_tenants_domain', 'tenants', ['domain'], unique=False)
op.drop_index(op.f('ix_refresh_tokens_user_id'), table_name='refresh_tokens')
op.drop_index(op.f('ix_refresh_tokens_expires_at'), table_name='refresh_tokens')
op.drop_index('idx_refresh_tokens_user_expires', table_name='refresh_tokens')
op.create_index('idx_refresh_tokens_user_id', 'refresh_tokens', ['user_id'], unique=False)
op.create_index('idx_refresh_tokens_expires', 'refresh_tokens', ['expires_at'], unique=False)
op.create_table_comment(
'audit_logs',
'Bit??cora de acciones para auditor??a y compliance',
existing_comment=None,
schema=None
)
op.drop_constraint(None, 'audit_logs', type_='foreignkey')
op.drop_constraint(None, 'audit_logs', type_='foreignkey')
op.create_foreign_key('audit_logs_tenant_id_fkey', 'audit_logs', 'tenants', ['tenant_id'], ['id'])
op.create_foreign_key('audit_logs_user_id_fkey', 'audit_logs', 'users', ['user_id'], ['id'])
op.drop_index(op.f('ix_audit_logs_user_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_tenant_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_resource_type'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_created_at'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_correlation_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_action'), table_name='audit_logs')
op.drop_index('idx_audit_logs_user_created', table_name='audit_logs')
op.drop_index('idx_audit_logs_tenant_action', table_name='audit_logs')
op.create_index('idx_audit_logs_user_id', 'audit_logs', ['user_id'], unique=False)
op.create_index('idx_audit_logs_tenant_id', 'audit_logs', ['tenant_id'], unique=False)
op.create_index('idx_audit_logs_created_at', 'audit_logs', ['created_at'], unique=False)
op.create_index('idx_audit_logs_correlation_id', 'audit_logs', ['correlation_id'], unique=False)
op.create_index('idx_audit_logs_action', 'audit_logs', ['action'], unique=False)
op.alter_column('audit_logs', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
nullable=True,
existing_server_default=sa.text('now()'))
op.drop_column('audit_logs', 'updated_at')
op.create_table('notification_logs',
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('recipient_email', sa.VARCHAR(length=320), autoincrement=False, nullable=False),
sa.Column('subject', sa.VARCHAR(length=500), autoincrement=False, nullable=False),
sa.Column('template_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('ticket_id', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('user_id', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('status', sa.VARCHAR(length=20), server_default=sa.text("'pending'::character varying"), autoincrement=False, nullable=True),
sa.Column('error_message', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('provider', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('external_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
sa.Column('sent_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.CheckConstraint("status::text = ANY (ARRAY['pending'::character varying, 'sent'::character varying, 'failed'::character varying, 'bounced'::character varying]::text[])", name='notification_logs_status_check'),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='notification_logs_tenant_id_fkey'),
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], name='notification_logs_ticket_id_fkey'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='notification_logs_user_id_fkey'),
sa.PrimaryKeyConstraint('id', name='notification_logs_pkey')
)
op.create_index('idx_notification_logs_ticket_id', 'notification_logs', ['ticket_id'], unique=False)
op.create_index('idx_notification_logs_tenant_id', 'notification_logs', ['tenant_id'], unique=False)
op.create_index('idx_notification_logs_status', 'notification_logs', ['status'], unique=False)
op.create_index('idx_notification_logs_recipient', 'notification_logs', ['recipient_email'], unique=False)
op.create_index('idx_notification_logs_created_at', 'notification_logs', ['created_at'], unique=False)
op.create_table('ticket_status_history',
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
sa.Column('ticket_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('changed_by', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('old_status', postgresql.ENUM('NEW', 'TRIAGE', 'IN_PROGRESS', 'WAITING_CUSTOMER', 'RESOLVED', 'CLOSED', 'REOPENED', name='ticket_status_enum'), autoincrement=False, nullable=True),
sa.Column('new_status', postgresql.ENUM('NEW', 'TRIAGE', 'IN_PROGRESS', 'WAITING_CUSTOMER', 'RESOLVED', 'CLOSED', 'REOPENED', name='ticket_status_enum'), autoincrement=False, nullable=False),
sa.Column('old_assigned_to', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('new_assigned_to', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('comment', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['changed_by'], ['users.id'], name='ticket_status_history_changed_by_fkey'),
sa.ForeignKeyConstraint(['new_assigned_to'], ['users.id'], name='ticket_status_history_new_assigned_to_fkey'),
sa.ForeignKeyConstraint(['old_assigned_to'], ['users.id'], name='ticket_status_history_old_assigned_to_fkey'),
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], name='ticket_status_history_ticket_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='ticket_status_history_pkey')
)
op.create_index('idx_ticket_status_history_ticket_id', 'ticket_status_history', ['ticket_id'], unique=False)
op.create_index('idx_ticket_status_history_created_at', 'ticket_status_history', ['created_at'], unique=False)
op.create_index('idx_ticket_status_history_changed_by', 'ticket_status_history', ['changed_by'], unique=False)
op.create_table('email_templates',
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('template_key', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('name', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('subject_template', sa.TEXT(), autoincrement=False, nullable=False),
sa.Column('html_template', sa.TEXT(), autoincrement=False, nullable=False),
sa.Column('text_template', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column('is_system', sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column('required_variables', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True),
sa.Column('default_variables', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True),
sa.Column('from_name', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
sa.Column('from_email', sa.VARCHAR(length=320), autoincrement=False, nullable=True),
sa.Column('version', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('last_used_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
sa.Column('usage_count', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False),
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='email_templates_tenant_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='email_templates_pkey')
)
op.create_index('idx_email_templates_tenant_id', 'email_templates', ['tenant_id'], unique=False)
# ### end Alembic commands ###

View File

@@ -0,0 +1,28 @@
"""add_ticket_participants
Revision ID: 46bccd948688
Revises: d2e3f4a5b6c7
Create Date: 2026-03-11 17:59:55.409878
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '46bccd948688'
down_revision = 'd2e3f4a5b6c7'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@@ -0,0 +1,63 @@
"""add_ticket_issues
Revision ID: b2dcb926e091
Revises: 46bccd948688
Create Date: 2026-03-17 16:25:34.452826
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = 'b2dcb926e091'
down_revision = '46bccd948688'
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = inspector.get_table_names()
if 'ticket_issues' not in existing_tables:
op.create_table(
'ticket_issues',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=False),
sa.Column('ticket_id', sa.UUID(), nullable=False),
sa.Column('created_by', sa.UUID(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column(
'priority',
postgresql.ENUM('LOW', 'MEDIUM', 'HIGH', 'URGENT',
name='ticket_priority_enum', create_type=False),
nullable=False,
),
sa.Column('attachment_path', sa.String(500), nullable=True),
sa.Column('attachment_filename', sa.String(255), nullable=True),
sa.Column('attachment_mime_type', sa.String(100), nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True),
server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True),
server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['created_by'], ['users.id']),
sa.PrimaryKeyConstraint('id'),
)
if 'ticket_issue_tagged_users' not in existing_tables:
op.create_table(
'ticket_issue_tagged_users',
sa.Column('issue_id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['issue_id'], ['ticket_issues.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('issue_id', 'user_id'),
)
def downgrade() -> None:
op.drop_table('ticket_issue_tagged_users')
op.drop_table('ticket_issues')

View File

@@ -0,0 +1,35 @@
"""Add CLIENT_MANAGER, CLIENT_AGENT, CLIENT_AUDITOR to user_role_enum
Revision ID: c1d2e3f4a5b6
Revises: b7c8d9e0f1a2
Create Date: 2026-03-03 10:00:00.000000
Agrega tres nuevos roles de cliente al enum PostgreSQL:
- CLIENT_MANAGER → gestiona tickets y usuarios del tenant
- CLIENT_AGENT → atiende tickets del tenant
- CLIENT_AUDITOR → auditoría de solo lectura del tenant
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = 'c1d2e3f4a5b6'
down_revision = 'b7c8d9e0f1a2'
branch_labels = None
depends_on = None
def upgrade() -> None:
# PostgreSQL permite agregar valores a un enum con ADD VALUE.
# IF NOT EXISTS evita error si la migración se aplica dos veces.
op.execute("ALTER TYPE user_role_enum ADD VALUE IF NOT EXISTS 'CLIENT_MANAGER'")
op.execute("ALTER TYPE user_role_enum ADD VALUE IF NOT EXISTS 'CLIENT_AGENT'")
op.execute("ALTER TYPE user_role_enum ADD VALUE IF NOT EXISTS 'CLIENT_AUDITOR'")
def downgrade() -> None:
# PostgreSQL no permite eliminar valores de un enum con ALTER TYPE DROP VALUE.
# Para revertir habría que recrear el tipo completo desde cero, lo que requiere
# actualizar todas las columnas que lo usan. Se documenta como no reversible
# automáticamente — usar con precaución.
pass

View File

@@ -0,0 +1,92 @@
"""Remove CLIENT_MANAGER, CLIENT_AGENT, CLIENT_AUDITOR from user_role_enum
Revision ID: d2e3f4a5b6c7
Revises: c1d2e3f4a5b6
Create Date: 2026-03-03 14:00:00.000000
Consolida 9 roles → 6 roles migrando datos primero y luego recreando
el tipo enum de PostgreSQL (única forma de eliminar valores en PG).
Mapeo de datos:
CLIENT_MANAGER → CLIENT_ADMIN (conserva nivel de gestión)
CLIENT_AGENT → CLIENT_USER (acceso básico de cliente)
CLIENT_AUDITOR → CLIENT_USER (acceso básico de cliente)
ADVERTENCIA DOWNGRADE: La migración inversa restaura los valores del
enum pero NO puede recuperar la distinción original entre CLIENT_AGENT
y CLIENT_AUDITOR (ambos quedaron como CLIENT_USER). El downgrade es
seguro a nivel de integridad de datos, pero irreversible en semántica.
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = 'd2e3f4a5b6c7'
down_revision = 'c1d2e3f4a5b6'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ── Paso 1: Migrar datos ANTES de modificar el tipo ────────────────────
# CLIENT_MANAGER → CLIENT_ADMIN (conserva acceso de gestión)
op.execute("UPDATE users SET role = 'CLIENT_ADMIN' WHERE role = 'CLIENT_MANAGER'")
# CLIENT_AGENT → CLIENT_USER (acceso básico de cliente)
op.execute("UPDATE users SET role = 'CLIENT_USER' WHERE role = 'CLIENT_AGENT'")
# CLIENT_AUDITOR → CLIENT_USER (acceso básico de cliente)
op.execute("UPDATE users SET role = 'CLIENT_USER' WHERE role = 'CLIENT_AUDITOR'")
# ── Paso 2: Soltar la restricción de tipo para poder recrear el enum ───
# PostgreSQL no permite DROP VALUE en un enum; hay que recrear el tipo.
op.execute("ALTER TABLE users ALTER COLUMN role TYPE TEXT")
# ── Paso 3: Eliminar tipo actual y recrearlo solo con los 6 roles ──────
op.execute("DROP TYPE user_role_enum")
op.execute("""
CREATE TYPE user_role_enum AS ENUM (
'ADMIN',
'SUPPORT_MANAGER',
'AGENT',
'AUDITOR',
'CLIENT_ADMIN',
'CLIENT_USER'
)
""")
# ── Paso 4: Restaurar columna al tipo enum ──────────────────────────────
op.execute(
"ALTER TABLE users ALTER COLUMN role TYPE user_role_enum "
"USING role::user_role_enum"
)
def downgrade() -> None:
# ── Paso 1: Soltar la restricción de tipo para recrear el enum ─────────
op.execute("ALTER TABLE users ALTER COLUMN role TYPE TEXT")
# ── Paso 2: Recrear enum con los 9 valores originales ──────────────────
op.execute("DROP TYPE user_role_enum")
op.execute("""
CREATE TYPE user_role_enum AS ENUM (
'ADMIN',
'SUPPORT_MANAGER',
'AGENT',
'AUDITOR',
'CLIENT_ADMIN',
'CLIENT_MANAGER',
'CLIENT_AGENT',
'CLIENT_AUDITOR',
'CLIENT_USER'
)
""")
# ── Paso 3: Restaurar columna al tipo enum ──────────────────────────────
op.execute(
"ALTER TABLE users ALTER COLUMN role TYPE user_role_enum "
"USING role::user_role_enum"
)
# ── Nota sobre pérdida de datos ─────────────────────────────────────────
# Los usuarios que eran CLIENT_MANAGER ahora son CLIENT_ADMIN.
# Los usuarios que eran CLIENT_AGENT o CLIENT_AUDITOR ahora son CLIENT_USER.
# No es posible restaurar la distinción original automáticamente.

View File

@@ -13,9 +13,9 @@ services:
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
#- ./db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
ports:
- "5432:5432"
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-servicemanager}"]
interval: 10s
@@ -215,7 +215,7 @@ services:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- uploads_data:/var/www/uploads:ro
ports:
- "80:80"
- "8088:80"
depends_on:
- backend
- frontend-client

View File

@@ -0,0 +1,11 @@
with open("/app/src/routes/usuarios/+page.svelte", "r") as f:
content = f.read()
content = content.replace(
"{#if (user as any).can_manage_users && user.role !== 'CLIENT_ADMIN'}",
"{#if user.can_manage_users && user.role !== 'CLIENT_ADMIN'}"
)
with open("/app/src/routes/usuarios/+page.svelte", "w") as f:
f.write(content)
print("Listo")

View File

@@ -1,19 +1,37 @@
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const cookie = event.request.headers.get('cookie') ?? '';
if (cookie) {
try {
const apiUrl = process.env.PUBLIC_API_URL ?? 'http://backend:8000';
const response = await fetch(`${apiUrl}/v1/auth/me`, {
headers: { cookie, 'X-App': 'client' }
});
event.locals.user = response.ok ? await response.json() : null;
} catch {
event.locals.user = null;
}
} else {
event.locals.user = null;
}
return resolve(event);
};
// No restaurar sesión en la página de login
if (event.url.pathname === '/login') {
event.locals.user = null;
return resolve(event);
}
const cookieHeader = event.request.headers.get('cookie') ?? '';
const cookieMatch = cookieHeader.match(/(?:client_access_token|internal_access_token)=([^;]+)/);
const token = cookieMatch?.[1];
if (token) {
try {
const apiUrl = process.env.PUBLIC_API_URL ?? 'http://backend:8000';
const response = await fetch(`${apiUrl}/v1/auth/me`, {
headers: {
'Authorization': `Bearer ${token}`,
'X-App': 'client',
'X-Tenant-Slug': ''
}
});
if (response.ok) {
event.locals.user = await response.json();
} else {
event.locals.user = null;
event.cookies.delete('client_access_token', { path: '/' });
event.cookies.delete('internal_access_token', { path: '/' });
}
} catch {
event.locals.user = null;
}
} else {
event.locals.user = null;
}
return resolve(event);
};

View File

@@ -18,7 +18,7 @@
function handleLogout() {
isMenuOpen = false;
auth.logout(); // El store maneja la redirección automática
auth.logout(); // El store maneja la redirección automática
}
</script>
@@ -49,6 +49,13 @@
>
Mis Tickets
</a>
<a
href="/usuarios"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
>
Usuarios
</a>
<a
href="/tickets/new"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
@@ -133,6 +140,13 @@
>
Mis Tickets
</a>
<a
href="/usuarios"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
>
Usuarios
</a>
<a
href="/tickets/new"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"

View File

@@ -0,0 +1,146 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { tickets } from '$lib/stores/tickets';
import { toast } from '$lib/stores/toast';
export let ticketId: string;
const dispatch = createEventDispatcher();
const PRIORITIES = [
{ value: 'LOW', label: 'Baja' },
{ value: 'MEDIUM', label: 'Media' },
{ value: 'HIGH', label: 'Alta' },
{ value: 'URGENT', label: 'Urgente' }
];
let content = '';
let priority = 'MEDIUM';
let file: File | null = null;
let isSubmitting = false;
function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement;
file = input.files?.[0] ?? null;
}
async function handleSubmit() {
if (!content.trim()) {
toast.error('El contenido del asunto es obligatorio');
return;
}
isSubmitting = true;
try {
await tickets.createIssue(ticketId, {
content: content.trim(),
priority,
tagged_user_ids: [],
file
});
toast.success('Asunto creado correctamente');
dispatch('created');
dispatch('close');
} catch (e: any) {
toast.error(e.message || 'Error al crear el asunto');
} finally {
isSubmitting = false;
}
}
function handleClose() {
dispatch('close');
}
</script>
<div class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4">
<div
class="fixed inset-0 bg-gray-500 bg-opacity-75"
on:click={handleClose}
role="button"
tabindex="-1"
on:keydown={(e) => e.key === 'Escape' && handleClose()}
/>
<div class="relative bg-white rounded-lg shadow-xl w-full max-w-lg z-10">
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900">Crear Asunto</h3>
<button type="button" on:click={handleClose} class="text-gray-400 hover:text-gray-500">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="px-6 py-5 space-y-5">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Descripción <span class="text-red-500">*</span>
</label>
<textarea
rows="4"
bind:value={content}
disabled={isSubmitting}
placeholder="Describe el asunto de escalación..."
class="form-input w-full"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Prioridad</label>
<select bind:value={priority} disabled={isSubmitting} class="form-input w-full">
{#each PRIORITIES as p}
<option value={p.value}>{p.label}</option>
{/each}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Adjunto (opcional)
</label>
<input
type="file"
on:change={handleFileChange}
disabled={isSubmitting}
class="block w-full text-sm text-gray-500
file:mr-4 file:py-2 file:px-4 file:rounded-md
file:border-0 file:text-sm file:font-medium
file:bg-blue-50 file:text-blue-700
hover:file:bg-blue-100 disabled:opacity-50"
/>
{#if file}
<p class="text-xs text-gray-500 mt-1">{file.name}</p>
{/if}
</div>
</div>
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-200">
<button
type="button"
on:click={handleClose}
disabled={isSubmitting}
class="btn-secondary px-4 py-2"
>
Cancelar
</button>
<button
type="button"
on:click={handleSubmit}
disabled={isSubmitting || !content.trim()}
class="btn-primary px-4 py-2 disabled:opacity-50"
>
{#if isSubmitting}
<div class="flex items-center gap-2">
<div class="spinner w-4 h-4" />
<span>Creando...</span>
</div>
{:else}
Crear Asunto
{/if}
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,242 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
import { auth } from '$lib/stores/auth';
export let ticketId: string;
export let createdBy: string;
const dispatch = createEventDispatcher();
interface Participant {
id: string;
user_id: string;
ticket_id: string;
role: string;
user_email: string;
user_name: string;
}
interface User {
id: string;
email: string;
first_name: string;
last_name: string;
role: string;
}
let participants: Participant[] = [];
let allUsers: User[] = [];
let isLoading = true;
let isAdding = false;
let searchQuery = '';
let selectedUserId = '';
$: isCreator = $auth.user?.id === createdBy;
$: isGlobalAdmin = $auth.user?.role === 'ADMIN';
$: canManage = isCreator || isGlobalAdmin;
$: participantIds = new Set(participants.map(p => p.user_id));
$: filteredUsers = allUsers.filter(u => {
if (participantIds.has(u.id)) return false;
if (u.id === createdBy) return false;
const q = searchQuery.toLowerCase();
return !q ||
u.email.toLowerCase().includes(q) ||
`${u.first_name} ${u.last_name}`.toLowerCase().includes(q);
});
onMount(async () => {
await Promise.all([loadParticipants(), loadUsers()]);
isLoading = false;
});
async function loadParticipants() {
try {
participants = await api.get(`/tickets/${ticketId}/participants`);
} catch (e: any) {
toast.error('Error cargando participantes');
}
}
async function loadUsers() {
try {
allUsers = await api.get('/users/');
} catch (e: any) {
toast.error('Error cargando usuarios');
}
}
async function addParticipant(userId: string) {
if (!userId) return;
isAdding = true;
try {
const p = await api.post(`/tickets/${ticketId}/participants`, { user_id: userId });
participants = [...participants, p];
searchQuery = '';
toast.success('Participante agregado');
} catch (e: any) {
toast.error(e.message || 'Error al agregar participante');
} finally {
isAdding = false;
}
}
async function removeParticipant(userId: string) {
try {
await api.delete(`/tickets/${ticketId}/participants/${userId}`);
participants = participants.filter(p => p.user_id !== userId);
toast.success('Participante eliminado');
} catch (e: any) {
toast.error(e.message || 'Error al eliminar participante');
}
}
function avatarInitials(name: string, email: string): string {
if (name.trim()) {
const parts = name.trim().split(' ');
return (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase();
}
return email[0].toUpperCase();
}
function avatarColor(email: string): string {
const colors = ['bg-blue-400', 'bg-violet-400', 'bg-emerald-400', 'bg-rose-400', 'bg-amber-400', 'bg-cyan-400'];
let hash = 0;
for (const c of email) hash = (hash << 5) - hash + c.charCodeAt(0);
return colors[Math.abs(hash) % colors.length];
}
</script>
<!-- Overlay -->
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" on:click={() => dispatch('close')}></div>
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-lg z-10 overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<div>
<h2 class="text-lg font-semibold text-gray-900">Participantes del asunto</h2>
<p class="text-xs text-gray-400 mt-0.5">{participants.length} persona{participants.length !== 1 ? 's' : ''} involucrada{participants.length !== 1 ? 's' : ''}</p>
</div>
<button type="button" on:click={() => dispatch('close')}
class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="p-6 space-y-5 max-h-[70vh] overflow-y-auto">
<!-- Buscador para agregar -->
{#if canManage}
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Agregar persona</label>
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input
type="text"
placeholder="Buscar por nombre o email..."
bind:value={searchQuery}
class="w-full pl-9 pr-4 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<!-- Resultados de búsqueda -->
{#if searchQuery && filteredUsers.length > 0}
<div class="mt-2 border border-gray-200 rounded-lg overflow-hidden shadow-sm">
{#each filteredUsers.slice(0, 6) as user (user.id)}
<button
type="button"
on:click={() => addParticipant(user.id)}
disabled={isAdding}
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-blue-50 transition-colors text-left border-b border-gray-100 last:border-0"
>
<div class="w-8 h-8 rounded-full {avatarColor(user.email)} flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
{avatarInitials(`${user.first_name} ${user.last_name}`, user.email)}
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">{user.first_name} {user.last_name}</p>
<p class="text-xs text-gray-400 truncate">{user.email}</p>
</div>
<span class="ml-auto text-xs text-blue-600 font-medium flex-shrink-0">+ Agregar</span>
</button>
{/each}
</div>
{:else if searchQuery && filteredUsers.length === 0}
<p class="mt-2 text-sm text-gray-400 text-center py-2">No se encontraron usuarios disponibles</p>
{/if}
</div>
{/if}
<!-- Lista de participantes -->
<div>
<h3 class="text-sm font-medium text-gray-700 mb-3">
{canManage ? 'Participantes actuales' : 'Personas involucradas'}
</h3>
{#if isLoading}
<div class="flex justify-center py-6">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
</div>
{:else if participants.length === 0}
<div class="text-center py-8 bg-gray-50 rounded-lg border border-dashed border-gray-200">
<svg class="w-10 h-10 mx-auto text-gray-300 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<p class="text-sm text-gray-400">Aún no hay participantes</p>
{#if canManage}
<p class="text-xs text-gray-300 mt-1">Usa el buscador para agregar personas</p>
{/if}
</div>
{:else}
<div class="space-y-2">
{#each participants as p (p.id)}
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg group">
<div class="w-9 h-9 rounded-full {avatarColor(p.user_email)} flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
{avatarInitials(p.user_name, p.user_email)}
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 truncate">{p.user_name || p.user_email}</p>
<p class="text-xs text-gray-400 truncate">{p.user_email}</p>
</div>
<span class="text-xs text-gray-400 bg-white px-2 py-0.5 rounded-full border border-gray-200">
Participante
</span>
{#if canManage}
<button
type="button"
on:click={() => removeParticipant(p.user_id)}
class="p-1 rounded text-gray-300 hover:text-rose-500 hover:bg-rose-50 transition-colors opacity-0 group-hover:opacity-100"
title="Quitar participante"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- Footer -->
<div class="px-6 py-4 bg-gray-50 border-t border-gray-100 flex justify-end">
<button type="button" on:click={() => dispatch('close')}
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
Cerrar
</button>
</div>
</div>
</div>

View File

@@ -1,7 +1,6 @@
import type { Writable } from 'svelte/store';
import { writable } from 'svelte/store';
// Types
export interface User {
id: string;
email: string;
@@ -35,7 +34,6 @@ export interface LoginResponse {
user: User;
}
// Initial state
const initialState: AuthState = {
user: null,
token: null,
@@ -43,100 +41,86 @@ const initialState: AuthState = {
isLoading: false
};
// Create auth store
function createAuthStore() {
const { subscribe, set, update }: Writable<AuthState> = writable(initialState);
let _state = initialState;
subscribe(s => { _state = s; });
return {
subscribe,
// Rehidrata sesión desde cookie HttpOnly (no toca localStorage)
// Verifica sesión activa al cargar la app (cookie HttpOnly)
init: async () => {
if (typeof window !== 'undefined') {
try {
const response = await fetch('/api/v1/auth/me', {
credentials: 'include',
headers: { 'X-App': 'client' }
});
if (response.ok) {
const user = await response.json();
set({
user,
token: null,
isAuthenticated: true,
isLoading: false
});
if (typeof window === 'undefined') return;
try {
const response = await fetch('/api/v1/auth/me', {
credentials: 'include',
headers: {
'X-App': 'client',
// Sin X-Tenant-ID aquí — /auth/me lee el tenant del JWT directamente
}
// 401/400 es esperado cuando no hay sesión activa — no es un error
} catch (error) {
// Ignorar errores de red en init
});
if (response.ok) {
const user = await response.json();
// Token es null — la autenticación viaja por cookie HttpOnly
// El store solo necesita el user para la UI
set({ user, token: null, isAuthenticated: true, isLoading: false });
} else {
// Cookie expirada o inválida — limpiar estado
set(initialState);
}
} catch {
set(initialState);
}
},
// Login
login: async (credentials: LoginRequest): Promise<void> => {
update(state => ({ ...state, isLoading: true }));
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-Tenant-Slug': credentials.tenant_slug,
},
body: JSON.stringify(credentials)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Login failed');
}
const data: LoginResponse = await response.json();
set({
user: data.user,
token: data.access_token,
isAuthenticated: true,
isLoading: false
});
// Guardamos el token en memoria para requests inmediatos
// Si la página se recarga, init() recupera la sesión desde la cookie
set({ user: data.user, token: data.access_token, isAuthenticated: true, isLoading: false });
} catch (error) {
update(state => ({ ...state, isLoading: false }));
throw error;
}
},
// Logout
logout: async () => {
// Llamar al backend para que borre la cookie HttpOnly
try {
const token = _state.token;
await fetch('/api/v1/auth/logout', {
method: 'POST',
credentials: 'include',
headers: { 'X-App': 'client' }
headers: {
'X-App': 'client',
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
});
} catch { /* ignorar errores de red */ }
} catch {}
set(initialState);
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
},
// Update user data
updateUser: (user: User) => {
update(state => ({ ...state, user }));
},
// Set user from SSR pre-load (no fetch required)
setUser: (user: User) => {
set({ user, token: null, isAuthenticated: true, isLoading: false });
},
// Set loading state
setLoading: (isLoading: boolean) => {
update(state => ({ ...state, isLoading }));
}
updateUser: (user: User) => { update(state => ({ ...state, user })); },
setUser: (user: User) => { set({ user, token: null, isAuthenticated: true, isLoading: false }); },
setLoading: (isLoading: boolean) => { update(state => ({ ...state, isLoading })); }
};
}

View File

@@ -0,0 +1,104 @@
import type { Writable } from 'svelte/store';
import { writable } from 'svelte/store';
export interface User {
id: string;
email: string;
first_name: string;
last_name: string;
tenant_id: string;
role: 'CLIENT_ADMIN' | 'CLIENT_USER';
is_active: boolean;
is_two_factor_enabled: boolean;
created_at: string;
}
export interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
}
export interface LoginRequest {
email: string;
password: string;
tenant_slug: string;
totp_code?: string;
}
export interface LoginResponse {
access_token: string;
token_type: string;
expires_in: number;
user: User;
}
const initialState: AuthState = {
user: null,
token: null,
isAuthenticated: false,
isLoading: false
};
function createAuthStore() {
const { subscribe, set, update }: Writable<AuthState> = writable(initialState);
let _state = initialState;
subscribe(s => { _state = s; });
return {
subscribe,
init: async () => {
if (typeof window !== 'undefined') {
try {
const response = await fetch('/api/v1/auth/me', {
credentials: 'include',
headers: { 'X-App': 'client', 'X-Tenant-Slug': 'aduanasoft' }
});
if (response.ok) {
const user = await response.json();
set({ user, token: null, isAuthenticated: true, isLoading: false });
}
} catch (error) {}
}
},
login: async (credentials: LoginRequest): Promise<void> => {
update(state => ({ ...state, isLoading: true }));
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-Tenant-Slug': credentials.tenant_slug,
},
body: JSON.stringify(credentials)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Login failed');
}
const data: LoginResponse = await response.json();
set({ user: data.user, token: data.access_token, isAuthenticated: true, isLoading: false });
} catch (error) {
update(state => ({ ...state, isLoading: false }));
throw error;
}
},
logout: async () => {
try {
const token = _state.token;
await fetch('/api/v1/auth/logout', {
method: 'POST',
credentials: 'include',
headers: {
'X-App': 'client',
'X-Tenant-Slug': 'aduanasoft',
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
});
} catch {}
set(initialState);
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
},
updateUser: (user: User) => { update(state => ({ ...state, user })); },
setUser: (user: User) => { set({ user, token: null, isAuthenticated: true, isLoading: false }); },
setLoading: (isLoading: boolean) => { update(state => ({ ...state, isLoading })); }
};
}
export const auth = createAuthStore();

View File

@@ -2,7 +2,6 @@ import type { Writable } from 'svelte/store';
import { get, writable } from 'svelte/store';
import { auth } from './auth';
// Types
interface FastAPIValidationError {
loc: (string | number)[];
msg: string;
@@ -24,6 +23,11 @@ export interface Ticket {
updated_at: string;
due_date: string | null;
resolution: string | null;
first_response_at?: string | null;
resolved_at?: string | null;
sla_response_due?: string | null;
sla_resolution_due?: string | null;
created_by?: string;
}
export interface TicketComment {
@@ -50,6 +54,19 @@ export interface TicketAttachment {
uploaded_at: string;
}
export interface TicketIssue {
id: string;
ticket_id: string;
content: string;
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
created_by: string;
created_by_name: string;
tagged_users: { id: string; full_name: string; email: string }[];
attachment_filename: string | null;
created_at: string;
updated_at: string;
}
export interface CreateTicketRequest {
title: string;
description: string;
@@ -62,33 +79,35 @@ export interface TicketsState {
currentTicket: Ticket | null;
comments: TicketComment[];
attachments: TicketAttachment[];
issues: TicketIssue[];
isLoading: boolean;
error: string | null;
}
// Initial state
const initialState: TicketsState = {
tickets: [],
currentTicket: null,
comments: [],
attachments: [],
issues: [],
isLoading: false,
error: null
};
// API helper function
async function apiCall(endpoint: string, options: RequestInit = {}) {
const authState = get(auth);
if (!authState.user) throw new Error('Not authenticated');
if (!authState.user) {
throw new Error('Not authenticated');
}
// Verificar que el usuario sigue autenticado
if (!authState.isAuthenticated) throw new Error('Session expired');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-App': 'client',
...(options.headers as Record<string, string>)
};
// Solo agregar Authorization si el token existe en memoria
// Si no está (recarga de página), la cookie HttpOnly lo maneja
if (authState.token) headers['Authorization'] = `Bearer ${authState.token}`;
if (authState.user.tenant_id) headers['X-Tenant-ID'] = authState.user.tenant_id;
@@ -102,13 +121,11 @@ async function apiCall(endpoint: string, options: RequestInit = {}) {
let errorMessage = 'Request failed';
try {
const error = await response.json();
console.error('❌ API Error Response:', error);
// Manejar diferentes formatos de error de FastAPI
if (error.detail) {
if (Array.isArray(error.detail)) {
// Errores de validación de FastAPI
errorMessage = error.detail.map((e: FastAPIValidationError) => `${e.loc.join('.')}: ${e.msg}`).join(', ');
errorMessage = error.detail
.map((e: FastAPIValidationError) => `${e.loc.join('.')}: ${e.msg}`)
.join(', ');
} else if (typeof error.detail === 'string') {
errorMessage = error.detail;
} else {
@@ -117,34 +134,29 @@ async function apiCall(endpoint: string, options: RequestInit = {}) {
} else {
errorMessage = JSON.stringify(error);
}
} catch (e) {
} catch {
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
}
throw new Error(errorMessage);
}
return response.json();
}
// Create tickets store
function createTicketsStore() {
const { subscribe, set, update }: Writable<TicketsState> = writable(initialState);
const { subscribe, update }: Writable<TicketsState> = writable(initialState);
return {
subscribe,
// Load user's tickets
loadTickets: async () => {
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
update(state => ({ ...state, isLoading: true, error: null }));
try {
const raw = await apiCall('/tickets/');
// El backend devuelve 'subject', el tipo Ticket usa 'title'
const tickets = raw.map((t: any) => ({ ...t, title: t.subject ?? t.title }));
update((state: TicketsState) => ({ ...state, tickets, isLoading: false }));
update(state => ({ ...state, tickets, isLoading: false }));
} catch (error) {
update((state: TicketsState) => ({
update(state => ({
...state,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to load tickets'
@@ -152,78 +164,117 @@ function createTicketsStore() {
}
},
// Load specific ticket with details
loadTicket: async (ticketId: string) => {
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
update(state => ({ ...state, isLoading: true, error: null }));
try {
const [ticketRaw, comments, attachments] = await Promise.all([
const [ticketRaw, comments, attachments, issues] = await Promise.all([
apiCall(`/tickets/${ticketId}`),
apiCall(`/tickets/${ticketId}/comments`),
apiCall(`/tickets/${ticketId}/attachments`)
apiCall(`/tickets/${ticketId}/attachments`),
apiCall(`/tickets/${ticketId}/issues`)
]);
// El backend devuelve 'subject', el tipo Ticket usa 'title'
const ticket = { ...ticketRaw, title: ticketRaw.subject ?? ticketRaw.title };
update((state: TicketsState) => ({
update(state => ({
...state,
currentTicket: ticket,
comments,
attachments,
issues,
isLoading: false
}));
} catch (error) {
update((state: TicketsState) => ({
update(state => ({
...state,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to load ticket'
}));
}
},
// Reload only comments silently (for polling)
reloadComments: async (ticketId: string) => {
try {
const comments = await apiCall(`/tickets/${ticketId}/comments`);
update((state: TicketsState) => ({ ...state, comments }));
} catch (error) {
// Silent fail - no mostrar error en polling
console.error('Error reloading comments:', error);
update(state => ({ ...state, comments }));
} catch {
// Silent fail en polling
}
},
createIssue: async (ticketId: string, data: {
content: string;
priority: string;
tagged_user_ids: string[];
file?: File | null;
}) => {
const authState = get(auth);
if (!authState.user) throw new Error('Not authenticated');
if (!authState.isAuthenticated) throw new Error('Session expired');
const headers: Record<string, string> = { 'X-App': 'client' };
// Solo agregar token si existe — sino la cookie HttpOnly lo maneja
if (authState.token) headers['Authorization'] = `Bearer ${authState.token}`;
if (authState.user.tenant_id) headers['X-Tenant-ID'] = authState.user.tenant_id;
let body: FormData | string;
if (data.file) {
const formData = new FormData();
formData.append('content', data.content);
formData.append('priority', data.priority);
data.tagged_user_ids.forEach(id => formData.append('tagged_user_ids', id));
formData.append('file', data.file);
body = formData;
// NO poner Content-Type — el browser lo agrega con boundary automáticamente
} else {
headers['Content-Type'] = 'application/json';
body = JSON.stringify({
content: data.content,
priority: data.priority,
tagged_user_ids: data.tagged_user_ids
});
}
const response = await fetch(`/api/v1/tickets/${ticketId}/issues`, {
method: 'POST',
credentials: 'include',
headers,
body
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Error creating issue' }));
throw new Error(
typeof error.detail === 'string' ? error.detail : JSON.stringify(error.detail)
);
}
const newIssue = await response.json();
update(state => ({ ...state, issues: [newIssue, ...state.issues] }));
return newIssue;
},
// Create new ticket
createTicket: async (ticket: CreateTicketRequest) => {
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
update(state => ({ ...state, isLoading: true, error: null }));
try {
// Mapear campos del frontend al formato del backend
const ticketData = {
subject: ticket.title, // ← Backend espera "subject" no "title"
subject: ticket.title,
description: ticket.description,
category_id: ticket.category_id,
priority: ticket.priority,
system_id: null // ← Opcional
system_id: null
};
console.log('Sending ticket data:', ticketData);
const newTicket = await apiCall('/tickets/', {
method: 'POST',
body: JSON.stringify(ticketData)
});
update((state: TicketsState) => ({
update(state => ({
...state,
tickets: [newTicket, ...state.tickets],
isLoading: false
}));
return newTicket;
} catch (error) {
console.error('Create ticket error:', error);
update((state: TicketsState) => ({
update(state => ({
...state,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to create ticket'
@@ -232,22 +283,18 @@ function createTicketsStore() {
}
},
// Add comment to ticket
addComment: async (ticketId: string, content: string) => {
try {
const comment = await apiCall(`/tickets/${ticketId}/comments`, {
method: 'POST',
body: JSON.stringify({ content })
// is_internal siempre false desde el frontend cliente
// el backend además lo bloquearía si fuera true (fix de seguridad pendiente)
body: JSON.stringify({ content, is_internal: false })
});
update((state: TicketsState) => ({
...state,
comments: [...state.comments, comment]
}));
update(state => ({ ...state, comments: [...state.comments, comment] }));
return comment;
} catch (error) {
update((state: TicketsState) => ({
update(state => ({
...state,
error: error instanceof Error ? error.message : 'Failed to add comment'
}));
@@ -255,17 +302,12 @@ function createTicketsStore() {
}
},
// Upload attachment
uploadAttachment: async (ticketId: string, file: File) => {
try {
const formData = new FormData();
formData.append('file', file);
const authState = get(auth);
if (!authState.user) {
throw new Error('Not authenticated');
}
if (!authState.user) throw new Error('Not authenticated');
const uploadHeaders: Record<string, string> = { 'X-App': 'client' };
if (authState.token) uploadHeaders['Authorization'] = `Bearer ${authState.token}`;
@@ -285,15 +327,10 @@ function createTicketsStore() {
const result = await response.json();
const attachment = result.data || result;
update((state: TicketsState) => ({
...state,
attachments: [...state.attachments, attachment]
}));
update(state => ({ ...state, attachments: [...state.attachments, attachment] }));
return attachment;
} catch (error) {
update((state: TicketsState) => ({
update(state => ({
...state,
error: error instanceof Error ? error.message : 'Failed to upload attachment'
}));
@@ -301,23 +338,20 @@ function createTicketsStore() {
}
},
// Close ticket (client can close their own tickets)
closeTicket: async (ticketId: string, resolution?: string) => {
try {
const updatedTicket = await apiCall(`/tickets/${ticketId}/close`, {
method: 'PATCH',
body: JSON.stringify({ resolution })
body: JSON.stringify({ resolution_notes: resolution })
});
update((state: TicketsState) => ({
update(state => ({
...state,
currentTicket: state.currentTicket?.id === ticketId ? updatedTicket : state.currentTicket,
tickets: state.tickets.map((t: Ticket) => t.id === ticketId ? updatedTicket : t)
tickets: state.tickets.map(t => t.id === ticketId ? updatedTicket : t)
}));
return updatedTicket;
} catch (error) {
update((state: TicketsState) => ({
update(state => ({
...state,
error: error instanceof Error ? error.message : 'Failed to close ticket'
}));
@@ -325,45 +359,36 @@ function createTicketsStore() {
}
},
// Clear error
clearError: () => {
update((state: TicketsState) => ({ ...state, error: null }));
},
clearError: () => { update(state => ({ ...state, error: null })); },
// Clear current ticket
clearCurrentTicket: () => {
update((state: TicketsState) => ({
update(state => ({
...state,
currentTicket: null,
comments: [],
attachments: []
attachments: [],
issues: []
}));
},
// Download attachment
downloadAttachment: async (ticketId: string, attachmentId: string, filename: string) => {
const authState = get(auth);
if (!authState.user) {
throw new Error('Not authenticated');
}
if (!authState.user) throw new Error('Not authenticated');
const dlHeaders: Record<string, string> = { 'X-App': 'client' };
if (authState.token) dlHeaders['Authorization'] = `Bearer ${authState.token}`;
if (authState.user.tenant_id) dlHeaders['X-Tenant-ID'] = authState.user.tenant_id;
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`, {
method: 'GET',
credentials: 'include',
headers: dlHeaders
});
const response = await fetch(
`/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`,
{ method: 'GET', credentials: 'include', headers: dlHeaders }
);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Download failed' }));
throw new Error(error.detail || 'Download failed');
}
// Crear blob y descargar
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');

View File

@@ -1,13 +1,7 @@
/**
* Cliente HTTP centralizado para frontend-client.
* Usa cookies HttpOnly (client_access_token) como fuente primaria de auth,
* con Bearer token como complemento cuando está disponible en memoria.
*/
import { auth } from '$lib/stores/auth';
import { get } from 'svelte/store';
const API_BASE = '/api/v1';
interface RequestOptions extends RequestInit {
params?: Record<string, string>;
}
@@ -20,7 +14,6 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
const filteredParams = Object.entries(params)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
if (Object.keys(filteredParams).length > 0) {
url += `?${new URLSearchParams(filteredParams).toString()}`;
}
@@ -29,18 +22,16 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
const authState = get(auth);
const headers = new Headers(init.headers);
// Bearer header cuando el token está en memoria (sesión activa sin reload)
if (authState.token) {
headers.set('Authorization', `Bearer ${authState.token}`);
}
if (authState.user?.tenant_id && !headers.has('X-Tenant-ID')) {
headers.set('X-Tenant-ID', authState.user.tenant_id);
}
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
// Identifica este frontend para que el backend use client_access_token
headers.set('X-App', 'client');
const slug = get(auth)?.user?.tenant_slug || get(auth)?.user?.tenant_id || '';
headers.set('X-Tenant-Slug', slug);
const response = await fetch(url, {
...init,
@@ -74,10 +65,10 @@ async function downloadFile(endpoint: string, filename: string): Promise<void> {
if (authState.token) {
headers.set('Authorization', `Bearer ${authState.token}`);
}
if (authState.user?.tenant_id) {
headers.set('X-Tenant-ID', authState.user.tenant_id);
}
headers.set('X-App', 'client');
const slug = get(auth)?.user?.tenant_slug || get(auth)?.user?.tenant_id || '';
headers.set('X-Tenant-Slug', slug);
const response = await fetch(`${API_BASE}${endpoint}`, {
method: 'GET',
@@ -108,19 +99,14 @@ async function downloadFile(endpoint: string, filename: string): Promise<void> {
export const api = {
get: <T>(endpoint: string, params?: Record<string, string>) =>
request<T>(endpoint, { method: 'GET', params }),
post: <T>(endpoint: string, body?: any) =>
request<T>(endpoint, { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined }),
put: <T>(endpoint: string, body?: any) =>
request<T>(endpoint, { method: 'PUT', body: body !== undefined ? JSON.stringify(body) : undefined }),
patch: <T>(endpoint: string, body?: any) =>
request<T>(endpoint, { method: 'PATCH', body: body !== undefined ? JSON.stringify(body) : undefined }),
delete: <T>(endpoint: string) =>
request<T>(endpoint, { method: 'DELETE' }),
downloadFile: (endpoint: string, filename: string) =>
downloadFile(endpoint, filename)
};
};

View File

@@ -7,7 +7,7 @@
let email = '';
let password = '';
let tenantSlug = 'aduanasoft-demo';
let tenantSlug = 'ventas';
let totpCode = '';
let isLoading = false;
let showTwoFactor = false;
@@ -34,7 +34,7 @@
await auth.login({
email,
password,
tenant_slug: tenantSlug.trim() || 'aduanasoft-demo',
tenant_slug: tenantSlug.trim() || 'ventas',
totp_code: totpCode || undefined
});
@@ -125,7 +125,7 @@
<div
class="p-3 rounded-md bg-red-50 border border-red-100 flex items-center gap-3 animate-fade-in text-sm text-red-600"
>
<Icon name="alert-circle" class="w-4 h-4 flex-shrink-0" />
<Icon name="alert-circle" className="w-4 h-4 flex-shrink-0" />
{errorMessage}
</div>
{/if}
@@ -141,7 +141,7 @@
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon
name="mail"
class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
className="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
/>
</div>
<input
@@ -166,7 +166,7 @@
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon
name="lock"
class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
className="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
/>
</div>
{#if showPassword}
@@ -197,7 +197,7 @@
class="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer text-gray-400 hover:text-gray-600 focus:outline-none"
on:click={() => (showPassword = !showPassword)}
>
<Icon name={showPassword ? 'eye-off' : 'eye'} class="w-5 h-5" />
<Icon name={showPassword ? 'eye-off' : 'eye'} className="w-5 h-5" />
</button>
</div>
</div>
@@ -221,7 +221,7 @@
class="text-sm font-medium text-blue-600 hover:text-blue-500 bg-transparent border-none p-0 cursor-pointer"
on:click={() => goto('/forgot-password')}
>
Olvide mi clave
Olvidé mi clave
</button>
</div>
</div>
@@ -235,7 +235,7 @@
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon name="shield-check" class="w-5 h-5 text-blue-500" />
<Icon name="shield-check" className="w-5 h-5 text-blue-500" />
</div>
<input
id="code"
@@ -259,7 +259,7 @@
disabled={isLoading}
>
{#if isLoading}
<Icon name="loader-2" class="w-5 h-5 animate-spin mr-2" />
<Icon name="loader-2" className="w-5 h-5 animate-spin mr-2" />
Procesando...
{:else}
{showTwoFactor ? 'Verificar Acceso' : 'Acceder al Portal'}

View File

@@ -5,6 +5,7 @@
import { tickets } from '$lib/stores/tickets.js';
import { toast } from '$lib/stores/toast.js';
import { goto } from '$app/navigation';
import IssueModal from '$lib/components/IssueModal.svelte';
let ticketId: string;
let newComment = '';
@@ -14,55 +15,38 @@
let closeResolution = '';
let fileInput: HTMLInputElement;
let isUploading = false;
let showAttachments = true;
let showIssueModal = false;
let pollingInterval: any = null;
let showAttachments = true; // Variable para controlar la visibilidad de attachments
async function loadComments() {
try {
await tickets.reloadComments(ticketId);
} catch (error) {
// No mostrar error en polling silencioso
console.error('Error recargando comentarios:', error);
}
}
onMount(() => {
// Redirect if not authenticated
if (!$auth.isAuthenticated) {
goto('/login');
return;
}
ticketId = $page.params.id;
if (ticketId) {
tickets.loadTicket(ticketId);
// Polling cada 3 segundos
pollingInterval = setInterval(() => {
loadComments();
}, 3000);
pollingInterval = setInterval(() => { loadComments(); }, 3000);
}
// Cleanup cuando se desmonte el componente
return () => {
if (pollingInterval) {
clearInterval(pollingInterval);
}
};
return () => { if (pollingInterval) clearInterval(pollingInterval); };
});
// Format date
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleString('es-ES', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
}
// Status mapping
const statusConfig: Record<string, { label: string; class: string }> = {
NEW: { label: 'Nuevo', class: 'badge-new' },
IN_PROGRESS: { label: 'En Progreso', class: 'badge-in-progress' },
@@ -74,7 +58,6 @@
};
const fallbackStatus = { label: 'Desconocido', class: 'badge-new' };
// Priority mapping
const priorityConfig: Record<string, { label: string; class: string }> = {
LOW: { label: 'Baja', class: 'badge-priority-low' },
MEDIUM: { label: 'Media', class: 'badge-priority-medium' },
@@ -85,7 +68,6 @@
async function handleAddComment() {
if (!newComment.trim()) return;
isSubmittingComment = true;
try {
await tickets.addComment(ticketId, newComment.trim());
@@ -102,34 +84,23 @@
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
// Validate file size (max 10MB)
if (file.size > 10 * 1024 * 1024) {
toast.error('El archivo es demasiado grande. Máximo 10MB');
target.value = '';
return;
}
// Validate file type
const allowedTypes = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
'application/msword',
'image/jpeg','image/png','image/gif','image/webp','application/pdf',
'text/plain','application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
];
if (!allowedTypes.includes(file.type)) {
toast.error('Tipo de archivo no permitido');
target.value = '';
return;
}
isUploading = true;
try {
await tickets.uploadAttachment(ticketId, file);
@@ -147,14 +118,11 @@
await tickets.downloadAttachment(ticketId, attachment.id, attachment.original_filename);
toast.success('Descarga iniciada');
} catch (error: any) {
console.error('Download error:', error);
toast.error(error.message || 'Error al descargar el archivo');
}
}
function handleCloseTicket() {
showCloseDialog = true;
}
function handleCloseTicket() { showCloseDialog = true; }
async function confirmCloseTicket() {
isClosingTicket = true;
@@ -175,7 +143,6 @@
closeResolution = '';
}
// Check if user can close ticket
$: canClose =
$tickets.currentTicket &&
['RESOLVED', 'WAITING_CUSTOMER'].includes($tickets.currentTicket.status);
@@ -195,16 +162,6 @@
</div>
{:else if $tickets.error}
<div class="text-center py-12">
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center mx-auto mb-4">
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Error al cargar ticket</h3>
<p class="text-gray-600 mb-4">{$tickets.error}</p>
<button on:click={() => tickets.loadTicket(ticketId)} class="btn-primary px-4 py-2">
@@ -244,32 +201,19 @@
</span>
</div>
</div>
{#if canClose}
<button
on:click={handleCloseTicket}
class="btn-success px-4 py-2"
disabled={isClosingTicket}
>
<button on:click={handleCloseTicket} class="btn-success px-4 py-2" disabled={isClosingTicket}>
Cerrar Ticket
</button>
{/if}
</div>
</div>
<div class="card-content">
<div class="prose max-w-none">
<p class="whitespace-pre-wrap text-gray-700">
{$tickets.currentTicket.description}
</p>
</div>
<p class="whitespace-pre-wrap text-gray-700">{$tickets.currentTicket.description}</p>
{#if $tickets.currentTicket.resolution}
<div class="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<h4 class="font-medium text-green-900 mb-2">Resolución:</h4>
<p class="text-green-800 whitespace-pre-wrap">
{$tickets.currentTicket.resolution}
</p>
<p class="text-green-800 whitespace-pre-wrap">{$tickets.currentTicket.resolution}</p>
</div>
{/if}
</div>
@@ -282,74 +226,34 @@
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 flex items-center space-x-2">
<span>Archivos Adjuntos</span>
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
>
{$tickets.attachments.length} archivo{$tickets.attachments.length !== 1
? 's'
: ''}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{$tickets.attachments.length}
</span>
</h3>
<button
class="text-sm text-gray-500 hover:text-gray-700"
title="Ver/Ocultar archivos adjuntos"
on:click={() => (showAttachments = !showAttachments)}
>
<button class="text-sm text-gray-500 hover:text-gray-700"
on:click={() => (showAttachments = !showAttachments)}>
{showAttachments ? 'Ocultar' : 'Ver'} archivos
</button>
</div>
</div>
{#if showAttachments}
<div class="card-content">
<div class="space-y-3">
{#each $tickets.attachments as attachment}
<div
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
>
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-gray-200 rounded flex items-center justify-center">
<svg
class="w-4 h-4 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-gray-900">
{attachment.original_filename}
</p>
<p class="text-xs text-gray-500">
{Math.round(attachment.size_bytes / 1024)} KB • Subido por {attachment.uploaded_by_name}
{formatDate(attachment.uploaded_at)}
</p>
</div>
</div>
<button
on:click={() => handleDownloadAttachment(attachment)}
class="btn-ghost p-2 hover:bg-blue-100 rounded-md transition-colors"
title="Descargar archivo"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</button>
<div class="card-content space-y-3">
{#each $tickets.attachments as attachment}
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100">
<div>
<p class="text-sm font-medium text-gray-900">{attachment.original_filename}</p>
<p class="text-xs text-gray-500">
{Math.round(attachment.size_bytes / 1024)} KB • {attachment.uploaded_by_name}
</p>
</div>
{/each}
</div>
<button on:click={() => handleDownloadAttachment(attachment)} class="btn-ghost p-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</button>
</div>
{/each}
</div>
{/if}
</div>
@@ -362,107 +266,69 @@
</div>
<div class="card-content">
{#if $tickets.comments.length === 0}
<p class="text-gray-500 text-center py-4">
No hay comentarios aún. ¡Sé el primero en comentar!
</p>
<p class="text-gray-500 text-center py-4">No hay comentarios aún.</p>
{:else}
<div class="space-y-4">
{#each $tickets.comments as comment}
{console.log('Comment:', comment)}
<div class="flex space-x-3">
<div
class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center flex-shrink-0"
>
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center flex-shrink-0">
<span class="text-primary-600 text-xs font-medium">
{comment.author_name
? comment.author_name
.split(' ')
.map(n => n[0])
.join('')
: '??'}
{comment.author_name?.split(' ').map(n => n[0]).join('') ?? '??'}
</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center space-x-2 mb-1">
<span class="text-sm font-medium text-gray-900">
{comment.author_name}
</span>
<span class="text-xs text-gray-500">
{formatDate(comment.created_at)}
</span>
<span class="text-sm font-medium text-gray-900">{comment.author_name}</span>
<span class="text-xs text-gray-500">{formatDate(comment.created_at)}</span>
{#if comment.is_internal}
<span class="bg-red-100 text-red-700 text-xs px-2 py-0.5 rounded">
Interno
</span>
<span class="bg-red-100 text-red-700 text-xs px-2 py-0.5 rounded">Interno</span>
{/if}
</div>
<p class="text-gray-700 whitespace-pre-wrap">
{comment.content}
</p>
<p class="text-gray-700 whitespace-pre-wrap">{comment.content}</p>
</div>
</div>
{/each}
</div>
{/if}
<!-- Add Comment Form -->
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="space-y-4">
<textarea
rows="4"
class="form-input"
placeholder="Escribe tu comentario o respuesta..."
bind:value={newComment}
disabled={isSubmittingComment}
/>
<div class="flex justify-between items-center">
<div class="flex items-center space-x-4">
<input
type="file"
bind:this={fileInput}
on:change={handleFileUpload}
class="hidden"
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.txt,.doc,.docx,.xls,.xlsx"
disabled={isUploading}
/>
<button
type="button"
on:click={() => fileInput.click()}
class="btn-ghost p-2 flex items-center space-x-2"
disabled={isUploading}
>
{#if isUploading}
<div class="spinner w-4 h-4" />
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
/>
</svg>
{/if}
<span class="text-sm">Adjuntar archivo</span>
</button>
</div>
<button
on:click={handleAddComment}
class="btn-primary px-4 py-2"
disabled={isSubmittingComment || !newComment.trim()}
>
{#if isSubmittingComment}
<div class="flex items-center space-x-2">
<div class="spinner w-4 h-4" />
<span>Enviando...</span>
</div>
<div class="mt-6 pt-6 border-t border-gray-200 space-y-4">
<textarea
rows="4"
class="form-input"
placeholder="Escribe tu comentario..."
bind:value={newComment}
disabled={isSubmittingComment}
/>
<div class="flex justify-between items-center">
<div>
<input type="file" bind:this={fileInput} on:change={handleFileUpload}
class="hidden"
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.txt,.doc,.docx,.xls,.xlsx"
disabled={isUploading} />
<button type="button" on:click={() => fileInput.click()}
class="btn-ghost p-2 flex items-center space-x-2" disabled={isUploading}>
{#if isUploading}
<div class="spinner w-4 h-4" />
{:else}
Enviar Comentario
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
{/if}
<span class="text-sm">Adjuntar</span>
</button>
</div>
<button on:click={handleAddComment} class="btn-primary px-4 py-2"
disabled={isSubmittingComment || !newComment.trim()}>
{#if isSubmittingComment}
<div class="flex items-center space-x-2">
<div class="spinner w-4 h-4" />
<span>Enviando...</span>
</div>
{:else}
Enviar
{/if}
</button>
</div>
</div>
</div>
@@ -479,40 +345,30 @@
<div class="card-content space-y-4">
<div>
<dt class="text-sm font-medium text-gray-500">ID del Ticket</dt>
<dd class="text-sm text-gray-900 font-mono">
#{$tickets.currentTicket.id.substring(0, 8)}
</dd>
<dd class="text-sm text-gray-900 font-mono">#{$tickets.currentTicket.id.substring(0, 8)}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Categoría</dt>
<dd class="text-sm text-gray-900">
{$tickets.currentTicket.category_name || 'Sin categoría'}
</dd>
<dd class="text-sm text-gray-900">{$tickets.currentTicket.category_name || 'Sin categoría'}</dd>
</div>
{#if $tickets.currentTicket.assigned_to_name}
<div>
<dt class="text-sm font-medium text-gray-500">Asignado a</dt>
<dd class="text-sm text-gray-900">{$tickets.currentTicket.assigned_to_name}</dd>
</div>
{/if}
<div>
<dt class="text-sm font-medium text-gray-500">Creado</dt>
<dd class="text-sm text-gray-900">{formatDate($tickets.currentTicket.created_at)}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Última actualización</dt>
<dd class="text-sm text-gray-900">{formatDate($tickets.currentTicket.updated_at)}</dd>
</div>
<!-- SLA -->
{#if $tickets.currentTicket.sla_response_due || $tickets.currentTicket.sla_resolution_due}
<div class="pt-3 border-t border-gray-100">
<dt class="text-sm font-medium text-gray-500 mb-2">Tiempos de SLA</dt>
{#if $tickets.currentTicket.sla_response_due}
{@const respDue = new Date($tickets.currentTicket.sla_response_due)}
{@const respVencido = respDue < new Date() && !$tickets.currentTicket.first_response_at}
@@ -528,7 +384,6 @@
</dd>
</div>
{/if}
{#if $tickets.currentTicket.sla_resolution_due}
{@const resDue = new Date($tickets.currentTicket.sla_resolution_due)}
{@const resVencido = resDue < new Date() && !$tickets.currentTicket.resolved_at}
@@ -546,119 +401,81 @@
{/if}
</div>
{/if}
{#if $tickets.currentTicket.due_date}
<div>
<dt class="text-sm font-medium text-gray-500">Fecha límite</dt>
<dd
class="text-sm text-gray-900 {new Date($tickets.currentTicket.due_date) <
new Date()
? 'text-red-600'
: ''}"
>
{formatDate($tickets.currentTicket.due_date)}
{#if new Date($tickets.currentTicket.due_date) < new Date()}
<span class="block text-xs text-red-500">¡Vencido!</span>
{/if}
</dd>
</div>
{/if}
</div>
</div>
<!-- Asuntos -->
<div class="card">
<div class="card-header flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-900">
Asuntos
{#if $tickets.issues.length > 0}
<span class="text-gray-400 font-normal text-sm ml-1">({$tickets.issues.length})</span>
{/if}
</h3>
<button type="button" on:click={() => showIssueModal = true}
class="text-sm text-blue-600 hover:text-blue-700 font-medium">
+ Crear
</button>
</div>
{#if $tickets.issues.length > 0}
<div class="divide-y divide-gray-100">
{#each $tickets.issues as issue}
<div class="card-content py-3">
<p class="text-sm text-gray-700">{issue.content}</p>
<span class="text-xs text-gray-400 mt-1 block">{issue.priority} · {issue.created_by_name}</span>
</div>
{/each}
</div>
{:else}
<div class="card-content">
<p class="text-sm text-gray-500">No hay asuntos creados.</p>
</div>
{/if}
</div>
</div>
</div>
{/if}
</div>
<!-- Issue Modal -->
{#if showIssueModal && $tickets.currentTicket}
<IssueModal
ticketId={$tickets.currentTicket.id}
on:close={() => showIssueModal = false}
on:created={() => showIssueModal = false}
/>
{/if}
<!-- Close Ticket Dialog -->
{#if showCloseDialog}
<div class="fixed inset-0 z-50 overflow-y-auto">
<div
class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"
>
<div
class="fixed inset-0 transition-opacity"
role="dialog"
tabindex="0"
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20">
<div class="fixed inset-0 bg-gray-500 opacity-75"
role="dialog" tabindex="0"
on:click={cancelCloseTicket}
on:keydown={e => e.key === 'Escape' && cancelCloseTicket()}
>
<div class="absolute inset-0 bg-gray-500 opacity-75" />
</div>
<div
class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
>
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-green-100 sm:mx-0 sm:h-10 sm:w-10"
>
<svg
class="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 class="text-lg leading-6 font-medium text-gray-900">Cerrar Ticket</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">
¿Estás seguro de que quieres cerrar este ticket? Esta acción indica que el
problema ha sido resuelto satisfactoriamente.
</p>
</div>
<div class="mt-4">
<label for="close-resolution" class="form-label">
Comentario de cierre (opcional)
</label>
<textarea
id="close-resolution"
rows="3"
class="form-input"
placeholder="Describe cómo se resolvió el problema o agrega comentarios finales..."
bind:value={closeResolution}
disabled={isClosingTicket}
/>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
class="w-full inline-flex justify-center btn-success px-4 py-2 sm:ml-3 sm:w-auto disabled:opacity-50"
disabled={isClosingTicket}
on:click={confirmCloseTicket}
>
on:keydown={e => e.key === 'Escape' && cancelCloseTicket()} />
<div class="relative bg-white rounded-lg shadow-xl sm:max-w-lg w-full z-10 p-6">
<h3 class="text-lg font-medium text-gray-900 mb-2">Cerrar Ticket</h3>
<p class="text-sm text-gray-500 mb-4">
¿Estás seguro de que quieres cerrar este ticket?
</p>
<textarea rows="3" class="form-input w-full mb-4"
placeholder="Comentario de cierre (opcional)..."
bind:value={closeResolution} disabled={isClosingTicket} />
<div class="flex justify-end gap-3">
<button class="btn-secondary px-4 py-2" disabled={isClosingTicket} on:click={cancelCloseTicket}>
Cancelar
</button>
<button class="btn-success px-4 py-2 disabled:opacity-50" disabled={isClosingTicket} on:click={confirmCloseTicket}>
{#if isClosingTicket}
<div class="flex items-center space-x-2">
<div class="spinner w-4 h-4" />
<span>Cerrando...</span>
</div>
<div class="flex items-center gap-2"><div class="spinner w-4 h-4" /><span>Cerrando...</span></div>
{:else}
Cerrar Ticket
{/if}
</button>
<button
type="button"
class="mt-3 w-full inline-flex justify-center btn-secondary px-4 py-2 sm:mt-0 sm:w-auto"
disabled={isClosingTicket}
on:click={cancelCloseTicket}
>
Cancelar
</button>
</div>
</div>
</div>
</div>
{/if}
{/if}

View File

@@ -0,0 +1,548 @@
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { toast } from '$lib/stores/toast.js';
import { goto } from '$app/navigation';
// ---- Tipos ----
interface User {
id: string;
email: string;
first_name: string;
last_name: string;
role: string;
is_active: boolean;
created_at: string;
tenant_id: string;
can_manage_users?: boolean;
}
// ---- Estado ----
let users: User[] = [];
let isLoading = true;
let showModal = false;
let showDeleteConfirm = false;
let modalMode: 'create' | 'edit' = 'create';
let selectedUser: User | null = null;
let searchQuery = '';
let filterRole = '';
let filterStatus = '';
let form = {
first_name: '',
last_name: '',
email: '',
password: '',
role: 'CLIENT_USER',
is_active: true,
can_manage_users: false
};
let isSaving = false;
let isDeleting = false;
// ---- Permisos ----
$: currentRole = $auth.user?.role ?? '';
$: isClientAdmin = currentRole === 'CLIENT_ADMIN';
$: canManageUsers = isClientAdmin;
// ---- Utilidades ----
function apiHeaders(): Record<string, string> {
const h: Record<string, string> = {
'X-App': 'client',
'X-Tenant-Slug': $auth.user?.tenant_slug || $auth.user?.tenant_id || '',
};
if ($auth.token) h['Authorization'] = `Bearer ${$auth.token}`;
return h;
}
// ---- Carga de usuarios ----
async function loadUsers() {
isLoading = true;
try {
const res = await fetch('/api/v1/users/', {
credentials: 'include',
headers: apiHeaders()
});
if (!res.ok) throw new Error((await res.json()).detail || 'Error al cargar usuarios');
users = await res.json();
} catch (e: any) {
toast.error(e.message);
} finally {
isLoading = false;
}
}
onMount(async () => {
if (!$auth.isAuthenticated) { goto('/login'); return; }
await loadUsers();
});
// ---- Filtros reactivos ----
$: filteredUsers = users.filter(u => {
const q = searchQuery.toLowerCase();
const matchSearch = !q ||
u.first_name?.toLowerCase().includes(q) ||
u.last_name?.toLowerCase().includes(q) ||
u.email?.toLowerCase().includes(q);
const matchRole = !filterRole || u.role === filterRole;
const matchStatus = filterStatus === '' ? true :
filterStatus === 'active' ? u.is_active : !u.is_active;
return matchSearch && matchRole && matchStatus;
});
// ---- Modal ----
function openCreate() {
modalMode = 'create';
form = { first_name: '', last_name: '', email: '', password: '', role: 'CLIENT_USER', is_active: true, can_manage_users: false };
showModal = true;
}
function openEdit(user: User) {
modalMode = 'edit';
selectedUser = user;
form = {
first_name: user.first_name || '',
last_name: user.last_name || '',
email: user.email,
password: '',
role: user.role,
is_active: user.is_active,
can_manage_users: user.can_manage_users || false
};
showModal = true;
}
function closeModal() {
showModal = false;
selectedUser = null;
}
// ---- CRUD ----
async function saveUser() {
isSaving = true;
try {
const payload: any = { ...form };
if (modalMode === 'edit' && !payload.password) delete payload.password;
const url = modalMode === 'create'
? '/api/v1/users/'
: `/api/v1/users/${selectedUser?.id}`;
const method = modalMode === 'create' ? 'POST' : 'PUT';
const res = await fetch(url, {
method,
credentials: 'include',
headers: { ...apiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error((await res.json()).detail || 'Error al guardar');
toast.success(modalMode === 'create' ? 'Usuario creado correctamente' : 'Usuario actualizado');
closeModal();
await loadUsers();
} catch (e: any) {
toast.error(e.message);
} finally {
isSaving = false;
}
}
async function toggleActive(user: User) {
try {
const endpoint = user.is_active
? `/api/v1/users/${user.id}`
: `/api/v1/users/${user.id}/activate`;
const method = user.is_active ? 'DELETE' : 'PATCH';
const res = await fetch(endpoint, {
method,
credentials: 'include',
headers: apiHeaders()
});
if (!res.ok) throw new Error((await res.json()).detail || 'Error');
toast.success(user.is_active ? 'Usuario desactivado' : 'Usuario activado');
await loadUsers();
} catch (e: any) {
toast.error(e.message);
}
}
async function deleteUser() {
if (!selectedUser) return;
isDeleting = true;
try {
const res = await fetch(`/api/v1/users/${selectedUser.id}`, {
method: 'DELETE',
credentials: 'include',
headers: apiHeaders()
});
if (!res.ok) throw new Error((await res.json()).detail || 'Error al eliminar');
toast.success('Usuario eliminado');
showDeleteConfirm = false;
selectedUser = null;
await loadUsers();
} catch (e: any) {
toast.error(e.message);
} finally {
isDeleting = false;
}
}
function confirmDelete(user: User) {
selectedUser = user;
showDeleteConfirm = true;
}
// ---- Helpers visuales ----
function roleLabel(role: string): string {
return role === 'CLIENT_ADMIN' ? 'Administrador' : 'Usuario';
}
function roleBadgeClass(role: string): string {
return role === 'CLIENT_ADMIN'
? 'bg-violet-100 text-violet-700 ring-1 ring-violet-200'
: 'bg-sky-50 text-sky-700 ring-1 ring-sky-200';
}
function initials(u: User): string {
const f = u.first_name?.[0] || '';
const l = u.last_name?.[0] || '';
return (f + l).toUpperCase() || u.email[0].toUpperCase();
}
function avatarColor(email: string): string {
const colors = [
'bg-rose-400', 'bg-orange-400', 'bg-amber-400',
'bg-emerald-400', 'bg-teal-400', 'bg-cyan-400',
'bg-blue-400', 'bg-violet-400', 'bg-pink-400'
];
let hash = 0;
for (const c of email) hash = (hash << 5) - hash + c.charCodeAt(0);
return colors[Math.abs(hash) % colors.length];
}
</script>
<svelte:head>
<title>Usuarios - ServiceManager</title>
</svelte:head>
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Encabezado -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
<div>
<h1 class="text-2xl font-bold text-gray-900">Usuarios</h1>
<p class="text-sm text-gray-500 mt-1">
Miembros de tu organización· {users.length} en total
</p>
</div>
{#if isClientAdmin}
<button
type="button"
on:click={openCreate}
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors shadow-sm"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Nuevo usuario
</button>
{/if}
</div>
<!-- Filtros -->
<div class="flex flex-col sm:flex-row gap-3 mb-6">
<div class="relative flex-1">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input
type="text"
placeholder="Buscar por nombre o email..."
bind:value={searchQuery}
class="w-full pl-9 pr-4 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
/>
</div>
<select bind:value={filterRole} class="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">Roles </option>
<option value="CLIENT_ADMIN">Administrador</option>
<option value="CLIENT_USER">Usuario</option>
</select>
<select bind:value={filterStatus} class="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">Estado </option>
<option value="active">Activos</option>
<option value="inactive">Inactivos</option>
</select>
</div>
<!-- Tabla -->
{#if isLoading}
<div class="flex items-center justify-center py-24">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
{:else if filteredUsers.length === 0}
<div class="text-center py-20 bg-white rounded-xl border border-gray-100">
<svg class="w-12 h-12 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<p class="text-gray-500 font-medium">No se encontraron usuarios</p>
<p class="text-sm text-gray-400 mt-1">Intenta ajustar los filtros de búsqueda</p>
</div>
{:else}
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-100 bg-gray-50">
<th class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Usuario</th>
<th class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Rol</th>
<th class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider hidden sm:table-cell">Miembro desde</th>
<th class="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Estado</th>
{#if canManageUsers}
<th class="px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider text-right">Acciones</th>
{/if}
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
{#each filteredUsers as user (user.id)}
<tr class="hover:bg-gray-50/50 transition-colors group">
<!-- Avatar + info -->
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-full {avatarColor(user.email)} flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
{initials(user)}
</div>
<div class="min-w-0">
<p class="font-medium text-gray-900 truncate">
{user.first_name || ''} {user.last_name || ''}
{#if user.id === $auth.user?.id}
<span class="ml-1 text-xs text-gray-400">(tú)</span>
{/if}
</p>
<p class="text-xs text-gray-400 truncate">{user.email}</p>
</div>
</div>
</td>
<!-- Rol -->
<td class="px-6 py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {roleBadgeClass(user.role)}">
{roleLabel(user.role)}
</span>
{#if user.can_manage_users && user.role !== 'CLIENT_ADMIN'}
<span class="ml-1 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-700 ring-1 ring-amber-200">
Gestión
</span>
{/if}
</td>
<!-- Fecha -->
<td class="px-6 py-4 text-gray-500 hidden sm:table-cell">
{user.created_at ? new Date(user.created_at).toLocaleDateString('es-MX', { year: 'numeric', month: 'short', day: 'numeric' }) : '—'}
</td>
<!-- Estado -->
<td class="px-6 py-4">
{#if user.is_active}
<span class="inline-flex items-center gap-1.5 text-xs font-medium text-emerald-700">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
Activo
</span>
{:else}
<span class="inline-flex items-center gap-1.5 text-xs font-medium text-gray-400">
<span class="w-1.5 h-1.5 rounded-full bg-gray-300"></span>
Inactivo
</span>
{/if}
</td>
<!-- Acciones -->
{#if canManageUsers}
<td class="px-6 py-4">
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<!-- Editar -->
<button
type="button"
on:click={() => openEdit(user)}
class="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 transition-colors"
title="Editar usuario"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<!-- Activar/Desactivar -->
<button
type="button"
on:click={() => toggleActive(user)}
class="p-1.5 rounded-lg transition-colors {user.is_active
? 'text-gray-400 hover:text-amber-600 hover:bg-amber-50'
: 'text-gray-400 hover:text-emerald-600 hover:bg-emerald-50'}"
title={user.is_active ? 'Desactivar' : 'Activar'}
disabled={user.id === $auth.user?.id}
>
{#if user.is_active}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>
</svg>
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{/if}
</button>
<!-- Eliminar (solo CLIENT_ADMIN) -->
{#if isClientAdmin && user.id !== $auth.user?.id}
<button
type="button"
on:click={() => confirmDelete(user)}
class="p-1.5 rounded-lg text-gray-400 hover:text-rose-600 hover:bg-rose-50 transition-colors"
title="Eliminar usuario"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
{/if}
</div>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- Modal crear/editar -->
{#if showModal}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" on:click={closeModal}></div>
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-md p-6 z-10">
<div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-semibold text-gray-900">
{modalMode === 'create' ? 'Nuevo usuario' : 'Editar usuario'}
</h2>
<button type="button" on:click={closeModal} class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<form on:submit|preventDefault={saveUser} class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Nombre</label>
<input type="text" bind:value={form.first_name} required
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Apellido</label>
<input type="text" bind:value={form.last_name}
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email" bind:value={form.email} required
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{modalMode === 'create' ? 'Contraseña' : 'Nueva contraseña (dejar vacío para no cambiar)'}
</label>
<input type="password" bind:value={form.password} required={modalMode === 'create'}
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Rol</label>
<select bind:value={form.role}
class="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white">
<option value="CLIENT_USER">Usuario</option>
<option value="CLIENT_ADMIN">Administrador</option>
</select>
</div>
<!-- Permiso de gestión (solo si el rol es CLIENT_USER) -->
{#if form.role === 'CLIENT_USER'}
<div class="flex items-start gap-3 p-3 bg-amber-50 rounded-lg border border-amber-100">
<input
type="checkbox"
id="can_manage_users"
bind:checked={form.can_manage_users}
class="mt-0.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label for="can_manage_users" class="text-sm text-gray-700 cursor-pointer">
<span class="font-medium">Permitir gestión de usuarios</span>
<p class="text-xs text-gray-500 mt-0.5">Podrá crear y editar usuarios, pero no eliminarlos</p>
</label>
</div>
{/if}
{#if modalMode === 'edit'}
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-100">
<input
type="checkbox"
id="is_active"
bind:checked={form.is_active}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label for="is_active" class="text-sm font-medium text-gray-700 cursor-pointer">Usuario activo</label>
</div>
{/if}
<div class="flex justify-end gap-3 pt-2">
<button type="button" on:click={closeModal}
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
Cancelar
</button>
<button type="submit" disabled={isSaving}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-60 transition-colors">
{isSaving ? 'Guardando...' : modalMode === 'create' ? 'Crear usuario' : 'Guardar cambios'}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Confirmacióclsn eliminar -->
{#if showDeleteConfirm && selectedUser}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" on:click={() => showDeleteConfirm = false}></div>
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-sm p-6 z-10">
<div class="w-12 h-12 rounded-full bg-rose-100 flex items-center justify-center mx-auto mb-4">
<svg class="w-6 h-6 text-rose-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
</div>
<h3 class="text-center font-semibold text-gray-900 mb-1">Eliminar usuario</h3>
<p class="text-center text-sm text-gray-500 mb-6">
¿Seguro que quieres eliminar a <strong>{selectedUser.first_name} {selectedUser.last_name}</strong>? Esta acción no se puede deshacer.
</p>
<div class="flex gap-3">
<button type="button" on:click={() => showDeleteConfirm = false}
class="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
Cancelar
</button>
<button type="button" on:click={deleteUser} disabled={isDeleting}
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-rose-600 rounded-lg hover:bg-rose-700 disabled:opacity-60 transition-colors">
{isDeleting ? 'Eliminando...' : 'Eliminar'}
</button>
</div>
</div>
</div>
{/if}

View File

@@ -1,38 +1,35 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
port: 3000,
host: '0.0.0.0',
watch: {
usePolling: true,
interval: 500
},
// HMR: el browser llega al contenedor en el mismo puerto 3000
hmr: {
host: 'localhost',
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3000')
},
// Permitir que Vite sirva archivos del filesystem del contenedor
fs: {
allow: ['/app', '.'],
strict: false
},
proxy: {
'/api': {
target: process.env.PUBLIC_API_URL || 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
preview: {
port: 3000,
host: '0.0.0.0'
},
build: {
target: 'esnext'
}
});
plugins: [sveltekit()],
server: {
port: 3000,
host: '0.0.0.0',
watch: {
usePolling: true,
interval: 500
},
hmr: {
host: 'localhost',
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3000')
},
fs: {
allow: ['/app', '.'],
strict: false
},
proxy: {
'/api': {
target: process.env.PUBLIC_API_URL || 'http://backend:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
preview: {
port: 3000,
host: '0.0.0.0'
},
build: {
target: 'esnext'
}
});

View File

@@ -0,0 +1,230 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
export let ticketId: string;
export let users: any[] = [];
const dispatch = createEventDispatcher();
const PRIORITIES = [
{ value: 'LOW', label: 'Baja' },
{ value: 'MEDIUM', label: 'Media' },
{ value: 'HIGH', label: 'Alta' },
{ value: 'URGENT', label: 'Urgente' }
];
let content = '';
let priority = 'MEDIUM';
let taggedUserIds: string[] = [];
let file: File | null = null;
let isSubmitting = false;
let fileInput: HTMLInputElement;
function toggleUser(userId: string) {
if (taggedUserIds.includes(userId)) {
taggedUserIds = taggedUserIds.filter(id => id !== userId);
} else {
taggedUserIds = [...taggedUserIds, userId];
}
}
function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement;
file = input.files?.[0] ?? null;
}
async function handleSubmit() {
if (!content.trim()) {
toast.error('El contenido del asunto es obligatorio');
return;
}
isSubmitting = true;
try {
// Si hay archivo usamos FormData, si no JSON normal
if (file) {
const formData = new FormData();
formData.append('content', content.trim());
formData.append('priority', priority);
taggedUserIds.forEach(id => formData.append('tagged_user_ids', id));
formData.append('file', file);
await api.postForm(`/tickets/${ticketId}/issues`, formData);
} else {
await api.post(`/tickets/${ticketId}/issues`, {
content: content.trim(),
priority,
tagged_user_ids: taggedUserIds
});
}
toast.success('Asunto creado correctamente');
dispatch('created');
dispatch('close');
} catch (e) {
toast.error(e.message || 'Error al crear el asunto');
} finally {
isSubmitting = false;
}
}
function handleClose() {
dispatch('close');
}
</script>
<!-- Overlay -->
<div class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4">
<!-- Backdrop -->
<div
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
on:click={handleClose}
role="button"
tabindex="-1"
on:keydown={(e) => e.key === 'Escape' && handleClose()}
/>
<!-- Modal -->
<div class="relative bg-white rounded-lg shadow-xl w-full max-w-lg z-10">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900">Crear Asunto</h3>
<button
type="button"
on:click={handleClose}
class="text-gray-400 hover:text-gray-500"
>
<span class="sr-only">Cerrar</span>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Body -->
<div class="px-6 py-5 space-y-5">
<!-- Contenido -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Descripción del asunto <span class="text-red-500">*</span>
</label>
<textarea
rows="4"
bind:value={content}
disabled={isSubmitting}
placeholder="Describe el asunto de escalación..."
class="block w-full rounded-md border border-gray-300 shadow-sm
focus:border-blue-500 focus:ring-blue-500 sm:text-sm p-2
disabled:opacity-50"
/>
</div>
<!-- Prioridad -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Prioridad</label>
<select
bind:value={priority}
disabled={isSubmitting}
class="block w-full rounded-md border border-gray-300 shadow-sm
focus:border-blue-500 focus:ring-blue-500 sm:text-sm p-2
disabled:opacity-50"
>
{#each PRIORITIES as p}
<option value={p.value}>{p.label}</option>
{/each}
</select>
</div>
<!-- Usuarios a etiquetar -->
{#if users.length > 0}
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Etiquetar usuarios
</label>
<div class="max-h-40 overflow-y-auto border border-gray-200 rounded-md divide-y divide-gray-100">
{#each users as user}
<label class="flex items-center gap-3 px-3 py-2 hover:bg-gray-50 cursor-pointer">
<input
type="checkbox"
checked={taggedUserIds.includes(user.id)}
on:change={() => toggleUser(user.id)}
disabled={isSubmitting}
class="h-4 w-4 rounded border-gray-300 text-blue-600
focus:ring-blue-500 disabled:opacity-50"
/>
<div class="flex flex-col">
<span class="text-sm font-medium text-gray-900">
{user.first_name} {user.last_name}
</span>
<span class="text-xs text-gray-500">{user.email}</span>
</div>
</label>
{/each}
</div>
{#if taggedUserIds.length > 0}
<p class="text-xs text-blue-600 mt-1">
{taggedUserIds.length} usuario{taggedUserIds.length > 1 ? 's' : ''} seleccionado{taggedUserIds.length > 1 ? 's' : ''}
</p>
{/if}
</div>
{/if}
<!-- Adjunto -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Adjunto (opcional)
</label>
<input
bind:this={fileInput}
type="file"
on:change={handleFileChange}
disabled={isSubmitting}
class="block w-full text-sm text-gray-500
file:mr-4 file:py-2 file:px-4 file:rounded-md
file:border-0 file:text-sm file:font-medium
file:bg-blue-50 file:text-blue-700
hover:file:bg-blue-100 disabled:opacity-50"
/>
{#if file}
<p class="text-xs text-gray-500 mt-1">{file.name}</p>
{/if}
</div>
</div>
<!-- Footer -->
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-200">
<button
type="button"
on:click={handleClose}
disabled={isSubmitting}
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border
border-gray-300 rounded-md shadow-sm hover:bg-gray-50
disabled:opacity-50"
>
Cancelar
</button>
<button
type="button"
on:click={handleSubmit}
disabled={isSubmitting || !content.trim()}
class="px-4 py-2 text-sm font-medium text-white bg-blue-700 border
border-transparent rounded-md shadow-sm hover:bg-blue-800
focus:outline-none focus:ring-2 focus:ring-offset-2
focus:ring-blue-500 disabled:opacity-50"
>
{#if isSubmitting}
<div class="flex items-center gap-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
<span>Creando...</span>
</div>
{:else}
Crear Asunto
{/if}
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,242 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
import { auth } from '$lib/stores/auth';
export let ticketId: string;
export let createdBy: string;
const dispatch = createEventDispatcher();
interface Participant {
id: string;
user_id: string;
ticket_id: string;
role: string;
user_email: string;
user_name: string;
}
interface User {
id: string;
email: string;
first_name: string;
last_name: string;
role: string;
}
let participants: Participant[] = [];
let allUsers: User[] = [];
let isLoading = true;
let isAdding = false;
let searchQuery = '';
let selectedUserId = '';
$: isCreator = $auth.user?.id === createdBy;
$: isGlobalAdmin = $auth.user?.role === 'ADMIN';
$: canManage = isCreator || isGlobalAdmin;
$: participantIds = new Set(participants.map(p => p.user_id));
$: filteredUsers = allUsers.filter(u => {
if (participantIds.has(u.id)) return false;
if (u.id === createdBy) return false;
const q = searchQuery.toLowerCase();
return !q ||
u.email.toLowerCase().includes(q) ||
`${u.first_name} ${u.last_name}`.toLowerCase().includes(q);
});
onMount(async () => {
await Promise.all([loadParticipants(), loadUsers()]);
isLoading = false;
});
async function loadParticipants() {
try {
participants = await api.get(`/tickets/${ticketId}/participants`);
} catch (e: any) {
toast.error('Error cargando participantes');
}
}
async function loadUsers() {
try {
allUsers = await api.get('/users/');
} catch (e: any) {
toast.error('Error cargando usuarios');
}
}
async function addParticipant(userId: string) {
if (!userId) return;
isAdding = true;
try {
const p = await api.post(`/tickets/${ticketId}/participants`, { user_id: userId });
participants = [...participants, p];
searchQuery = '';
toast.success('Participante agregado');
} catch (e: any) {
toast.error(e.message || 'Error al agregar participante');
} finally {
isAdding = false;
}
}
async function removeParticipant(userId: string) {
try {
await api.delete(`/tickets/${ticketId}/participants/${userId}`);
participants = participants.filter(p => p.user_id !== userId);
toast.success('Participante eliminado');
} catch (e: any) {
toast.error(e.message || 'Error al eliminar participante');
}
}
function avatarInitials(name: string, email: string): string {
if (name.trim()) {
const parts = name.trim().split(' ');
return (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase();
}
return email[0].toUpperCase();
}
function avatarColor(email: string): string {
const colors = ['bg-blue-400', 'bg-violet-400', 'bg-emerald-400', 'bg-rose-400', 'bg-amber-400', 'bg-cyan-400'];
let hash = 0;
for (const c of email) hash = (hash << 5) - hash + c.charCodeAt(0);
return colors[Math.abs(hash) % colors.length];
}
</script>
<!-- Overlay -->
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" on:click={() => dispatch('close')}></div>
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-lg z-10 overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<div>
<h2 class="text-lg font-semibold text-gray-900">Participantes del asunto</h2>
<p class="text-xs text-gray-400 mt-0.5">{participants.length} persona{participants.length !== 1 ? 's' : ''} involucrada{participants.length !== 1 ? 's' : ''}</p>
</div>
<button type="button" on:click={() => dispatch('close')}
class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="p-6 space-y-5 max-h-[70vh] overflow-y-auto">
<!-- Buscador para agregar -->
{#if canManage}
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Agregar persona</label>
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input
type="text"
placeholder="Buscar por nombre o email..."
bind:value={searchQuery}
class="w-full pl-9 pr-4 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<!-- Resultados de búsqueda -->
{#if searchQuery && filteredUsers.length > 0}
<div class="mt-2 border border-gray-200 rounded-lg overflow-hidden shadow-sm">
{#each filteredUsers.slice(0, 6) as user (user.id)}
<button
type="button"
on:click={() => addParticipant(user.id)}
disabled={isAdding}
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-blue-50 transition-colors text-left border-b border-gray-100 last:border-0"
>
<div class="w-8 h-8 rounded-full {avatarColor(user.email)} flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
{avatarInitials(`${user.first_name} ${user.last_name}`, user.email)}
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">{user.first_name} {user.last_name}</p>
<p class="text-xs text-gray-400 truncate">{user.email}</p>
</div>
<span class="ml-auto text-xs text-blue-600 font-medium flex-shrink-0">+ Agregar</span>
</button>
{/each}
</div>
{:else if searchQuery && filteredUsers.length === 0}
<p class="mt-2 text-sm text-gray-400 text-center py-2">No se encontraron usuarios disponibles</p>
{/if}
</div>
{/if}
<!-- Lista de participantes -->
<div>
<h3 class="text-sm font-medium text-gray-700 mb-3">
{canManage ? 'Participantes actuales' : 'Personas involucradas'}
</h3>
{#if isLoading}
<div class="flex justify-center py-6">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
</div>
{:else if participants.length === 0}
<div class="text-center py-8 bg-gray-50 rounded-lg border border-dashed border-gray-200">
<svg class="w-10 h-10 mx-auto text-gray-300 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<p class="text-sm text-gray-400">Aún no hay participantes</p>
{#if canManage}
<p class="text-xs text-gray-300 mt-1">Usa el buscador para agregar personas</p>
{/if}
</div>
{:else}
<div class="space-y-2">
{#each participants as p (p.id)}
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg group">
<div class="w-9 h-9 rounded-full {avatarColor(p.user_email)} flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
{avatarInitials(p.user_name, p.user_email)}
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 truncate">{p.user_name || p.user_email}</p>
<p class="text-xs text-gray-400 truncate">{p.user_email}</p>
</div>
<span class="text-xs text-gray-400 bg-white px-2 py-0.5 rounded-full border border-gray-200">
Participante
</span>
{#if canManage}
<button
type="button"
on:click={() => removeParticipant(p.user_id)}
class="p-1 rounded text-gray-300 hover:text-rose-500 hover:bg-rose-50 transition-colors opacity-0 group-hover:opacity-100"
title="Quitar participante"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- Footer -->
<div class="px-6 py-4 bg-gray-50 border-t border-gray-100 flex justify-end">
<button type="button" on:click={() => dispatch('close')}
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
Cerrar
</button>
</div>
</div>
</div>

View File

@@ -1,23 +1,22 @@
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
import { writable } from 'svelte/store';
// Types
export interface InternalUser {
export interface User {
id: string;
email: string;
first_name: string;
last_name: string;
role: 'ADMIN' | 'SUPPORT_MANAGER' | 'AGENT' | 'AUDITOR';
tenant_id: string;
tenant_slug: string;
role: 'CLIENT_ADMIN' | 'CLIENT_USER';
is_active: boolean;
is_two_factor_enabled: boolean;
created_at: string;
tenant_id: string;
}
export interface AuthState {
user: InternalUser | null;
user: User | null;
token: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
}
@@ -25,49 +24,31 @@ export interface AuthState {
export interface LoginRequest {
email: string;
password: string;
tenant_slug: string;
tenant_slug?: string;
totp_code?: string;
}
export interface LoginResponse {
access_token: string;
refresh_token: string;
token_type: string;
expires_in: number;
user: InternalUser;
user: User;
}
export interface RefreshTokenRequest {
refresh_token: string;
}
export interface TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
}
// Initial state
const initialState: AuthState = {
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false
};
// Create auth store
function createAuthStore() {
const { subscribe, set, update } = writable<AuthState>(initialState);
// Track current state for uso interno (evita dependencias circulares)
const { subscribe, set, update }: Writable<AuthState> = writable(initialState);
let _state = initialState;
subscribe(s => { _state = s; });
return {
subscribe,
// Rehidrata sesión desde cookie HttpOnly (no toca localStorage)
init: async () => {
if (typeof window !== 'undefined') {
try {
@@ -77,128 +58,56 @@ function createAuthStore() {
});
if (response.ok) {
const user = await response.json();
set({
user,
token: null,
refreshToken: null,
isAuthenticated: true,
isLoading: false
});
set({ user, token: null, isAuthenticated: true, isLoading: false });
}
// 401/400 es esperado cuando no hay sesión activa — no es un error
} catch (error) {
// Ignorar errores de red en init
}
} catch (error) { }
}
},
// Login
login: async (credentials: LoginRequest): Promise<void> => {
update(state => ({ ...state, isLoading: true }));
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-App': 'client',
},
body: JSON.stringify(credentials)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Login failed');
}
const data: LoginResponse = await response.json();
set({
user: data.user,
token: data.access_token,
refreshToken: data.refresh_token,
isAuthenticated: true,
isLoading: false
});
set({ user: data.user, token: data.access_token, isAuthenticated: true, isLoading: false });
} catch (error) {
update(state => ({ ...state, isLoading: false }));
throw error;
}
},
// Refresh Session
refreshSession: async (): Promise<void> => {
const currentRefreshToken = _state.refreshToken;
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',
credentials: 'include',
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(state => ({
...state,
token: data.access_token,
isLoading: false
}));
} catch (error) {
update(state => ({ ...state, isLoading: false }));
throw error;
}
},
// Logout
logout: async () => {
// Llamar al backend para que borre la cookie HttpOnly
try {
const token = _state.token;
const slug = _state.user?.tenant_slug || _state.user?.tenant_id || '';
await fetch('/api/v1/auth/logout', {
method: 'POST',
credentials: 'include',
headers: { 'X-App': 'internal' }
headers: {
'X-App': 'internal',
'X-Tenant-Slug': slug,
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
});
} catch { /* ignorar errores de red */ }
} catch { }
set(initialState);
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
},
// Update user data
updateUser: (user: InternalUser) => {
update(state => ({ ...state, user }));
},
// Set user from SSR pre-load (no fetch required)
setUser: (user: InternalUser) => {
set({ user, token: null, refreshToken: null, isAuthenticated: true, isLoading: false });
},
// Set loading state
setLoading: (isLoading: boolean) => {
update(state => ({ ...state, isLoading }));
}
updateUser: (user: User) => { update(state => ({ ...state, user })); },
setUser: (user: User) => { set({ user, token: null, isAuthenticated: true, isLoading: false }); },
setLoading: (isLoading: boolean) => { update(state => ({ ...state, isLoading })); }
};
}

View File

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

View File

@@ -1,10 +1,15 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import IssueModal from '$lib/components/IssueModal.svelte';
import ParticipantsModal from '$lib/components/ParticipantsModal.svelte';
import { toast } from '$lib/stores/toast';
import { api } from '$lib/utils/api';
import { onMount } from 'svelte';
let showParticipants = false;
let showIssueModal = false;
let issues = [];
let ticketId: string;
let ticket = null;
let comments = [];
@@ -42,24 +47,26 @@
}
async function loadData() {
isLoading = true;
try {
const [ticketData, commentsData, attachmentsData, usersData] = await Promise.all([
api.get(`/tickets/${ticketId}`),
api.get(`/tickets/${ticketId}/comments`),
api.get(`/tickets/${ticketId}/attachments`),
api.get('/users/')
]);
ticket = ticketData;
comments = commentsData;
attachments = attachmentsData;
users = usersData;
} catch (e) {
toast.error('Error cargando ticket: ' + (e.message || 'Error desconocido'));
} finally {
isLoading = false;
}
isLoading = true;
try {
const [ticketData, commentsData, attachmentsData, usersData, issuesData] = await Promise.all([
api.get(`/tickets/${ticketId}`),
api.get(`/tickets/${ticketId}/comments`),
api.get(`/tickets/${ticketId}/attachments`),
api.get('/users/'),
api.get(`/tickets/${ticketId}/issues`)
]);
ticket = ticketData;
comments = commentsData;
attachments = attachmentsData;
users = usersData;
issues = issuesData;
} catch (e) {
toast.error('Error cargando ticket: ' + (e.message || 'Error desconocido'));
} finally {
isLoading = false;
}
}
async function handleAddComment() {
if (!newComment.trim()) return;
@@ -288,7 +295,9 @@
{formatDate(comment.created_at)}
</span>
{#if comment.is_internal}
<span class="bg-gray-50 text-red-700 text-xs px-2 py-0.5 rounded border border-red-200">
<span
class="bg-gray-50 text-red-700 text-xs px-2 py-0.5 rounded border border-red-200"
>
Interno
</span>
{/if}
@@ -374,26 +383,80 @@
<dd class="text-sm text-gray-900">{formatDate(ticket.updated_at)}</dd>
</div>
<!-- Participantes -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-5 border-b border-gray-200 flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-900">
Asuntos {#if issues.length > 0}<span class="text-gray-400 font-normal"
>({issues.length})</span
>{/if}
</h3>
<button
type="button"
on:click={() => (showIssueModal = true)}
class="text-sm text-blue-600 hover:text-blue-700 font-medium"
>
Crear asunto
</button>
</div>
{#if issues.length > 0}
<div class="divide-y divide-gray-100">
{#each issues as issue}
<div class="px-6 py-4">
<div class="flex items-start justify-between gap-2">
<p class="text-sm text-gray-700 flex-1">{issue.content}</p>
<span
class="text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 shrink-0"
>
{issue.priority}
</span>
</div>
{#if issue.tagged_users?.length > 0}
<div class="mt-2 flex flex-wrap gap-1">
{#each issue.tagged_users as u}
<span class="text-xs bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full">
{u.full_name}
</span>
{/each}
</div>
{/if}
<p class="text-xs text-gray-400 mt-1">{issue.created_by_name}</p>
</div>
{/each}
</div>
{:else}
<p class="px-6 py-4 text-sm text-gray-500">No hay asuntos creados.</p>
{/if}
</div>
<!-- SLA Information -->
{#if ticket.sla_response_due || ticket.sla_resolution_due}
<div class="pt-4 border-t border-gray-200">
<h4 class="text-sm font-semibold text-gray-900 mb-3">SLA (Acuerdos de Nivel de Servicio)</h4>
<h4 class="text-sm font-semibold text-gray-900 mb-3">
SLA (Acuerdos de Nivel de Servicio)
</h4>
{#if ticket.sla_response_due}
<div class="mb-3">
<dt class="text-xs font-medium text-gray-500">Tiempo de Respuesta</dt>
<dd class="text-sm text-gray-900 mt-1">
{formatDate(ticket.sla_response_due)}
{#if new Date(ticket.sla_response_due) < new Date() && !ticket.sla_response_met}
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-red-700 border border-red-200">
<span
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-red-700 border border-red-200"
>
Vencido
</span>
{:else if ticket.sla_response_met}
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-green-700 border border-green-200">
<span
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-green-700 border border-green-200"
>
Cumplido
</span>
{:else}
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-blue-700 border border-blue-200">
<span
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-blue-700 border border-blue-200"
>
En plazo
</span>
{/if}
@@ -407,15 +470,21 @@
<dd class="text-sm text-gray-900 mt-1">
{formatDate(ticket.sla_resolution_due)}
{#if new Date(ticket.sla_resolution_due) < new Date() && !ticket.sla_resolution_met}
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-red-700 border border-red-200">
<span
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-red-700 border border-red-200"
>
Vencido
</span>
{:else if ticket.sla_resolution_met}
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-green-700 border border-green-200">
<span
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-green-700 border border-green-200"
>
Cumplido
</span>
{:else}
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-blue-700 border border-blue-200">
<span
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-blue-700 border border-blue-200"
>
En plazo
</span>
{/if}
@@ -429,4 +498,22 @@
</div>
</div>
{/if}
{#if showParticipants && ticket}
<ParticipantsModal
ticketId={ticket.id}
createdBy={ticket.created_by}
on:close={() => (showParticipants = false)}
/>
{/if}
{#if showIssueModal && ticket}
<IssueModal
ticketId={ticket.id}
{users}
on:close={() => (showIssueModal = false)}
on:created={() => {
showIssueModal = false;
loadData();
}}
/>
{/if}
</div>

View File

@@ -1,39 +1,36 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
// Puerto: dentro del contenedor siempre 3000; Docker mapea 3001:3000 al host
port: parseInt(process.env.PORT || '3001'),
host: '0.0.0.0',
watch: {
usePolling: true,
interval: 500
},
// HMR: el browser llega al contenedor a través del puerto 3001 del host
hmr: {
host: 'localhost',
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3001')
},
// Permitir que Vite sirva archivos del filesystem del contenedor
fs: {
allow: ['/app', '.'],
strict: false
},
proxy: {
'/api': {
target: process.env.PUBLIC_API_URL || 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
preview: {
port: parseInt(process.env.PORT || '3001'),
host: '0.0.0.0'
},
build: {
target: 'esnext'
}
});
plugins: [sveltekit()],
server: {
port: parseInt(process.env.PORT || '3001'),
host: '0.0.0.0',
watch: {
usePolling: true,
interval: 500
},
hmr: {
host: 'localhost',
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3001')
},
fs: {
allow: ['/app', '.'],
strict: false
},
proxy: {
'/api': {
target: process.env.PUBLIC_API_URL || 'http://backend:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
preview: {
port: parseInt(process.env.PORT || '3001'),
host: '0.0.0.0'
},
build: {
target: 'esnext'
}
});

BIN
migrations.sql Normal file

Binary file not shown.

130
participants_router.py Normal file
View File

@@ -0,0 +1,130 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
import uuid
from app.api import deps
from app.core.database import get_db
from app.models.ticket import Ticket, TicketParticipant
from app.models.user import User
from pydantic import BaseModel
router = APIRouter()
class ParticipantAdd(BaseModel):
user_id: uuid.UUID
class ParticipantResponse(BaseModel):
id: uuid.UUID
user_id: uuid.UUID
ticket_id: uuid.UUID
role: str
user_email: str
user_name: str
class Config:
from_attributes = True
@router.get("/{ticket_id}/participants", response_model=list[ParticipantResponse])
async def list_participants(
ticket_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
):
result = await db.execute(
select(TicketParticipant)
.where(TicketParticipant.ticket_id == ticket_id)
.options(selectinload(TicketParticipant.user))
)
participants = result.scalars().all()
return [
ParticipantResponse(
id=p.id,
user_id=p.user_id,
ticket_id=p.ticket_id,
role=p.role,
user_email=p.user.email,
user_name=f"{p.user.first_name or ''} {p.user.last_name or ''}".strip()
)
for p in participants
]
@router.post("/{ticket_id}/participants", response_model=ParticipantResponse)
async def add_participant(
ticket_id: uuid.UUID,
data: ParticipantAdd,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
):
# Verificar que el ticket existe
ticket_result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
ticket = ticket_result.scalar_one_or_none()
if not ticket:
raise HTTPException(status_code=404, detail="Ticket not found")
# Solo el creador puede agregar participantes
if ticket.created_by != current_user.id and not current_user.role.is_global:
raise HTTPException(status_code=403, detail="Only the ticket creator can add participants")
# Verificar que el usuario existe
user_result = await db.execute(select(User).where(User.id == data.user_id))
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Verificar que no sea duplicado
existing = await db.execute(
select(TicketParticipant).where(
TicketParticipant.ticket_id == ticket_id,
TicketParticipant.user_id == data.user_id
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="User is already a participant")
participant = TicketParticipant(
ticket_id=ticket_id,
user_id=data.user_id,
role="participant"
)
db.add(participant)
await db.commit()
await db.refresh(participant)
return ParticipantResponse(
id=participant.id,
user_id=participant.user_id,
ticket_id=participant.ticket_id,
role=participant.role,
user_email=user.email,
user_name=f"{user.first_name or ''} {user.last_name or ''}".strip()
)
@router.delete("/{ticket_id}/participants/{user_id}", status_code=204)
async def remove_participant(
ticket_id: uuid.UUID,
user_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
):
ticket_result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
ticket = ticket_result.scalar_one_or_none()
if not ticket:
raise HTTPException(status_code=404, detail="Ticket not found")
if ticket.created_by != current_user.id and not current_user.role.is_global:
raise HTTPException(status_code=403, detail="Only the ticket creator can remove participants")
result = await db.execute(
select(TicketParticipant).where(
TicketParticipant.ticket_id == ticket_id,
TicketParticipant.user_id == user_id
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=404, detail="Participant not found")
await db.delete(participant)
await db.commit()

View File

@@ -0,0 +1,43 @@
# Scripts PowerShell en ServiceManagerWeb
## security-test-data.ps1
- **Propósito:** Genera o limpia datos de prueba para análisis de seguridad.
- **Uso:**
- `. ools\security-test-data.ps1 generar` → Genera datos de prueba.
- `. ools\security-test-data.ps1 limpiar` → Limpia los datos de prueba.
- **Funcionamiento:** Verifica que el contenedor backend esté corriendo y ejecuta el script Python correspondiente dentro del contenedor.
## test_critical_sync.ps1
- **Propósito:** Verifica la sincronización de incidentes críticos entre Auditoría y Seguridad.
- **Pasos:**
1. Login como admin y obtiene token.
2. Consulta estadísticas del módulo Auditoría.
3. Consulta estadísticas del módulo Seguridad.
- **Resultado:** Muestra si los incidentes críticos están sincronizados.
## test_tenant_update.ps1
- **Propósito:** Prueba el endpoint de actualización de tenants.
- **Pasos:**
1. Login como admin.
2. Obtiene lista de tenants.
3. Actualiza el tenant (ejemplo: teléfono y status).
- **Resultado:** Verifica que la actualización funcione correctamente.
## test_manual.ps1
- **Propósito:** Pruebas manuales de endpoints clave.
- **Pasos:**
1. Login y obtención de token.
2. Listar categorías.
3. Crear ticket con SLA automático.
- **Resultado:** Permite validar manualmente el flujo de API.
## test_frontend_integration.ps1
- **Propósito:** Verifica la integración entre frontend y backend.
- **Pasos:**
1. Verifica servicios Docker.
2. Login y obtención de token.
3. Verifica tickets con SLA.
- **Resultado:** Confirma que el frontend puede consumir correctamente el backend.
---
**Recomendación:** Conserva estos scripts para testing e integración. Documenta cualquier script nuevo siguiendo este formato.

View File

@@ -0,0 +1,29 @@
# Guía rápida de scripts esenciales
## 1. setup-dev.sh
Configura el entorno de desarrollo completo (servicios, dependencias).
## 2. seed_data.py
Inicializa categorías, sistemas y usuarios demo.
## 3. seed_tickets.py
Genera tickets de prueba (requiere seed_data.py ejecutado).
## 4. run_tests.sh
Ejecuta la suite de tests de integración.
## 5. reset_passwords.py
Resetea contraseñas de usuarios demo para pruebas de login.
## 6. generate_sla_test_data.py
Crea tickets con diferentes estados de SLA.
## 7. generate_security_test_data.py
Genera logs de auditoría de prueba.
## 8. check_tenants.py
Lista y audita los tenants existentes.
---
**Recomendación:** Ejecuta los scripts en este orden para tener un entorno funcional y datos de prueba completos. Elimina los scripts de debugging/manuales si no los necesitas para troubleshooting avanzado.

View File

@@ -1,7 +0,0 @@
from app.models.ticket import Ticket
from app.models import relationships # ensure relationships are loaded
from sqlalchemy import inspect
mapper = inspect(Ticket)
print("Relationships:", [r.key for r in mapper.relationships])
print("Columns:", [c.key for c in mapper.columns])

View File

@@ -1,40 +0,0 @@
"""Check SLA state of tickets and categories"""
import asyncio
import os
import sys
sys.path.insert(0, '/app')
os.chdir('/app')
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
async def run():
database_url = os.environ.get('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager')
engine = create_async_engine(database_url)
async with engine.connect() as c:
print("=== CATEGORIES SLA HOURS ===")
r = await c.execute(text(
"SELECT name, sla_response_hours, sla_resolution_hours "
"FROM ticket_categories "
"ORDER BY name"
))
for row in r.fetchall():
print(f" {row[0]}: response={row[1]}h, resolution={row[2]}h")
print("\n=== TICKETS SLA DATES ===")
r2 = await c.execute(text(
"SELECT ticket_number, category_id, sla_response_due, sla_resolution_due "
"FROM tickets "
"ORDER BY created_at "
"LIMIT 10"
))
for row in r2.fetchall():
print(f" {row[0]}: cat={str(row[1])[:8] if row[1] else 'None'}, sla_resp={row[2]}, sla_res={row[3]}")
await engine.dispose()
asyncio.run(run())

View File

@@ -1,41 +0,0 @@
"""Debug: check if ticket's category_id maps to a valid category and what tenant it belongs to"""
import asyncio
import os
import sys
sys.path.insert(0, '/app')
os.chdir('/app')
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
async def run():
database_url = os.environ.get('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager')
engine = create_async_engine(database_url)
async with engine.connect() as c:
# Check tickets and their category names via direct JOIN
r = await c.execute(text("""
SELECT t.ticket_number, t.category_id,
cat.name as category_name, cat.tenant_id as cat_tenant,
t.tenant_id as ticket_tenant
FROM tickets t
LEFT JOIN ticket_categories cat ON cat.id = t.category_id
WHERE t.category_id IS NOT NULL
LIMIT 10
"""))
print("=== TICKET -> CATEGORY JOIN ===")
for row in r.fetchall():
match = "✓ SAME TENANT" if row[3] == row[4] else "✗ DIFFERENT TENANT"
print(f" {row[0]}: cat_id={str(row[1])[:8]}, cat_name={row[2]}, {match}")
# Check what tenant aduanasoft-demo is
r2 = await c.execute(text("SELECT id, slug FROM tenants WHERE slug='aduanasoft-demo'"))
tenant = r2.fetchone()
print(f"\nTenant aduanasoft-demo: {tenant[0] if tenant else 'NOT FOUND'}")
await engine.dispose()
asyncio.run(run())

View File

@@ -1,49 +0,0 @@
"""Debug: check SQLAlchemy ORM category loading"""
import asyncio
import os
import sys
sys.path.insert(0, '/app')
os.chdir('/app')
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker, selectinload
from sqlalchemy import select
from app.models.ticket import Ticket
from app.models.category import Category
async def run():
database_url = os.environ.get('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager')
engine = create_async_engine(database_url, echo=True)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
# Test selectinload
result = await session.execute(
select(Ticket)
.options(selectinload(Ticket.category))
.where(Ticket.category_id != None)
.limit(3)
)
tickets = result.scalars().all()
print(f"\n=== ORM RESULTS ({len(tickets)} tickets) ===")
for t in tickets:
print(f" {t.ticket_number}: category_id={t.category_id}, category={t.category}")
if t.category:
print(f" -> category.name={t.category.name}")
else:
print(f" -> category is None!")
# Check if Category model can be queried directly
r2 = await session.execute(select(Category).limit(3))
cats = r2.scalars().all()
print(f"\n=== DIRECT CATEGORY QUERY ({len(cats)} categories) ===")
for c in cats:
print(f" id={c.id}, name={c.name}")
await engine.dispose()
asyncio.run(run())

22
scripts/reset-fabrica.sh Normal file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
# Script para resetear datos a estado de fábrica (solo datos, no afecta estructura ni funcionalidad)
set -e
echo "🧹 Reseteando datos del sistema..."
# 1. Eliminar datos de tickets, comentarios, logs, auditoría, uploads
# (Ejemplo: usando comandos SQL directos desde el contenedor)
docker-compose exec backend psql -U servicemanager -d servicemanager -c "TRUNCATE tickets, ticket_comments, audit_logs, uploads RESTART IDENTITY CASCADE;"
echo "✅ Datos eliminados."
# 2. Volver a poblar datos demo
docker-compose exec backend python scripts/seed_data.py
docker-compose exec backend python scripts/seed_tickets.py
docker-compose exec backend python scripts/generate_sla_test_data.py
docker-compose exec backend python scripts/generate_security_test_data.py
docker-compose exec backend python scripts/reset_passwords.py
echo "🎉 Sistema restaurado a estado de fábrica demo."