Compare commits

...

15 Commits

50 changed files with 3700 additions and 973 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 globales (is_global) pueden operar en cualquier tenant → omitir chequeo.
# Roles de cliente (is_client) deben coincidir con su propio tenant. # Roles de cliente (is_client) deben coincidir con su propio tenant.
request_tenant_id = getattr(getattr(request, "state", None), "tenant_id", None) 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Tenant header does not match authenticated user", detail="Tenant header does not match authenticated user",

View File

@@ -11,13 +11,14 @@ import uuid
class CategoryCreate(BaseModel): 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 name: str
description: Optional[str] = None description: Optional[str] = None
color: Optional[str] = None color: Optional[str] = None
sla_response_hours: int = 24 sla_response_hours: int = 24
sla_resolution_hours: int = 72 sla_resolution_hours: int = 72
auto_assign_to: Optional[uuid.UUID] = None auto_assign_to: Optional[uuid.UUID] = None
tenant_id: Optional[uuid.UUID] = None
class CategoryUpdate(BaseModel): 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

@@ -284,6 +284,7 @@ async def login(
"last_name": user.last_name, "last_name": user.last_name,
"role": user.role, "role": user.role,
"tenant_id": str(user.tenant_id), "tenant_id": str(user.tenant_id),
"tenant_slug": tenant.slug if tenant else str(user.tenant_id),
"is_active": user.is_active, "is_active": user.is_active,
"is_two_factor_enabled": user.totp_enabled or False, "is_two_factor_enabled": user.totp_enabled or False,
"created_at": user.created_at.isoformat() if user.created_at else None "created_at": user.created_at.isoformat() if user.created_at else None
@@ -480,6 +481,7 @@ async def get_current_user(
"role": user.role.value if hasattr(user.role, 'value') else user.role, "role": user.role.value if hasattr(user.role, 'value') else user.role,
"tenant_id": str(user.tenant_id), "tenant_id": str(user.tenant_id),
"tenant_name": user.tenant.name if user.tenant else None, "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_active": user.is_active,
"is_two_factor_enabled": user.totp_secret is not None, "is_two_factor_enabled": user.totp_secret is not None,
"last_login": user.last_login.isoformat() if user.last_login else None, "last_login": user.last_login.isoformat() if user.last_login else None,

View File

@@ -79,11 +79,16 @@ async def create_category(
✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario. ✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
""" """
# ✅ CORREGIDO: Asignar tenant_id del usuario actual # ADMIN global puede especificar tenant_id; otros usan el suyo
db_category = Category( from app.models.user import UserRole as _R
**category.model_dump(), data = category.model_dump()
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático 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) db.add(db_category)
await db.commit() 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), db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser) current_user = Depends(deps.get_current_active_superuser)
): ):
from app.models.permission import TenantPermission, DEFAULT_PERMISSIONS
from datetime import datetime
# Check existing slug # Check existing slug
query = select(Tenant).where(Tenant.slug == tenant.slug) query = select(Tenant).where(Tenant.slug == tenant.slug)
result = await db.execute(query) result = await db.execute(query)
if result.scalar_one_or_none(): if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Tenant slug already exists") 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) 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.commit()
await db.refresh(db_tenant) await db.refresh(db_tenant)
return db_tenant return db_tenant

View File

