fix: ADMIN puede ver tickets de cualquier tenant, fixes seguridad tickets.py, logout interno

This commit is contained in:
2026-03-19 08:55:08 -06:00
parent b76fcb1390
commit 6687802f19
3 changed files with 301 additions and 212 deletions

View File

@@ -34,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,
@@ -43,13 +44,16 @@ 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)
@@ -64,22 +68,25 @@ async def get_tickets(skip: int = 0, limit: int = 100, status: Optional[str] = N
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)
@@ -111,7 +118,6 @@ async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter:
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:
@@ -127,21 +133,32 @@ async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter:
"category_name": categories_map.get(ticket.category_id) if ticket.category_id else None, "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 != UserRole.ADMIN:
query = query.where(Ticket.tenant_id == current_user.tenant_id)
if current_user.role.is_client: if current_user.role.is_client:
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()
@@ -150,11 +167,18 @@ async def get_ticket(ticket_id: str, db: AsyncSession = Depends(get_db), current
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 != UserRole.ADMIN:
query = query.where(Ticket.tenant_id == current_user.tenant_id)
if current_user.role.is_client: if current_user.role.is_client:
query = query.where(Ticket.created_by == current_user.id) query = query.where(Ticket.created_by == current_user.id)
@@ -163,7 +187,11 @@ async def update_ticket(ticket_id: str, ticket: TicketUpdate, db: AsyncSession =
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():
@@ -180,15 +208,26 @@ async def update_ticket(ticket_id: str, ticket: TicketUpdate, db: AsyncSession =
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)
@@ -217,18 +256,28 @@ async def close_ticket(ticket_id: str, close_request: TicketCloseRequest, db: As
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.is_client:
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)
@@ -236,23 +285,34 @@ async def get_ticket_comments(ticket_id: str, db: AsyncSession = Depends(get_db)
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, Ticket.tenant_id == current_user.tenant_id)
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]: if current_user.role.is_client:
query = query.where(Ticket.created_by == current_user.id) query = query.where(Ticket.created_by == current_user.id)
result = await db.execute(query) result = await db.execute(query)
@@ -260,6 +320,13 @@ async def create_comment(ticket_id: str, comment: CommentCreate, db: AsyncSessio
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")
# Fix is_internal — clientes no pueden crear comentarios internos
if comment.is_internal and current_user.role.is_client:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Clientes no pueden crear comentarios internos"
)
new_comment = TicketComment( 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,
@@ -276,17 +343,26 @@ async def create_comment(ticket_id: str, comment: CommentCreate, db: AsyncSessio
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): 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") 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)
@@ -295,64 +371,98 @@ async def delete_ticket(ticket_id: str, db: AsyncSession = Depends(get_db), curr
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.is_client 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.is_client 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) # Para el file_handler usamos el tenant real del ticket
upload_tenant_id = ticket.tenant_id
file_metadata = await file_handler.save_upload(file, upload_tenant_id, ticket_uuid)
attachment = TicketAttachment( 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)
@@ -363,58 +473,66 @@ async def upload_attachment(ticket_id: str, file: UploadFile = File(...), db: As
"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.is_client 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]) @router.get("/{ticket_id}/issues", response_model=List[IssueResponse])
async def get_ticket_issues( async def get_ticket_issues(
@@ -425,11 +543,9 @@ async def get_ticket_issues(
"""Obtener asuntos de un ticket.""" """Obtener asuntos de un ticket."""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
# Verificar que el ticket existe y pertenece al tenant query = select(Ticket).where(Ticket.id == ticket_uuid)
query = select(Ticket).where( if current_user.role != UserRole.ADMIN:
Ticket.id == ticket_uuid, query = query.where(Ticket.tenant_id == current_user.tenant_id)
Ticket.tenant_id == current_user.tenant_id,
)
if current_user.role.is_client: if current_user.role.is_client:
query = query.where(Ticket.created_by == current_user.id) query = query.where(Ticket.created_by == current_user.id)
@@ -452,11 +568,8 @@ async def get_ticket_issues(
return [ return [
IssueResponse( IssueResponse(
id=issue.id, id=issue.id, ticket_id=issue.ticket_id, tenant_id=issue.tenant_id,
ticket_id=issue.ticket_id, content=issue.content, priority=issue.priority.value,
tenant_id=issue.tenant_id,
content=issue.content,
priority=issue.priority.value,
created_by=issue.created_by, created_by=issue.created_by,
created_by_name=f"{issue.created_by_user.first_name} {issue.created_by_user.last_name}", created_by_name=f"{issue.created_by_user.first_name} {issue.created_by_user.last_name}",
tagged_users=[ tagged_users=[
@@ -465,8 +578,7 @@ async def get_ticket_issues(
], ],
attachment_filename=issue.attachment_filename, attachment_filename=issue.attachment_filename,
attachment_mime_type=issue.attachment_mime_type, attachment_mime_type=issue.attachment_mime_type,
created_at=issue.created_at, created_at=issue.created_at, updated_at=issue.updated_at,
updated_at=issue.updated_at,
) )
for issue in issues for issue in issues
] ]
@@ -481,14 +593,9 @@ async def create_ticket_issue(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
current_tenant: Tenant = Depends(get_current_tenant), current_tenant: Tenant = Depends(get_current_tenant),
): ):
""" """Crear un asunto de escalación en un ticket."""
Crear un asunto de escalación en un ticket.
Solo puede crearlo el dueño del ticket o el CLIENT_ADMIN del tenant.
"""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
# 1. Verificar que el ticket existe y pertenece al tenant
result = await db.execute( result = await db.execute(
select(Ticket).where( select(Ticket).where(
Ticket.id == ticket_uuid, Ticket.id == ticket_uuid,
@@ -499,23 +606,21 @@ async def create_ticket_issue(
if not ticket_obj: if not ticket_obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ticket no encontrado") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ticket no encontrado")
# 2. Verificar permisos: solo el creador del ticket o CLIENT_ADMIN
is_ticket_owner = ticket_obj.created_by == current_user.id is_ticket_owner = ticket_obj.created_by == current_user.id
is_client_admin = current_user.role == UserRole.CLIENT_ADMIN 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: if not is_ticket_owner and not is_client_admin and not is_staff:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Solo el creador del ticket o el administrador del tenant pueden crear asuntos", detail="Solo el creador del ticket o el administrador pueden crear asuntos",
) )
# 3. Validar usuarios etiquetados — deben pertenecer al mismo tenant
tagged_users = [] tagged_users = []
if issue.tagged_user_ids: if issue.tagged_user_ids:
result = await db.execute( result = await db.execute(
select(User).where( select(User).where(
User.id.in_(issue.tagged_user_ids), User.id.in_(issue.tagged_user_ids),
User.tenant_id == current_user.tenant_id,
User.is_active == True, User.is_active == True,
) )
) )
@@ -526,10 +631,9 @@ async def create_ticket_issue(
if missing: if missing:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Usuarios no encontrados en el tenant: {missing}", detail=f"Usuarios no encontrados: {missing}",
) )
# 4. Manejar adjunto opcional
attachment_path = None attachment_path = None
attachment_filename = None attachment_filename = None
attachment_mime_type = None attachment_mime_type = None
@@ -539,19 +643,13 @@ async def create_ticket_issue(
attachment_filename = file_metadata["original_filename"] attachment_filename = file_metadata["original_filename"]
attachment_mime_type = file_metadata["mime_type"] attachment_mime_type = file_metadata["mime_type"]
# 5. Crear el asunto
new_issue = TicketIssue( new_issue = TicketIssue(
id=uuid.uuid4(), id=uuid.uuid4(), ticket_id=ticket_uuid,
ticket_id=ticket_uuid, tenant_id=current_user.tenant_id, created_by=current_user.id,
tenant_id=current_user.tenant_id, content=issue.content, priority=TicketPriority[issue.priority],
created_by=current_user.id, attachment_path=attachment_path, attachment_filename=attachment_filename,
content=issue.content,
priority=TicketPriority[issue.priority],
attachment_path=attachment_path,
attachment_filename=attachment_filename,
attachment_mime_type=attachment_mime_type, attachment_mime_type=attachment_mime_type,
created_at=datetime.utcnow(), created_at=datetime.utcnow(), updated_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
) )
new_issue.tagged_users = tagged_users new_issue.tagged_users = tagged_users
db.add(new_issue) db.add(new_issue)
@@ -559,27 +657,18 @@ async def create_ticket_issue(
await db.commit() await db.commit()
await db.refresh(new_issue, ["created_by_user", "tagged_users"]) await db.refresh(new_issue, ["created_by_user", "tagged_users"])
# 6. Audit log
await safe_audit_log( await safe_audit_log(
db=db, db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
tenant_id=current_user.tenant_id, action="ticket.issue.create", resource_type="ticket_issue", resource_id=new_issue.id,
user_id=current_user.id,
action="ticket.issue.create",
resource_type="ticket_issue",
resource_id=new_issue.id,
new_values={ new_values={
"ticket_id": str(ticket_uuid), "ticket_id": str(ticket_uuid), "priority": issue.priority,
"priority": issue.priority,
"tagged_users": [str(uid) for uid in issue.tagged_user_ids], "tagged_users": [str(uid) for uid in issue.tagged_user_ids],
}, },
) )
return IssueResponse( return IssueResponse(
id=new_issue.id, id=new_issue.id, ticket_id=new_issue.ticket_id, tenant_id=new_issue.tenant_id,
ticket_id=new_issue.ticket_id, content=new_issue.content, priority=new_issue.priority.value,
tenant_id=new_issue.tenant_id,
content=new_issue.content,
priority=new_issue.priority.value,
created_by=new_issue.created_by, created_by=new_issue.created_by,
created_by_name=f"{new_issue.created_by_user.first_name} {new_issue.created_by_user.last_name}", created_by_name=f"{new_issue.created_by_user.first_name} {new_issue.created_by_user.last_name}",
tagged_users=[ tagged_users=[
@@ -588,6 +677,5 @@ async def create_ticket_issue(
], ],
attachment_filename=new_issue.attachment_filename, attachment_filename=new_issue.attachment_filename,
attachment_mime_type=new_issue.attachment_mime_type, attachment_mime_type=new_issue.attachment_mime_type,
created_at=new_issue.created_at, created_at=new_issue.created_at, updated_at=new_issue.updated_at,
updated_at=new_issue.updated_at,
) )

View File

@@ -54,13 +54,13 @@ function createAuthStore() {
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': 'internal' }
}); });
if (response.ok) { if (response.ok) {
const user = await response.json(); const user = await response.json();
set({ user, token: null, isAuthenticated: true, isLoading: false }); set({ user, token: null, isAuthenticated: true, isLoading: false });
} }
} catch (error) {} } catch (error) { }
} }
}, },
login: async (credentials: LoginRequest): Promise<void> => { login: async (credentials: LoginRequest): Promise<void> => {
@@ -94,12 +94,12 @@ function createAuthStore() {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
headers: { headers: {
'X-App': 'client', 'X-App': 'internal',
'X-Tenant-Slug': slug, 'X-Tenant-Slug': slug,
...(token ? { 'Authorization': `Bearer ${token}` } : {}) ...(token ? { 'Authorization': `Bearer ${token}` } : {})
} }
}); });
} catch {} } catch { }
set(initialState); set(initialState);
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.location.href = '/login'; window.location.href = '/login';

View File

@@ -1,5 +1,6 @@
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: {