Compare commits

...

17 Commits

58 changed files with 4039 additions and 1235 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

@@ -92,7 +92,7 @@ async def get_current_user(
# 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 user.role.is_client 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
# 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:
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",
)
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)
# 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,6 +7,8 @@ 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
@@ -32,6 +34,7 @@ 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,
@@ -41,13 +44,16 @@ async def create_ticket(
"""Crear un nuevo ticket"""
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)
# Solo CLIENT_USER ve únicamente sus propios tickets.
# CLIENT_ADMIN ve todos los del tenant.
if current_user.role == UserRole.CLIENT_USER:
query = query.where(Ticket.created_by == current_user.id)
@@ -62,22 +68,25 @@ async def get_tickets(skip: int = 0, limit: int = 100, status: Optional[str] = N
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 (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 == UserRole.SUPPORT_MANAGER:
query = query.where(Ticket.tenant_id == current_user.tenant_id)
@@ -109,7 +118,6 @@ async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter:
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:
@@ -125,21 +133,32 @@ 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.is_client:
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 == UserRole.CLIENT_USER:
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()
@@ -148,12 +167,19 @@ async def get_ticket(ticket_id: str, db: AsyncSession = Depends(get_db), current
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.is_client:
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 == UserRole.CLIENT_USER:
query = query.where(Ticket.created_by == current_user.id)
result = await db.execute(query)
@@ -161,7 +187,11 @@ async def update_ticket(ticket_id: str, ticket: TicketUpdate, db: AsyncSession =
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():
@@ -178,15 +208,26 @@ async def update_ticket(ticket_id: str, ticket: TicketUpdate, db: AsyncSession =
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)
@@ -215,18 +256,28 @@ async def close_ticket(ticket_id: str, close_request: TicketCloseRequest, db: As
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 == UserRole.CLIENT_USER:
query = query.where(Ticket.created_by == current_user.id)
result = await db.execute(query)
@@ -234,23 +285,36 @@ async def get_ticket_comments(ticket_id: str, db: AsyncSession = Depends(get_db)
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"]:
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 == UserRole.CLIENT_USER:
query = query.where(Ticket.created_by == current_user.id)
result = await db.execute(query)
@@ -258,6 +322,12 @@ async def create_comment(ticket_id: str, comment: CommentCreate, db: AsyncSessio
if not ticket_obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
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,
@@ -274,15 +344,26 @@ async def create_comment(ticket_id: str, comment: CommentCreate, db: AsyncSessio
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)
@@ -291,64 +372,97 @@ async def delete_ticket(ticket_id: str, db: AsyncSession = Depends(get_db), curr
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 == UserRole.CLIENT_USER 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 == UserRole.CLIENT_USER 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)
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)
@@ -359,55 +473,240 @@ async def upload_attachment(ticket_id: str, file: UploadFile = File(...), db: As
"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 == UserRole.CLIENT_USER 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 == UserRole.CLIENT_USER:
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,
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")
query = select(Ticket).where(Ticket.id == ticket_uuid)
if current_user.role != UserRole.ADMIN:
query = query.where(Ticket.tenant_id == current_user.tenant_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")
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}",
)
new_issue = TicketIssue(
id=uuid.uuid4(), ticket_id=ticket_uuid,
tenant_id=ticket_obj.tenant_id, created_by=current_user.id,
content=issue.content, priority=TicketPriority[issue.priority],
attachment_path=None, attachment_filename=None, attachment_mime_type=None,
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,
)
@router.post("/{ticket_id}/issues/{issue_id}/attachment", status_code=status.HTTP_200_OK)
async def upload_issue_attachment(
ticket_id: str,
issue_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 adjunto a un asunto existente."""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
issue_uuid = validate_uuid_param(issue_id, "issue ID")
result = await db.execute(
select(TicketIssue).where(
TicketIssue.id == issue_uuid,
TicketIssue.ticket_id == ticket_uuid,
)
)
db_issue = result.scalars().first()
if not db_issue:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asunto no encontrado")
if db_issue.created_by != current_user.id and not current_user.role.is_global:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Sin permisos")
upload_tenant_id = db_issue.tenant_id
file_metadata = await file_handler.save_upload(file, upload_tenant_id, ticket_uuid)
db_issue.attachment_path = file_metadata["file_path"]
db_issue.attachment_filename = file_metadata["original_filename"]
db_issue.attachment_mime_type = file_metadata["mime_type"]
db_issue.updated_at = datetime.utcnow()
await db.commit()
return {
"message": "Adjunto subido correctamente",
"attachment_filename": db_issue.attachment_filename,
"attachment_mime_type": db_issue.attachment_mime_type,
}

View File

@@ -148,9 +148,12 @@ async def read_user(
✅ Implementa multi-tenancy: solo permite acceso a usuarios del propio 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 # ✅ Seguridad multi-tenant
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
user = result.scalar_one_or_none()
@@ -187,7 +190,10 @@ async def update_user(
detail="You don't have permission to update users"
)
# Buscar usuario
# 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
@@ -280,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"
@@ -294,7 +301,10 @@ async def delete_user(
detail="You cannot delete yourself"
)
# Buscar usuario
# 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
@@ -325,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
@@ -371,7 +381,10 @@ async def activate_user(
detail="You don't have permission to activate users"
)
# Buscar usuario
# 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

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

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

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

@@ -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,14 +1,32 @@
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const cookie = event.request.headers.get('cookie') ?? '';
if (cookie) {
// 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: { cookie, 'X-App': 'client' }
headers: {
'Authorization': `Bearer ${token}`,
'X-App': 'client',
'X-Tenant-Slug': ''
}
});
event.locals.user = response.ok ? await response.json() : null;
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;
}

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') {
if (typeof window === 'undefined') return;
try {
const response = await fetch('/api/v1/auth/me', {
credentials: 'include',
headers: { 'X-App': 'client' }
headers: {
'X-App': 'client',
// Sin X-Tenant-ID aquí — /auth/me lee el tenant del JWT directamente
}
});
if (response.ok) {
const user = await response.json();
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
// 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,75 +226,35 @@
<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">
<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 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 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-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)}
{Math.round(attachment.size_bytes / 1024)} KB • {attachment.uploaded_by_name}
</p>
</div>
</div>
<button
on:click={() => handleDownloadAttachment(attachment)}
class="btn-ghost p-2 hover:bg-blue-100 rounded-md transition-colors"
title="Descargar archivo"
>
<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"
/>
<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>
</div>
{/if}
</div>
{/if}
@@ -362,104 +266,67 @@
</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">
<div class="mt-6 pt-6 border-t border-gray-200 space-y-4">
<textarea
rows="4"
class="form-input"
placeholder="Escribe tu comentario o respuesta..."
placeholder="Escribe tu comentario..."
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}
<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}
>
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"
/>
<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>
<span class="text-sm">Adjuntar</span>
</button>
</div>
<button
on:click={handleAddComment}
class="btn-primary px-4 py-2"
disabled={isSubmittingComment || !newComment.trim()}
>
<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 Comentario
Enviar
{/if}
</button>
</div>
@@ -467,7 +334,6 @@
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
@@ -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,117 +401,79 @@
{/if}
</div>
{/if}
</div>
</div>
{#if $tickets.currentTicket.due_date}
<div>
<dt class="text-sm font-medium text-gray-500">Fecha límite</dt>
<dd
class="text-sm text-gray-900 {new Date($tickets.currentTicket.due_date) <
new Date()
? 'text-red-600'
: ''}"
>
{formatDate($tickets.currentTicket.due_date)}
{#if new Date($tickets.currentTicket.due_date) < new Date()}
<span class="block text-xs text-red-500">¡Vencido!</span>
<!-- 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}
</dd>
</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>
</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.
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>
</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}
>
<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>

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,6 +1,5 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
@@ -10,19 +9,17 @@ export default defineConfig({
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',
target: process.env.PUBLIC_API_URL || 'http://backend:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}

View File

@@ -0,0 +1,230 @@
<script lang="ts">
import { toast } from '$lib/stores/toast';
import { api } from '$lib/utils/api';
import { createEventDispatcher } from 'svelte';
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 {
// 1. Crear el issue con JSON
const newIssue = await api.post(`/tickets/${ticketId}/issues`, {
content: content.trim(),
priority,
tagged_user_ids: taggedUserIds
});
// 2. Si hay archivo, subirlo en request separado
if (file) {
const formData = new FormData();
formData.append('file', file);
await api.postForm(`/tickets/${ticketId}/issues/${newIssue.id}/attachment`, formData);
}
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
});
}
// 401/400 es esperado cuando no hay sesión activa — no es un error
} catch (error) {
// Ignorar errores de red en init
set({ user, token: null, isAuthenticated: true, isLoading: false });
}
} 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,9 +1,9 @@
<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 = '';
@@ -32,7 +32,7 @@
await auth.login({
email,
password,
tenant_slug: 'aduanasoft-demo',
tenant_slug: 'aduanasoft',
totp_code: totpCode || undefined
});
@@ -61,43 +61,49 @@
}
</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" />
<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" />
<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" />
<Icon name="shield" className="w-4 h-4 text-blue-500" />
<span>256-bit Encryption Enabled</span>
</div>
</div>
@@ -106,7 +112,6 @@
<!-- 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>
@@ -115,8 +120,13 @@
<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" />
<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}
@@ -124,10 +134,16 @@
{#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>
<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
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"
@@ -143,10 +159,16 @@
</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>
<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
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"
@@ -161,11 +183,16 @@
</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="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"
@@ -193,7 +220,7 @@
disabled={isLoading}
>
{#if isLoading}
<Icon name="loader" class="w-4 h-4 animate-spin mr-2" />
<Icon name="loader" className="w-4 h-4 animate-spin mr-2" />
Autenticando...
{:else}
{showTwoFactor ? 'Verificar Token' : 'Entrar al Panel'}
@@ -204,7 +231,9 @@
</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>
<p class="text-[10px] text-gray-400 text-center uppercase tracking-widest">
Aduanasoft Internal Systems © 2024
</p>
</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 = [];
@@ -44,31 +49,35 @@
async function loadData() {
isLoading = true;
try {
const [ticketData, commentsData, attachmentsData, usersData] = await Promise.all([
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('/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;
isSubmittingComment = true;
try {
const comment = await api.post(`/tickets/${ticketId}/comments`, {
content: newComment.trim(),
is_internal: false
const comment = await api.post(`/tickets/${ticketId}/issues`, {
content: content.trim(),
priority,
tagged_user_ids: taggedUserIds
});
comments = [...comments, comment];
newComment = '';
@@ -288,7 +297,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,10 +385,58 @@
<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">
@@ -385,15 +444,21 @@
<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 +472,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 +500,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,29 +1,26 @@
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',
target: process.env.PUBLIC_API_URL || 'http://backend:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}

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