@@ -7,6 +7,8 @@ from sqlalchemy.orm import selectinload
from typing import List, Optional from typing import List, Optional
from datetime import datetime, timedelta from datetime import datetime, timedelta
import uuid 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.core.database import get_db
from app.api.deps import get_current_user, get_current_tenant from app.api.deps import get_current_user, get_current_tenant
@@ -19,7 +21,7 @@ from app.models.comment import TicketComment
from app.models.attachment import TicketAttachment from app.models.attachment import TicketAttachment
from app.api.schemas.attachment import AttachmentResponse from app.api.schemas.attachment import AttachmentResponse
from app.api.schemas.ticket import ( from app.api.schemas.ticket import (
TicketCreate, TicketUpdate, TicketResponse, TicketCreate, TicketUpdate, TicketResponse,
TicketCloseRequest, CommentCreate, CommentResponse TicketCloseRequest, CommentCreate, CommentResponse
) )
from app.core.file_handler import file_handler from app.core.file_handler import file_handler
@@ -32,6 +34,7 @@ from app.services.ticket_service import TicketService, get_ticket_service
router = APIRouter() router = APIRouter()
@router.post("/", response_model=TicketResponse, status_code=status.HTTP_201_CREATED) @router.post("/", response_model=TicketResponse, status_code=status.HTTP_201_CREATED)
async def create_ticket( async def create_ticket(
ticket: TicketCreate, ticket: TicketCreate,
@@ -41,16 +44,19 @@ async def create_ticket(
"""Crear un nuevo ticket""" """Crear un nuevo ticket"""
return await ticket_service.create_ticket(ticket, current_user.tenant_id, current_user.id) return await ticket_service.create_ticket(ticket, current_user.tenant_id, current_user.id)
@router.get("/", response_model=List[TicketResponse]) @router.get("/", response_model=List[TicketResponse])
async def get_tickets(skip: int = 0, limit: int = 100, status: Optional[str] = None, priority: Optional[str] = None, async def get_tickets(
db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): 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""" """Obtener tickets con filtros opcionales"""
query = select(Ticket).where(Ticket.tenant_id == current_user.tenant_id) 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: if current_user.role == UserRole.CLIENT_USER:
query = query.where(Ticket.created_by == current_user.id) query = query.where(Ticket.created_by == current_user.id)
query = apply_enum_filter(query, Ticket.status, status, TicketStatus, "status") query = apply_enum_filter(query, Ticket.status, status, TicketStatus, "status")
query = apply_enum_filter(query, Ticket.priority, priority, TicketPriority, "priority") query = apply_enum_filter(query, Ticket.priority, priority, TicketPriority, "priority")
query = query.options( query = query.options(
@@ -59,25 +65,28 @@ async def get_tickets(skip: int = 0, limit: int = 100, status: Optional[str] = N
selectinload(Ticket.assigned_to_user) selectinload(Ticket.assigned_to_user)
) )
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit) query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query) result = await db.execute(query)
tickets = result.scalars().all() tickets = result.scalars().all()
return [ticket_to_dict(t) for t in tickets] return [ticket_to_dict(t) for t in tickets]
@router.get("/admin/all", response_model=List[dict]) @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, async def get_all_tickets_admin(
priority_filter: Optional[str] = None, tenant_id_filter: Optional[str] = None, category_filter: Optional[str] = None, skip: int = 0, limit: int = 100,
assigned_to_filter: Optional[str] = None, search: Optional[str] = None, date_from: Optional[str] = None, status_filter: Optional[str] = None, priority_filter: Optional[str] = None,
date_to: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): 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).""" """Obtener todos los tickets del tenant del administrador (ADMIN/SUPPORT_MANAGER)."""
if current_user.role not in (UserRole.ADMIN, UserRole.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") 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) 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: if current_user.role == UserRole.SUPPORT_MANAGER:
query = query.where(Ticket.tenant_id == current_user.tenant_id) query = query.where(Ticket.tenant_id == current_user.tenant_id)
@@ -104,12 +113,11 @@ async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter:
query = query.where(Ticket.created_at < date_to_parsed) query = query.where(Ticket.created_at < date_to_parsed)
except ValueError: except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid date_to format. Use YYYY-MM-DD") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid date_to format. Use YYYY-MM-DD")
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit) query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query) result = await db.execute(query)
rows = result.all() 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}) category_ids = list({ticket.category_id for ticket, _, _ in rows if ticket.category_id})
categories_map = {} categories_map = {}
if category_ids: if category_ids:
@@ -125,44 +133,66 @@ async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter:
"category_name": categories_map.get(ticket.category_id) if ticket.category_id else None, "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}", "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, "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, "created_at": ticket.created_at, "updated_at": ticket.updated_at,
"sla_resolution_due": ticket.sla_resolution_due, "first_response_at": ticket.first_response_at, "sla_response_due": ticket.sla_response_due, "sla_resolution_due": ticket.sla_resolution_due,
"resolved_at": ticket.resolved_at} "first_response_at": ticket.first_response_at, "resolved_at": ticket.resolved_at}
for ticket, tenant, creator in rows for ticket, tenant, creator in rows
] ]
@router.get("/{ticket_id}", response_model=TicketResponse) @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""" """Obtener un ticket por ID"""
ticket_uuid = validate_uuid_param(ticket_id, "ticket 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) query = select(Ticket).where(Ticket.id == ticket_uuid)
if current_user.role.is_client: 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.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) result = await db.execute(query)
db_ticket = result.scalars().first() db_ticket = result.scalars().first()
if not db_ticket: if not db_ticket:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
return ticket_to_dict(db_ticket) return ticket_to_dict(db_ticket)
@router.patch("/{ticket_id}", response_model=TicketResponse) @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""" """Actualizar un ticket"""
ticket_uuid = validate_uuid_param(ticket_id, "ticket 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) query = select(Ticket).where(Ticket.id == ticket_uuid)
if current_user.role.is_client: 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.where(Ticket.created_by == current_user.id)
result = await db.execute(query) result = await db.execute(query)
db_ticket = result.scalars().first() db_ticket = result.scalars().first()
if not db_ticket: if not db_ticket:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found") 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) update_data = ticket.dict(exclude_unset=True)
for field, value in update_data.items(): for field, value in update_data.items():
if field == "status" and value: if field == "status" and value:
@@ -173,37 +203,48 @@ async def update_ticket(ticket_id: str, ticket: TicketUpdate, db: AsyncSession =
setattr(db_ticket, field, uuid.UUID(value)) setattr(db_ticket, field, uuid.UUID(value))
elif value is not None: elif value is not None:
setattr(db_ticket, field, value) setattr(db_ticket, field, value)
db_ticket.updated_at = datetime.utcnow() db_ticket.updated_at = datetime.utcnow()
await db.commit() await db.commit()
await db.refresh(db_ticket, ["category", "affected_system", "assigned_to_user"]) 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} new_values = {
await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id, "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, 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) return ticket_to_dict(db_ticket)
@router.patch("/{ticket_id}/close", response_model=TicketResponse) @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""" """Cerrar un ticket"""
ticket_uuid = validate_uuid_param(ticket_id, "ticket 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) query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
result = await db.execute(query) result = await db.execute(query)
db_ticket = result.scalars().first() db_ticket = result.scalars().first()
if not db_ticket: if not db_ticket:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found")
if db_ticket.status in [TicketStatus.CLOSED, TicketStatus.RESOLVED]: if db_ticket.status in [TicketStatus.CLOSED, TicketStatus.RESOLVED]:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ticket ya está cerrado o resuelto") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ticket ya está cerrado o resuelto")
old_status = db_ticket.status.value old_status = db_ticket.status.value
db_ticket.status = TicketStatus.CLOSED db_ticket.status = TicketStatus.CLOSED
db_ticket.resolved_at = datetime.utcnow() db_ticket.resolved_at = datetime.utcnow()
db_ticket.updated_at = datetime.utcnow() db_ticket.updated_at = datetime.utcnow()
if close_request.resolution_notes: if close_request.resolution_notes:
comment = TicketComment( comment = TicketComment(
id=uuid.uuid4(), ticket_id=ticket_uuid, author_id=current_user.id, id=uuid.uuid4(), ticket_id=ticket_uuid, author_id=current_user.id,
@@ -211,203 +252,461 @@ async def close_ticket(ticket_id: str, close_request: TicketCloseRequest, db: As
is_internal=False, created_at=datetime.utcnow(), updated_at=datetime.utcnow() is_internal=False, created_at=datetime.utcnow(), updated_at=datetime.utcnow()
) )
db.add(comment) db.add(comment)
await db.commit() await db.commit()
await db.refresh(db_ticket, ["category", "affected_system", "assigned_to_user"]) 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, 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) return ticket_to_dict(db_ticket)
@router.get("/{ticket_id}/comments", response_model=List[CommentResponse]) @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""" """Obtener comentarios de un ticket"""
ticket_uuid = validate_uuid_param(ticket_id, "ticket 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) query = select(Ticket).where(Ticket.id == ticket_uuid)
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]: 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.where(Ticket.created_by == current_user.id)
result = await db.execute(query) result = await db.execute(query)
ticket_obj = result.scalars().first() ticket_obj = result.scalars().first()
if not ticket_obj: if not ticket_obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found") 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) result = await db.execute(comments_query)
comments = result.scalars().all() comments = result.scalars().all()
return [ return [
{"id": str(c.id), "ticket_id": str(c.ticket_id), "author_id": str(c.author_id), {"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", "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 for c in comments
] ]
@router.post("/{ticket_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED) @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""" """Crear un comentario en un ticket"""
ticket_uuid = validate_uuid_param(ticket_id, "ticket 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) query = select(Ticket).where(Ticket.id == ticket_uuid)
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]: 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.where(Ticket.created_by == current_user.id)
result = await db.execute(query) result = await db.execute(query)
ticket_obj = result.scalars().first() ticket_obj = result.scalars().first()
if not ticket_obj: if not ticket_obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found") 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( new_comment = TicketComment(
id=uuid.uuid4(), ticket_id=ticket_uuid, author_id=current_user.id, id=uuid.uuid4(), ticket_id=ticket_uuid, author_id=current_user.id,
content=comment.content, is_internal=comment.is_internal, content=comment.content, is_internal=comment.is_internal,
created_at=datetime.utcnow(), updated_at=datetime.utcnow() created_at=datetime.utcnow(), updated_at=datetime.utcnow()
) )
db.add(new_comment) db.add(new_comment)
staff_roles = ["ADMIN", "SUPPORT_MANAGER", "AGENT"] staff_roles = ["ADMIN", "SUPPORT_MANAGER", "AGENT"]
if current_user.role in staff_roles and not comment.is_internal and ticket_obj.first_response_at is None: if current_user.role in staff_roles and not comment.is_internal and ticket_obj.first_response_at is None:
ticket_obj.first_response_at = datetime.utcnow() ticket_obj.first_response_at = datetime.utcnow()
ticket_obj.updated_at = datetime.utcnow() ticket_obj.updated_at = datetime.utcnow()
await db.commit() await db.commit()
await db.refresh(new_comment) await db.refresh(new_comment)
return { 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}", "author_name": f"{current_user.first_name} {current_user.last_name}",
"content": new_comment.content, "is_internal": new_comment.is_internal, "content": new_comment.content, "is_internal": new_comment.is_internal,
"created_at": new_comment.created_at, "updated_at": new_comment.updated_at "created_at": new_comment.created_at, "updated_at": new_comment.updated_at
} }
@router.delete("/{ticket_id}", status_code=status.HTTP_204_NO_CONTENT) @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)""" """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") ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id) query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
result = await db.execute(query) result = await db.execute(query)
db_ticket = result.scalars().first() db_ticket = result.scalars().first()
if not db_ticket: if not db_ticket:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found") 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, old_values = {
"status": db_ticket.status.value, "priority": db_ticket.priority.value} "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.delete(db_ticket)
await db.commit() await db.commit()
await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id, await safe_audit_log(
action="ticket.delete", resource_type="ticket", resource_id=ticket_uuid, old_values=old_values) 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"} return {"message": "Ticket deleted successfully"}
@router.get("/{ticket_id}/attachments", response_model=List[AttachmentResponse]) @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""" """Obtener adjuntos de un ticket"""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") 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() ticket = result.scalar_one_or_none()
if not ticket: if not ticket:
raise HTTPException(status_code=404, detail="Ticket no encontrado") 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") 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() attachments = result.scalars().all()
return [ return [
AttachmentResponse( AttachmentResponse(
id=att.id, ticket_id=att.ticket_id, comment_id=att.comment_id, uploaded_by=att.uploaded_by, id=att.id, ticket_id=att.ticket_id, comment_id=att.comment_id,
filename=att.filename, original_filename=att.original_filename, mime_type=att.mime_type, 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, 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", 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 ) for att in attachments
] ]
@router.post("/{ticket_id}/attachments", status_code=status.HTTP_201_CREATED) @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), async def upload_attachment(
current_user: User = Depends(get_current_user), current_tenant: Tenant = Depends(get_current_tenant)): 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""" """Subir un archivo adjunto a un ticket"""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") 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() ticket = result.scalar_one_or_none()
if not ticket: if not ticket:
raise HTTPException(status_code=404, detail="Ticket no encontrado") 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") 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( attachment = TicketAttachment(
id=uuid.uuid4(), ticket_id=ticket_uuid, uploaded_by=current_user.id, filename=file_metadata["filename"], id=uuid.uuid4(), ticket_id=ticket_uuid, uploaded_by=current_user.id,
original_filename=file_metadata["original_filename"], mime_type=file_metadata["mime_type"], filename=file_metadata["filename"], original_filename=file_metadata["original_filename"],
file_size=file_metadata["file_size"], file_path=file_metadata["file_path"], mime_type=file_metadata["mime_type"], file_size=file_metadata["file_size"],
md5_hash=file_metadata["md5_hash"], sha256_hash=file_metadata["sha256_hash"], created_at=datetime.utcnow() 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) db.add(attachment)
await db.commit() await db.commit()
await db.refresh(attachment, ["uploaded_by_user"]) await db.refresh(attachment, ["uploaded_by_user"])
return { return {
"success": True, "message": "Archivo subido exitosamente", "success": True, "message": "Archivo subido exitosamente",
"data": AttachmentResponse( "data": AttachmentResponse(
id=attachment.id, ticket_id=attachment.ticket_id, comment_id=attachment.comment_id, 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, uploaded_by=attachment.uploaded_by, filename=attachment.filename,
mime_type=attachment.mime_type, file_size=attachment.file_size, file_path=attachment.file_path, 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}", 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") @router.get("/{ticket_id}/attachments/{attachment_id}/download")
async def download_attachment(ticket_id: str, attachment_id: str, db: AsyncSession = Depends(get_db), async def download_attachment(
current_user: User = Depends(get_current_user), current_tenant: Tenant = Depends(get_current_tenant)): 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""" """Descargar un archivo adjunto"""
import logging import logging
logger = logging.getLogger(__name__) 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") ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
attachment_uuid = validate_uuid_param(attachment_id, "attachment 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() ticket = result.scalar_one_or_none()
if not ticket: if not ticket:
logger.error(f"Ticket not found - ticket_id: {ticket_id}")
raise HTTPException(status_code=404, detail="Ticket no encontrado") 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") 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() attachment = result.scalar_one_or_none()
if not attachment: if not attachment:
logger.error(f"Attachment not found - attachment_id: {attachment_id}")
raise HTTPException(status_code=404, detail="Adjunto no encontrado") 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: try:
file_path = file_handler.get_file_path(attachment.file_path) file_path = file_handler.get_file_path(attachment.file_path)
logger.info(f"Absolute file path: {file_path}")
if not file_path.exists(): 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") raise HTTPException(status_code=404, detail="Archivo no encontrado en el sistema")
except Exception as e: except Exception as e:
logger.error(f"Error getting file path: {str(e)}") logger.error(f"Error getting file path: {str(e)}")
raise raise
logger.info(f"Returning file: {attachment.original_filename}") return FileResponse(
return FileResponse(path=file_path, filename=attachment.original_filename, media_type=attachment.mime_type) 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,10 +148,13 @@ async def read_user(
✅ Implementa multi-tenancy: solo permite acceso a usuarios del propio tenant. ✅ Implementa multi-tenancy: solo permite acceso a usuarios del propio tenant.
""" """
query = select(User).where( if current_user.role.value == 'ADMIN':
User.id == user_id, query = select(User).where(User.id == user_id)
User.tenant_id == current_user.tenant_id # ✅ Seguridad multi-tenant else:
) query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query) result = await db.execute(query)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
@@ -187,11 +190,14 @@ async def update_user(
detail="You don't have permission to update users" detail="You don't have permission to update users"
) )
# Buscar usuario # Buscar usuario - ADMIN global puede editar cualquier tenant
query = select(User).where( if current_user.role.value == "ADMIN":
User.id == user_id, query = select(User).where(User.id == user_id)
User.tenant_id == current_user.tenant_id else:
) query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query) result = await db.execute(query)
db_user = result.scalar_one_or_none() db_user = result.scalar_one_or_none()
@@ -280,8 +286,9 @@ async def delete_user(
- No se puede eliminar a sí mismo - No se puede eliminar a sí mismo
- No se puede eliminar el último ADMIN del tenant - No se puede eliminar el último ADMIN del tenant
""" """
# Verificar permisos - solo ADMIN puede eliminar # Verificar permisos - ADMIN global o CLIENT_ADMIN pueden eliminar
if current_user.role != UserRole.ADMIN: allowed = [UserRole.ADMIN, UserRole.CLIENT_ADMIN]
if current_user.role not in allowed:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can delete users" detail="Only admins can delete users"
@@ -294,11 +301,14 @@ async def delete_user(
detail="You cannot delete yourself" detail="You cannot delete yourself"
) )
# Buscar usuario # Buscar usuario - ADMIN global puede editar cualquier tenant
query = select(User).where( if current_user.role.value == "ADMIN":
User.id == user_id, query = select(User).where(User.id == user_id)
User.tenant_id == current_user.tenant_id else:
) query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query) result = await db.execute(query)
db_user = result.scalar_one_or_none() db_user = result.scalar_one_or_none()
@@ -325,8 +335,8 @@ async def delete_user(
detail="Cannot delete the last active admin of the tenant" detail="Cannot delete the last active admin of the tenant"
) )
# Soft delete # Hard delete
db_user.is_active = False await db.delete(db_user)
await db.commit() await db.commit()
# Registrar eliminación en auditoría # Registrar eliminación en auditoría
@@ -371,11 +381,14 @@ async def activate_user(
detail="You don't have permission to activate users" detail="You don't have permission to activate users"
) )
# Buscar usuario # Buscar usuario - ADMIN global puede editar cualquier tenant
query = select(User).where( if current_user.role.value == "ADMIN":
User.id == user_id, query = select(User).where(User.id == user_id)
User.tenant_id == current_user.tenant_id else:
) query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query) result = await db.execute(query)
db_user = result.scalar_one_or_none() db_user = result.scalar_one_or_none()

View File

@@ -6,7 +6,7 @@ Router principal para la API v1
from fastapi import APIRouter 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() api_router = APIRouter()
@@ -53,6 +53,12 @@ api_router.include_router(
prefix="/tickets", prefix="/tickets",
tags=["tickets"] tags=["tickets"]
) )
# Participants routes (nested under tickets)
api_router.include_router(
participants.router,
prefix="/tickets",
tags=["participants"]
)
# Client Profile routes # Client Profile routes
api_router.include_router( 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.middleware.correlation_id import CorrelationIDMiddleware
from app.core.cache import cache from app.core.cache import cache
from app.core.limiter import limiter from app.core.limiter import limiter
from app.models.issue import TicketIssue, ticket_issue_tagged_users
settings = get_settings() settings = get_settings()
setup_logging() setup_logging()

View File

@@ -3,6 +3,7 @@
from .user import User from .user import User
from .tenant import Tenant from .tenant import Tenant
from .ticket import Ticket from .ticket import Ticket
from .issue import TicketIssue, ticket_issue_tagged_users
from .comment import TicketComment from .comment import TicketComment
from .system import System from .system import System
from .category import Category from .category import Category
@@ -10,12 +11,15 @@ from .client_profile import ClientProfile
from .attachment import TicketAttachment from .attachment import TicketAttachment
from .audit import AuditLog from .audit import AuditLog
from .refresh_token import RefreshToken from .refresh_token import RefreshToken
from .permission import TenantPermission
__all__ = [ __all__ = [
"User", "User",
"Tenant", "Tenant",
"Ticket", "Ticket",
"TicketIssue",
"TicketComment", "TicketComment",
"TenantPermission",
"System", "System",
"Category", "Category",
"ClientProfile", "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'), 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: def __repr__(self) -> str:
return f"<Ticket(id={self.id}, number='{self.ticket_number}', status={self.status})>" return f"<Ticket(id={self.id}, number='{self.ticket_number}', status={self.status})>"
class TicketParticipant(Base):
"""Participantes asignados a un ticket"""
__tablename__ = "ticket_participants"
ticket_id: Mapped[uuid.UUID] = mapped_column(
GUID(),
ForeignKey("tickets.id", ondelete="CASCADE"),
nullable=False
)
user_id: Mapped[uuid.UUID] = mapped_column(
GUID(),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False
)
role: Mapped[str] = mapped_column(String(50), nullable=False, default="participant")
ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="participants")
user: Mapped["User"] = relationship("User")
__table_args__ = (
UniqueConstraint("ticket_id", "user_id", name="uq_ticket_participant"),
)
def __repr__(self):
return f"<TicketParticipant(ticket={self.ticket_id}, user={self.user_id})>"

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" POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - 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: ports:
- "5432:5432" - "5433:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-servicemanager}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-servicemanager}"]
interval: 10s interval: 10s
@@ -215,7 +215,7 @@ services:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- uploads_data:/var/www/uploads:ro - uploads_data:/var/www/uploads:ro
ports: ports:
- "80:80" - "8088:80"
depends_on: depends_on:
- backend - backend
- frontend-client - frontend-client

View File

@@ -1,67 +0,0 @@
import re
with open("/app/app/api/v1/endpoints/auth.py", "r") as f:
content = f.read()
old = ''' # 1. Validar tenant
tenant_result = await db.execute(
select(Tenant).where(Tenant.slug == login_data.tenant_slug)
)
tenant = tenant_result.scalar_one_or_none()
if tenant is None:
logger.warning(
"Login failed - tenant not found",
email=login_data.email,
tenant_slug=login_data.tenant_slug,
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found",
)'''
new = ''' # 1. Validar tenant - por slug si viene, sino detectar por email
if login_data.tenant_slug:
tenant_result = await db.execute(
select(Tenant).where(Tenant.slug == login_data.tenant_slug)
)
tenant = tenant_result.scalar_one_or_none()
if tenant is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found",
)
else:
tenant = None'''
if old in content:
content = content.replace(old, new)
print("OK: bloque tenant reemplazado")
else:
print("ERROR: bloque no encontrado")
# Tambien actualizar la query de usuario para usar tenant o no
old2 = ''' # 2. Buscar usuario en base de datos (aislado por tenant)
query = select(User).where(
User.email == login_data.email,
User.tenant_id == tenant.id,
)'''
new2 = ''' # 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)'''
if old2 in content:
content = content.replace(old2, new2)
print("OK: bloque query reemplazado")
else:
print("ERROR: bloque query no encontrado")
with open("/app/app/api/v1/endpoints/auth.py", "w") as f:
f.write(content)
print("Listo")

View File

@@ -1,23 +0,0 @@
with open("/app/app/api/v1/endpoints/auth.py", "r") as f:
content = f.read()
old = ''' # Rate limiting (best-effort): by (tenant,email) to slow brute force.
ident_key = None
if settings.RATE_LIMIT_ENABLED and not settings.TESTING:
email_norm = login_data.email.strip().lower()
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)'''
new = ''' # Rate limiting (best-effort): by (tenant,email) to slow brute force.
ident_key = None
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)'''
if old in content:
content = content.replace(old, new)
print("OK: rate limiting fix aplicado")
else:
print("ERROR: bloque no encontrado")
with open("/app/app/api/v1/endpoints/auth.py", "w") as f:
f.write(content)

View File

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

View File

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

View File

@@ -18,7 +18,7 @@
function handleLogout() { function handleLogout() {
isMenuOpen = false; isMenuOpen = false;
auth.logout(); // El store maneja la redirección automática auth.logout(); // El store maneja la redirección automática
} }
</script> </script>
@@ -49,6 +49,13 @@
> >
Mis Tickets Mis Tickets
</a> </a>
<a
href="/usuarios"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
>
Usuarios
</a>
<a <a
href="/tickets/new" href="/tickets/new"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium" class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
@@ -133,6 +140,13 @@
> >
Mis Tickets Mis Tickets
</a> </a>
<a
href="/usuarios"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium"
>
Usuarios
</a>
<a <a
href="/tickets/new" href="/tickets/new"
class="text-gray-700 hover:text-primary-600 px-3 py-2 text-sm font-medium" 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 type { Writable } from 'svelte/store';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
// Types
export interface User { export interface User {
id: string; id: string;
email: string; email: string;
@@ -35,7 +34,6 @@ export interface LoginResponse {
user: User; user: User;
} }
// Initial state
const initialState: AuthState = { const initialState: AuthState = {
user: null, user: null,
token: null, token: null,
@@ -43,100 +41,86 @@ const initialState: AuthState = {
isLoading: false isLoading: false
}; };
// Create auth store
function createAuthStore() { function createAuthStore() {
const { subscribe, set, update }: Writable<AuthState> = writable(initialState); const { subscribe, set, update }: Writable<AuthState> = writable(initialState);
let _state = initialState;
subscribe(s => { _state = s; });
return { return {
subscribe, subscribe,
// Rehidrata sesión desde cookie HttpOnly (no toca localStorage) // Verifica sesión activa al cargar la app (cookie HttpOnly)
init: async () => { init: async () => {
if (typeof window !== 'undefined') { if (typeof window === 'undefined') return;
try { try {
const response = await fetch('/api/v1/auth/me', { const response = await fetch('/api/v1/auth/me', {
credentials: 'include', credentials: 'include',
headers: { 'X-App': 'client' } headers: {
}); 'X-App': 'client',
if (response.ok) { // Sin X-Tenant-ID aquí — /auth/me lee el tenant del JWT directamente
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) { if (response.ok) {
// Ignorar errores de red en init const user = await response.json();
// Token es null — la autenticación viaja por cookie HttpOnly
// El store solo necesita el user para la UI
set({ user, token: null, isAuthenticated: true, isLoading: false });
} else {
// Cookie expirada o inválida — limpiar estado
set(initialState);
} }
} catch {
set(initialState);
} }
}, },
// Login
login: async (credentials: LoginRequest): Promise<void> => { login: async (credentials: LoginRequest): Promise<void> => {
update(state => ({ ...state, isLoading: true })); update(state => ({ ...state, isLoading: true }));
try { try {
const response = await fetch('/api/v1/auth/login', { const response = await fetch('/api/v1/auth/login', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Tenant-Slug': credentials.tenant_slug,
}, },
body: JSON.stringify(credentials) body: JSON.stringify(credentials)
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.detail || 'Login failed'); throw new Error(error.detail || 'Login failed');
} }
const data: LoginResponse = await response.json(); const data: LoginResponse = await response.json();
// Guardamos el token en memoria para requests inmediatos
set({ // Si la página se recarga, init() recupera la sesión desde la cookie
user: data.user, set({ user: data.user, token: data.access_token, isAuthenticated: true, isLoading: false });
token: data.access_token,
isAuthenticated: true,
isLoading: false
});
} catch (error) { } catch (error) {
update(state => ({ ...state, isLoading: false })); update(state => ({ ...state, isLoading: false }));
throw error; throw error;
} }
}, },
// Logout
logout: async () => { logout: async () => {
// Llamar al backend para que borre la cookie HttpOnly
try { try {
const token = _state.token;
await fetch('/api/v1/auth/logout', { await fetch('/api/v1/auth/logout', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
headers: { 'X-App': 'client' } headers: {
'X-App': 'client',
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
}); });
} catch { /* ignorar errores de red */ } } catch {}
set(initialState); set(initialState);
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.location.href = '/login'; window.location.href = '/login';
} }
}, },
// Update user data updateUser: (user: User) => { update(state => ({ ...state, user })); },
updateUser: (user: User) => { setUser: (user: User) => { set({ user, token: null, isAuthenticated: true, isLoading: false }); },
update(state => ({ ...state, user })); setLoading: (isLoading: boolean) => { update(state => ({ ...state, isLoading })); }
},
// 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 }));
}
}; };
} }

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 { get, writable } from 'svelte/store';
import { auth } from './auth'; import { auth } from './auth';
// Types
interface FastAPIValidationError { interface FastAPIValidationError {
loc: (string | number)[]; loc: (string | number)[];
msg: string; msg: string;
@@ -24,6 +23,11 @@ export interface Ticket {
updated_at: string; updated_at: string;
due_date: string | null; due_date: string | null;
resolution: 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 { export interface TicketComment {
@@ -50,6 +54,19 @@ export interface TicketAttachment {
uploaded_at: string; 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 { export interface CreateTicketRequest {
title: string; title: string;
description: string; description: string;
@@ -62,33 +79,35 @@ export interface TicketsState {
currentTicket: Ticket | null; currentTicket: Ticket | null;
comments: TicketComment[]; comments: TicketComment[];
attachments: TicketAttachment[]; attachments: TicketAttachment[];
issues: TicketIssue[];
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
} }
// Initial state
const initialState: TicketsState = { const initialState: TicketsState = {
tickets: [], tickets: [],
currentTicket: null, currentTicket: null,
comments: [], comments: [],
attachments: [], attachments: [],
issues: [],
isLoading: false, isLoading: false,
error: null error: null
}; };
// API helper function
async function apiCall(endpoint: string, options: RequestInit = {}) { async function apiCall(endpoint: string, options: RequestInit = {}) {
const authState = get(auth); const authState = get(auth);
if (!authState.user) throw new Error('Not authenticated');
if (!authState.user) { // Verificar que el usuario sigue autenticado
throw new Error('Not authenticated'); if (!authState.isAuthenticated) throw new Error('Session expired');
}
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-App': 'client', 'X-App': 'client',
...(options.headers as Record<string, string>) ...(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.token) headers['Authorization'] = `Bearer ${authState.token}`;
if (authState.user.tenant_id) headers['X-Tenant-ID'] = authState.user.tenant_id; 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'; let errorMessage = 'Request failed';
try { try {
const error = await response.json(); const error = await response.json();
console.error('❌ API Error Response:', error);
// Manejar diferentes formatos de error de FastAPI
if (error.detail) { if (error.detail) {
if (Array.isArray(error.detail)) { if (Array.isArray(error.detail)) {
// Errores de validación de FastAPI errorMessage = error.detail
errorMessage = error.detail.map((e: FastAPIValidationError) => `${e.loc.join('.')}: ${e.msg}`).join(', '); .map((e: FastAPIValidationError) => `${e.loc.join('.')}: ${e.msg}`)
.join(', ');
} else if (typeof error.detail === 'string') { } else if (typeof error.detail === 'string') {
errorMessage = error.detail; errorMessage = error.detail;
} else { } else {
@@ -117,34 +134,29 @@ async function apiCall(endpoint: string, options: RequestInit = {}) {
} else { } else {
errorMessage = JSON.stringify(error); errorMessage = JSON.stringify(error);
} }
} catch (e) { } catch {
errorMessage = `HTTP ${response.status}: ${response.statusText}`; errorMessage = `HTTP ${response.status}: ${response.statusText}`;
} }
throw new Error(errorMessage); throw new Error(errorMessage);
} }
return response.json(); return response.json();
} }
// Create tickets store
function createTicketsStore() { function createTicketsStore() {
const { subscribe, set, update }: Writable<TicketsState> = writable(initialState); const { subscribe, update }: Writable<TicketsState> = writable(initialState);
return { return {
subscribe, subscribe,
// Load user's tickets
loadTickets: async () => { loadTickets: async () => {
update((state: TicketsState) => ({ ...state, isLoading: true, error: null })); update(state => ({ ...state, isLoading: true, error: null }));
try { try {
const raw = await apiCall('/tickets/'); 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 })); 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) { } catch (error) {
update((state: TicketsState) => ({ update(state => ({
...state, ...state,
isLoading: false, isLoading: false,
error: error instanceof Error ? error.message : 'Failed to load tickets' 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) => { loadTicket: async (ticketId: string) => {
update((state: TicketsState) => ({ ...state, isLoading: true, error: null })); update(state => ({ ...state, isLoading: true, error: null }));
try { try {
const [ticketRaw, comments, attachments] = await Promise.all([ const [ticketRaw, comments, attachments, issues] = await Promise.all([
apiCall(`/tickets/${ticketId}`), apiCall(`/tickets/${ticketId}`),
apiCall(`/tickets/${ticketId}/comments`), 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 }; const ticket = { ...ticketRaw, title: ticketRaw.subject ?? ticketRaw.title };
update(state => ({
update((state: TicketsState) => ({
...state, ...state,
currentTicket: ticket, currentTicket: ticket,
comments, comments,
attachments, attachments,
issues,
isLoading: false isLoading: false
})); }));
} catch (error) { } catch (error) {
update((state: TicketsState) => ({ update(state => ({
...state, ...state,
isLoading: false, isLoading: false,
error: error instanceof Error ? error.message : 'Failed to load ticket' error: error instanceof Error ? error.message : 'Failed to load ticket'
})); }));
} }
}, },
// Reload only comments silently (for polling)
reloadComments: async (ticketId: string) => { reloadComments: async (ticketId: string) => {
try { try {
const comments = await apiCall(`/tickets/${ticketId}/comments`); const comments = await apiCall(`/tickets/${ticketId}/comments`);
update((state: TicketsState) => ({ ...state, comments })); update(state => ({ ...state, comments }));
} catch (error) { } catch {
// Silent fail - no mostrar error en polling // Silent fail en polling
console.error('Error reloading comments:', error);
} }
}, },
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) => { createTicket: async (ticket: CreateTicketRequest) => {
update((state: TicketsState) => ({ ...state, isLoading: true, error: null })); update(state => ({ ...state, isLoading: true, error: null }));
try { try {
// Mapear campos del frontend al formato del backend
const ticketData = { const ticketData = {
subject: ticket.title, // ← Backend espera "subject" no "title" subject: ticket.title,
description: ticket.description, description: ticket.description,
category_id: ticket.category_id, category_id: ticket.category_id,
priority: ticket.priority, priority: ticket.priority,
system_id: null // ← Opcional system_id: null
}; };
console.log('Sending ticket data:', ticketData);
const newTicket = await apiCall('/tickets/', { const newTicket = await apiCall('/tickets/', {
method: 'POST', method: 'POST',
body: JSON.stringify(ticketData) body: JSON.stringify(ticketData)
}); });
update(state => ({
update((state: TicketsState) => ({
...state, ...state,
tickets: [newTicket, ...state.tickets], tickets: [newTicket, ...state.tickets],
isLoading: false isLoading: false
})); }));
return newTicket; return newTicket;
} catch (error) { } catch (error) {
console.error('Create ticket error:', error); update(state => ({
update((state: TicketsState) => ({
...state, ...state,
isLoading: false, isLoading: false,
error: error instanceof Error ? error.message : 'Failed to create ticket' 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) => { addComment: async (ticketId: string, content: string) => {
try { try {
const comment = await apiCall(`/tickets/${ticketId}/comments`, { const comment = await apiCall(`/tickets/${ticketId}/comments`, {
method: 'POST', 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 => ({ ...state, comments: [...state.comments, comment] }));
update((state: TicketsState) => ({
...state,
comments: [...state.comments, comment]
}));
return comment; return comment;
} catch (error) { } catch (error) {
update((state: TicketsState) => ({ update(state => ({
...state, ...state,
error: error instanceof Error ? error.message : 'Failed to add comment' error: error instanceof Error ? error.message : 'Failed to add comment'
})); }));
@@ -255,17 +302,12 @@ function createTicketsStore() {
} }
}, },
// Upload attachment
uploadAttachment: async (ticketId: string, file: File) => { uploadAttachment: async (ticketId: string, file: File) => {
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
const authState = get(auth); 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' }; const uploadHeaders: Record<string, string> = { 'X-App': 'client' };
if (authState.token) uploadHeaders['Authorization'] = `Bearer ${authState.token}`; if (authState.token) uploadHeaders['Authorization'] = `Bearer ${authState.token}`;
@@ -285,15 +327,10 @@ function createTicketsStore() {
const result = await response.json(); const result = await response.json();
const attachment = result.data || result; const attachment = result.data || result;
update(state => ({ ...state, attachments: [...state.attachments, attachment] }));
update((state: TicketsState) => ({
...state,
attachments: [...state.attachments, attachment]
}));
return attachment; return attachment;
} catch (error) { } catch (error) {
update((state: TicketsState) => ({ update(state => ({
...state, ...state,
error: error instanceof Error ? error.message : 'Failed to upload attachment' 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) => { closeTicket: async (ticketId: string, resolution?: string) => {
try { try {
const updatedTicket = await apiCall(`/tickets/${ticketId}/close`, { const updatedTicket = await apiCall(`/tickets/${ticketId}/close`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ resolution }) body: JSON.stringify({ resolution_notes: resolution })
}); });
update(state => ({
update((state: TicketsState) => ({
...state, ...state,
currentTicket: state.currentTicket?.id === ticketId ? updatedTicket : state.currentTicket, 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; return updatedTicket;
} catch (error) { } catch (error) {
update((state: TicketsState) => ({ update(state => ({
...state, ...state,
error: error instanceof Error ? error.message : 'Failed to close ticket' error: error instanceof Error ? error.message : 'Failed to close ticket'
})); }));
@@ -325,45 +359,36 @@ function createTicketsStore() {
} }
}, },
// Clear error clearError: () => { update(state => ({ ...state, error: null })); },
clearError: () => {
update((state: TicketsState) => ({ ...state, error: null }));
},
// Clear current ticket
clearCurrentTicket: () => { clearCurrentTicket: () => {
update((state: TicketsState) => ({ update(state => ({
...state, ...state,
currentTicket: null, currentTicket: null,
comments: [], comments: [],
attachments: [] attachments: [],
issues: []
})); }));
}, },
// Download attachment
downloadAttachment: async (ticketId: string, attachmentId: string, filename: string) => { downloadAttachment: async (ticketId: string, attachmentId: string, filename: string) => {
const authState = get(auth); 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' }; const dlHeaders: Record<string, string> = { 'X-App': 'client' };
if (authState.token) dlHeaders['Authorization'] = `Bearer ${authState.token}`; if (authState.token) dlHeaders['Authorization'] = `Bearer ${authState.token}`;
if (authState.user.tenant_id) dlHeaders['X-Tenant-ID'] = authState.user.tenant_id; if (authState.user.tenant_id) dlHeaders['X-Tenant-ID'] = authState.user.tenant_id;
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`, { const response = await fetch(
method: 'GET', `/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`,
credentials: 'include', { method: 'GET', credentials: 'include', headers: dlHeaders }
headers: dlHeaders );
});
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Download failed' })); const error = await response.json().catch(() => ({ detail: 'Download failed' }));
throw new Error(error.detail || 'Download failed'); throw new Error(error.detail || 'Download failed');
} }
// Crear blob y descargar
const blob = await response.blob(); const blob = await response.blob();
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); 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 { auth } from '$lib/stores/auth';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
const API_BASE = '/api/v1'; const API_BASE = '/api/v1';
interface RequestOptions extends RequestInit { interface RequestOptions extends RequestInit {
params?: Record<string, string>; params?: Record<string, string>;
} }
@@ -20,7 +14,6 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
const filteredParams = Object.entries(params) const filteredParams = Object.entries(params)
.filter(([, value]) => value !== undefined && value !== null && value !== '') .filter(([, value]) => value !== undefined && value !== null && value !== '')
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
if (Object.keys(filteredParams).length > 0) { if (Object.keys(filteredParams).length > 0) {
url += `?${new URLSearchParams(filteredParams).toString()}`; url += `?${new URLSearchParams(filteredParams).toString()}`;
} }
@@ -29,18 +22,16 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
const authState = get(auth); const authState = get(auth);
const headers = new Headers(init.headers); const headers = new Headers(init.headers);
// Bearer header cuando el token está en memoria (sesión activa sin reload)
if (authState.token) { if (authState.token) {
headers.set('Authorization', `Bearer ${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')) { if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json'); headers.set('Content-Type', 'application/json');
} }
// Identifica este frontend para que el backend use client_access_token
headers.set('X-App', 'client'); 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, { const response = await fetch(url, {
...init, ...init,
@@ -74,10 +65,10 @@ async function downloadFile(endpoint: string, filename: string): Promise<void> {
if (authState.token) { if (authState.token) {
headers.set('Authorization', `Bearer ${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'); 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}`, { const response = await fetch(`${API_BASE}${endpoint}`, {
method: 'GET', method: 'GET',
@@ -108,19 +99,14 @@ async function downloadFile(endpoint: string, filename: string): Promise<void> {
export const api = { export const api = {
get: <T>(endpoint: string, params?: Record<string, string>) => get: <T>(endpoint: string, params?: Record<string, string>) =>
request<T>(endpoint, { method: 'GET', params }), request<T>(endpoint, { method: 'GET', params }),
post: <T>(endpoint: string, body?: any) => post: <T>(endpoint: string, body?: any) =>
request<T>(endpoint, { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined }), request<T>(endpoint, { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined }),
put: <T>(endpoint: string, body?: any) => put: <T>(endpoint: string, body?: any) =>
request<T>(endpoint, { method: 'PUT', body: body !== undefined ? JSON.stringify(body) : undefined }), request<T>(endpoint, { method: 'PUT', body: body !== undefined ? JSON.stringify(body) : undefined }),
patch: <T>(endpoint: string, body?: any) => patch: <T>(endpoint: string, body?: any) =>
request<T>(endpoint, { method: 'PATCH', body: body !== undefined ? JSON.stringify(body) : undefined }), request<T>(endpoint, { method: 'PATCH', body: body !== undefined ? JSON.stringify(body) : undefined }),
delete: <T>(endpoint: string) => delete: <T>(endpoint: string) =>
request<T>(endpoint, { method: 'DELETE' }), request<T>(endpoint, { method: 'DELETE' }),
downloadFile: (endpoint: string, filename: string) => downloadFile: (endpoint: string, filename: string) =>
downloadFile(endpoint, filename) downloadFile(endpoint, filename)
}; };

View File

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

View File

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

View File

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

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 type { Writable } from 'svelte/store';
import { writable } from 'svelte/store';
// Types export interface User {
export interface InternalUser {
id: string; id: string;
email: string; email: string;
first_name: string; first_name: string;
last_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_active: boolean;
is_two_factor_enabled: boolean; is_two_factor_enabled: boolean;
created_at: string; created_at: string;
tenant_id: string;
} }
export interface AuthState { export interface AuthState {
user: InternalUser | null; user: User | null;
token: string | null; token: string | null;
refreshToken: string | null;
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean; isLoading: boolean;
} }
@@ -25,49 +24,31 @@ export interface AuthState {
export interface LoginRequest { export interface LoginRequest {
email: string; email: string;
password: string; password: string;
tenant_slug: string; tenant_slug?: string;
totp_code?: string; totp_code?: string;
} }
export interface LoginResponse { export interface LoginResponse {
access_token: string; access_token: string;
refresh_token: string;
token_type: string; token_type: string;
expires_in: number; 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 = { const initialState: AuthState = {
user: null, user: null,
token: null, token: null,
refreshToken: null,
isAuthenticated: false, isAuthenticated: false,
isLoading: false isLoading: false
}; };
// Create auth store
function createAuthStore() { function createAuthStore() {
const { subscribe, set, update } = writable<AuthState>(initialState); const { subscribe, set, update }: Writable<AuthState> = writable(initialState);
// Track current state for uso interno (evita dependencias circulares)
let _state = initialState; let _state = initialState;
subscribe(s => { _state = s; }); subscribe(s => { _state = s; });
return { return {
subscribe, subscribe,
// Rehidrata sesión desde cookie HttpOnly (no toca localStorage)
init: async () => { init: async () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
try { try {
@@ -77,128 +58,56 @@ function createAuthStore() {
}); });
if (response.ok) { if (response.ok) {
const user = await response.json(); const user = await response.json();
set({ set({ user, token: null, isAuthenticated: true, isLoading: false });
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) { }
} catch (error) {
// Ignorar errores de red en init
}
} }
}, },
// Login
login: async (credentials: LoginRequest): Promise<void> => { login: async (credentials: LoginRequest): Promise<void> => {
update(state => ({ ...state, isLoading: true })); update(state => ({ ...state, isLoading: true }));
try { try {
const response = await fetch('/api/v1/auth/login', { const response = await fetch('/api/v1/auth/login', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-App': 'client',
}, },
body: JSON.stringify(credentials) body: JSON.stringify(credentials)
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.detail || 'Login failed'); throw new Error(error.detail || 'Login failed');
} }
const data: LoginResponse = await response.json(); const data: LoginResponse = await response.json();
set({ user: data.user, token: data.access_token, isAuthenticated: true, isLoading: false });
set({
user: data.user,
token: data.access_token,
refreshToken: data.refresh_token,
isAuthenticated: true,
isLoading: false
});
} catch (error) { } catch (error) {
update(state => ({ ...state, isLoading: false })); update(state => ({ ...state, isLoading: false }));
throw error; 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 () => { logout: async () => {
// Llamar al backend para que borre la cookie HttpOnly
try { try {
const token = _state.token;
const slug = _state.user?.tenant_slug || _state.user?.tenant_id || '';
await fetch('/api/v1/auth/logout', { await fetch('/api/v1/auth/logout', {
method: 'POST', method: 'POST',
credentials: 'include', 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); set(initialState);
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.location.href = '/login'; window.location.href = '/login';
} }
}, },
updateUser: (user: User) => { update(state => ({ ...state, user })); },
// Update user data setUser: (user: User) => { set({ user, token: null, isAuthenticated: true, isLoading: false }); },
updateUser: (user: InternalUser) => { setLoading: (isLoading: boolean) => { update(state => ({ ...state, isLoading })); }
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 }));
}
}; };
} }

View File

@@ -191,7 +191,7 @@
<label <label
for="code" for="code"
class="block text-xs font-semibold uppercase tracking-wider text-blue-800 dark:text-blue-300 mb-2 text-center" 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 >Verificación de Seguridad</label
> >
<div class="relative"> <div class="relative">
<input <input

View File

@@ -1,10 +1,15 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; 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 { toast } from '$lib/stores/toast';
import { api } from '$lib/utils/api'; import { api } from '$lib/utils/api';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
let showParticipants = false;
let showIssueModal = false;
let issues = [];
let ticketId: string; let ticketId: string;
let ticket = null; let ticket = null;
let comments = []; let comments = [];
@@ -42,33 +47,37 @@
} }
async function loadData() { async function loadData() {
isLoading = true; isLoading = true;
try { 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}`),
api.get(`/tickets/${ticketId}/comments`), api.get(`/tickets/${ticketId}/comments`),
api.get(`/tickets/${ticketId}/attachments`), api.get(`/tickets/${ticketId}/attachments`),
api.get('/users/') api.get('/users/'),
]); api.get(`/tickets/${ticketId}/issues`)
ticket = ticketData; ]);
comments = commentsData; ticket = ticketData;
attachments = attachmentsData; comments = commentsData;
users = usersData; attachments = attachmentsData;
} catch (e) { users = usersData;
toast.error('Error cargando ticket: ' + (e.message || 'Error desconocido')); issues = issuesData;
} finally { } catch (e) {
isLoading = false; toast.error('Error cargando ticket: ' + (e.message || 'Error desconocido'));
} } finally {
isLoading = false;
} }
}
async function handleAddComment() { async function handleAddComment() {
if (!newComment.trim()) return; if (!newComment.trim()) return;
isSubmittingComment = true; isSubmittingComment = true;
try { try {
const comment = await api.post(`/tickets/${ticketId}/comments`, { const comment = await api.post(`/tickets/${ticketId}/issues`, {
content: newComment.trim(), content: content.trim(),
is_internal: false priority,
tagged_user_ids: taggedUserIds
}); });
comments = [...comments, comment]; comments = [...comments, comment];
newComment = ''; newComment = '';
@@ -288,7 +297,9 @@
{formatDate(comment.created_at)} {formatDate(comment.created_at)}
</span> </span>
{#if comment.is_internal} {#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 Interno
</span> </span>
{/if} {/if}
@@ -374,26 +385,80 @@
<dd class="text-sm text-gray-900">{formatDate(ticket.updated_at)}</dd> <dd class="text-sm text-gray-900">{formatDate(ticket.updated_at)}</dd>
</div> </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 --> <!-- SLA Information -->
{#if ticket.sla_response_due || ticket.sla_resolution_due} {#if ticket.sla_response_due || ticket.sla_resolution_due}
<div class="pt-4 border-t border-gray-200"> <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} {#if ticket.sla_response_due}
<div class="mb-3"> <div class="mb-3">
<dt class="text-xs font-medium text-gray-500">Tiempo de Respuesta</dt> <dt class="text-xs font-medium text-gray-500">Tiempo de Respuesta</dt>
<dd class="text-sm text-gray-900 mt-1"> <dd class="text-sm text-gray-900 mt-1">
{formatDate(ticket.sla_response_due)} {formatDate(ticket.sla_response_due)}
{#if new Date(ticket.sla_response_due) < new Date() && !ticket.sla_response_met} {#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 Vencido
</span> </span>
{:else if ticket.sla_response_met} {: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 Cumplido
</span> </span>
{:else} {: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 En plazo
</span> </span>
{/if} {/if}
@@ -407,15 +472,21 @@
<dd class="text-sm text-gray-900 mt-1"> <dd class="text-sm text-gray-900 mt-1">
{formatDate(ticket.sla_resolution_due)} {formatDate(ticket.sla_resolution_due)}
{#if new Date(ticket.sla_resolution_due) < new Date() && !ticket.sla_resolution_met} {#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 Vencido
</span> </span>
{:else if ticket.sla_resolution_met} {: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 Cumplido
</span> </span>
{:else} {: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 En plazo
</span> </span>
{/if} {/if}
@@ -429,4 +500,22 @@
</div> </div>
</div> </div>
{/if} {/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> </div>

View File

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

BIN
migrations.sql Normal file

Binary file not shown.

130
participants_router.py Normal file
View File

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