From 6687802f1933ad7117505e74f3e8fb81d313c9d5 Mon Sep 17 00:00:00 2001 From: icamarillo Date: Thu, 19 Mar 2026 08:55:08 -0600 Subject: [PATCH] fix: ADMIN puede ver tickets de cualquier tenant, fixes seguridad tickets.py, logout interno --- backend/app/api/v1/endpoints/tickets.py | 446 ++++++++++++++--------- frontend-internal/src/lib/stores/auth.ts | 8 +- frontend-internal/vite.config.js | 59 +-- 3 files changed, 301 insertions(+), 212 deletions(-) diff --git a/backend/app/api/v1/endpoints/tickets.py b/backend/app/api/v1/endpoints/tickets.py index 6acee79..91dd982 100644 --- a/backend/app/api/v1/endpoints/tickets.py +++ b/backend/app/api/v1/endpoints/tickets.py @@ -21,7 +21,7 @@ from app.models.comment import TicketComment from app.models.attachment import TicketAttachment from app.api.schemas.attachment import AttachmentResponse from app.api.schemas.ticket import ( - TicketCreate, TicketUpdate, TicketResponse, + TicketCreate, TicketUpdate, TicketResponse, TicketCloseRequest, CommentCreate, CommentResponse ) from app.core.file_handler import file_handler @@ -34,6 +34,7 @@ from app.services.ticket_service import TicketService, get_ticket_service router = APIRouter() + @router.post("/", response_model=TicketResponse, status_code=status.HTTP_201_CREATED) async def create_ticket( ticket: TicketCreate, @@ -43,16 +44,19 @@ async def create_ticket( """Crear un nuevo ticket""" return await ticket_service.create_ticket(ticket, current_user.tenant_id, current_user.id) + @router.get("/", response_model=List[TicketResponse]) -async def get_tickets(skip: int = 0, limit: int = 100, status: Optional[str] = None, priority: Optional[str] = None, - db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): +async def get_tickets( + skip: int = 0, limit: int = 100, + status: Optional[str] = None, priority: Optional[str] = None, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): """Obtener tickets con filtros opcionales""" query = select(Ticket).where(Ticket.tenant_id == current_user.tenant_id) - # Solo CLIENT_USER ve únicamente sus propios tickets. - # CLIENT_ADMIN ve todos los del tenant. if current_user.role == UserRole.CLIENT_USER: query = query.where(Ticket.created_by == current_user.id) - + query = apply_enum_filter(query, Ticket.status, status, TicketStatus, "status") query = apply_enum_filter(query, Ticket.priority, priority, TicketPriority, "priority") query = query.options( @@ -61,25 +65,28 @@ async def get_tickets(skip: int = 0, limit: int = 100, status: Optional[str] = N selectinload(Ticket.assigned_to_user) ) query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit) - + result = await db.execute(query) tickets = result.scalars().all() - return [ticket_to_dict(t) for t in tickets] + @router.get("/admin/all", response_model=List[dict]) -async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter: Optional[str] = None, - priority_filter: Optional[str] = None, tenant_id_filter: Optional[str] = None, category_filter: Optional[str] = None, - assigned_to_filter: Optional[str] = None, search: Optional[str] = None, date_from: Optional[str] = None, - date_to: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): +async def get_all_tickets_admin( + skip: int = 0, limit: int = 100, + status_filter: Optional[str] = None, priority_filter: Optional[str] = None, + tenant_id_filter: Optional[str] = None, category_filter: Optional[str] = None, + assigned_to_filter: Optional[str] = None, search: Optional[str] = None, + date_from: Optional[str] = None, date_to: Optional[str] = None, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): """Obtener todos los tickets del tenant del administrador (ADMIN/SUPPORT_MANAGER).""" if current_user.role not in (UserRole.ADMIN, UserRole.SUPPORT_MANAGER): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No tienes permisos para acceder a esta función") query = select(Ticket, Tenant, User).join(Tenant, Ticket.tenant_id == Tenant.id).join(User, Ticket.created_by == User.id) - # SUPPORT_MANAGER solo ve su propio tenant. - # ADMIN ve todos los tenants (es el administrador de la plataforma). if current_user.role == UserRole.SUPPORT_MANAGER: query = query.where(Ticket.tenant_id == current_user.tenant_id) @@ -106,12 +113,11 @@ async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter: query = query.where(Ticket.created_at < date_to_parsed) except ValueError: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid date_to format. Use YYYY-MM-DD") - + query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit) result = await db.execute(query) rows = result.all() - - # Cargar categorías en un solo query para evitar N+1 + category_ids = list({ticket.category_id for ticket, _, _ in rows if ticket.category_id}) categories_map = {} if category_ids: @@ -127,44 +133,66 @@ async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter: "category_name": categories_map.get(ticket.category_id) if ticket.category_id else None, "created_by": str(ticket.created_by), "creator_name": f"{creator.first_name} {creator.last_name}", "creator_email": creator.email, "assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None, - "created_at": ticket.created_at, "updated_at": ticket.updated_at, "sla_response_due": ticket.sla_response_due, - "sla_resolution_due": ticket.sla_resolution_due, "first_response_at": ticket.first_response_at, - "resolved_at": ticket.resolved_at} + "created_at": ticket.created_at, "updated_at": ticket.updated_at, + "sla_response_due": ticket.sla_response_due, "sla_resolution_due": ticket.sla_resolution_due, + "first_response_at": ticket.first_response_at, "resolved_at": ticket.resolved_at} for ticket, tenant, creator in rows ] + @router.get("/{ticket_id}", response_model=TicketResponse) -async def get_ticket(ticket_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): +async def get_ticket( + ticket_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): """Obtener un ticket por ID""" ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") - query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id) + query = select(Ticket).where(Ticket.id == ticket_uuid) + if current_user.role != UserRole.ADMIN: + query = query.where(Ticket.tenant_id == current_user.tenant_id) if current_user.role.is_client: query = query.where(Ticket.created_by == current_user.id) - - query = query.options(selectinload(Ticket.category), selectinload(Ticket.affected_system), selectinload(Ticket.assigned_to_user)) + + query = query.options( + selectinload(Ticket.category), + selectinload(Ticket.affected_system), + selectinload(Ticket.assigned_to_user) + ) result = await db.execute(query) db_ticket = result.scalars().first() - + if not db_ticket: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found") - + return ticket_to_dict(db_ticket) + @router.patch("/{ticket_id}", response_model=TicketResponse) -async def update_ticket(ticket_id: str, ticket: TicketUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): +async def update_ticket( + ticket_id: str, ticket: TicketUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): """Actualizar un ticket""" ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") - query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id) + query = select(Ticket).where(Ticket.id == ticket_uuid) + if current_user.role != UserRole.ADMIN: + query = query.where(Ticket.tenant_id == current_user.tenant_id) if current_user.role.is_client: query = query.where(Ticket.created_by == current_user.id) - + result = await db.execute(query) db_ticket = result.scalars().first() if not db_ticket: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found") - - old_values = {"status": db_ticket.status.value, "priority": db_ticket.priority.value, "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None} - + + old_values = { + "status": db_ticket.status.value, + "priority": db_ticket.priority.value, + "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None + } + update_data = ticket.dict(exclude_unset=True) for field, value in update_data.items(): if field == "status" and value: @@ -175,37 +203,48 @@ async def update_ticket(ticket_id: str, ticket: TicketUpdate, db: AsyncSession = setattr(db_ticket, field, uuid.UUID(value)) elif value is not None: setattr(db_ticket, field, value) - + db_ticket.updated_at = datetime.utcnow() await db.commit() await db.refresh(db_ticket, ["category", "affected_system", "assigned_to_user"]) - - new_values = {"status": db_ticket.status.value, "priority": db_ticket.priority.value, "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None} - await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id, + + new_values = { + "status": db_ticket.status.value, + "priority": db_ticket.priority.value, + "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None + } + await safe_audit_log( + db=db, tenant_id=current_user.tenant_id, user_id=current_user.id, action="ticket.update", resource_type="ticket", resource_id=db_ticket.id, - old_values=old_values, new_values=new_values) - + old_values=old_values, new_values=new_values + ) + return ticket_to_dict(db_ticket) + @router.patch("/{ticket_id}/close", response_model=TicketResponse) -async def close_ticket(ticket_id: str, close_request: TicketCloseRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): +async def close_ticket( + ticket_id: str, close_request: TicketCloseRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): """Cerrar un ticket""" ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id) result = await db.execute(query) db_ticket = result.scalars().first() - + if not db_ticket: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found") - + if db_ticket.status in [TicketStatus.CLOSED, TicketStatus.RESOLVED]: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ticket ya está cerrado o resuelto") - + old_status = db_ticket.status.value db_ticket.status = TicketStatus.CLOSED db_ticket.resolved_at = datetime.utcnow() db_ticket.updated_at = datetime.utcnow() - + if close_request.resolution_notes: comment = TicketComment( id=uuid.uuid4(), ticket_id=ticket_uuid, author_id=current_user.id, @@ -213,208 +252,287 @@ async def close_ticket(ticket_id: str, close_request: TicketCloseRequest, db: As is_internal=False, created_at=datetime.utcnow(), updated_at=datetime.utcnow() ) db.add(comment) - + await db.commit() await db.refresh(db_ticket, ["category", "affected_system", "assigned_to_user"]) - - await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id, + + await safe_audit_log( + db=db, tenant_id=current_user.tenant_id, user_id=current_user.id, action="ticket.close", resource_type="ticket", resource_id=db_ticket.id, - old_values={"status": old_status}, new_values={"status": db_ticket.status.value, "resolution_notes": close_request.resolution_notes}) - + old_values={"status": old_status}, + new_values={"status": db_ticket.status.value, "resolution_notes": close_request.resolution_notes} + ) + return ticket_to_dict(db_ticket) + @router.get("/{ticket_id}/comments", response_model=List[CommentResponse]) -async def get_ticket_comments(ticket_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): +async def get_ticket_comments( + ticket_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): """Obtener comentarios de un ticket""" ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") - query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id) - if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]: + query = select(Ticket).where(Ticket.id == ticket_uuid) + if current_user.role != UserRole.ADMIN: + query = query.where(Ticket.tenant_id == current_user.tenant_id) + if current_user.role.is_client: query = query.where(Ticket.created_by == current_user.id) - + result = await db.execute(query) ticket_obj = result.scalars().first() if not ticket_obj: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found") - - comments_query = select(TicketComment).where(TicketComment.ticket_id == ticket_uuid).options(selectinload(TicketComment.author)).order_by(TicketComment.created_at.desc()) + + comments_query = ( + select(TicketComment) + .where(TicketComment.ticket_id == ticket_uuid) + .options(selectinload(TicketComment.author)) + .order_by(TicketComment.created_at.desc()) + ) result = await db.execute(comments_query) comments = result.scalars().all() - + return [ {"id": str(c.id), "ticket_id": str(c.ticket_id), "author_id": str(c.author_id), "author_name": f"{c.author.first_name} {c.author.last_name}" if c.author else "Unknown", - "content": c.content, "is_internal": c.is_internal, "created_at": c.created_at, "updated_at": c.updated_at} + "content": c.content, "is_internal": c.is_internal, + "created_at": c.created_at, "updated_at": c.updated_at} for c in comments ] + @router.post("/{ticket_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED) -async def create_comment(ticket_id: str, comment: CommentCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): +async def create_comment( + ticket_id: str, comment: CommentCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): """Crear un comentario en un ticket""" ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id) - if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]: + if current_user.role.is_client: query = query.where(Ticket.created_by == current_user.id) - + result = await db.execute(query) ticket_obj = result.scalars().first() if not ticket_obj: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found") - + + # Fix is_internal — clientes no pueden crear comentarios internos + if comment.is_internal and current_user.role.is_client: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Clientes no pueden crear comentarios internos" + ) + new_comment = TicketComment( id=uuid.uuid4(), ticket_id=ticket_uuid, author_id=current_user.id, content=comment.content, is_internal=comment.is_internal, created_at=datetime.utcnow(), updated_at=datetime.utcnow() ) db.add(new_comment) - + staff_roles = ["ADMIN", "SUPPORT_MANAGER", "AGENT"] if current_user.role in staff_roles and not comment.is_internal and ticket_obj.first_response_at is None: ticket_obj.first_response_at = datetime.utcnow() - + ticket_obj.updated_at = datetime.utcnow() await db.commit() await db.refresh(new_comment) - + return { - "id": str(new_comment.id), "ticket_id": str(new_comment.ticket_id), "author_id": str(new_comment.author_id), + "id": str(new_comment.id), "ticket_id": str(new_comment.ticket_id), + "author_id": str(new_comment.author_id), "author_name": f"{current_user.first_name} {current_user.last_name}", "content": new_comment.content, "is_internal": new_comment.is_internal, "created_at": new_comment.created_at, "updated_at": new_comment.updated_at } + @router.delete("/{ticket_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_ticket(ticket_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): +async def delete_ticket( + ticket_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): """Eliminar un ticket (solo admin/manager)""" if current_user.role not in (UserRole.ADMIN, UserRole.SUPPORT_MANAGER): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No tienes permisos para eliminar tickets") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="No tienes permisos para eliminar tickets" + ) ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id) result = await db.execute(query) db_ticket = result.scalars().first() - + if not db_ticket: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket {ticket_id} not found") - - old_values = {"ticket_number": db_ticket.ticket_number, "subject": db_ticket.subject, - "status": db_ticket.status.value, "priority": db_ticket.priority.value} - + + old_values = { + "ticket_number": db_ticket.ticket_number, "subject": db_ticket.subject, + "status": db_ticket.status.value, "priority": db_ticket.priority.value + } + await db.delete(db_ticket) await db.commit() - - await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id, - action="ticket.delete", resource_type="ticket", resource_id=ticket_uuid, old_values=old_values) - + + await safe_audit_log( + db=db, tenant_id=current_user.tenant_id, user_id=current_user.id, + action="ticket.delete", resource_type="ticket", resource_id=ticket_uuid, + old_values=old_values + ) + return {"message": "Ticket deleted successfully"} + @router.get("/{ticket_id}/attachments", response_model=List[AttachmentResponse]) -async def get_ticket_attachments(ticket_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), current_tenant: Tenant = Depends(get_current_tenant)): +async def get_ticket_attachments( + ticket_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), + current_tenant: Tenant = Depends(get_current_tenant) +): """Obtener adjuntos de un ticket""" ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") - result = await db.execute(select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id)) + + query = select(Ticket).where(Ticket.id == ticket_uuid) + if current_user.role != UserRole.ADMIN: + query = query.where(Ticket.tenant_id == current_tenant.id) + result = await db.execute(query) ticket = result.scalar_one_or_none() - + if not ticket: raise HTTPException(status_code=404, detail="Ticket no encontrado") - if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id: + if current_user.role.is_client and ticket.created_by != current_user.id: raise HTTPException(status_code=404, detail="Ticket no encontrado") - - result = await db.execute(select(TicketAttachment).where(TicketAttachment.ticket_id == ticket_uuid).options(selectinload(TicketAttachment.uploaded_by_user)).order_by(TicketAttachment.created_at.desc())) + + result = await db.execute( + select(TicketAttachment) + .where(TicketAttachment.ticket_id == ticket_uuid) + .options(selectinload(TicketAttachment.uploaded_by_user)) + .order_by(TicketAttachment.created_at.desc()) + ) attachments = result.scalars().all() - + return [ AttachmentResponse( - id=att.id, ticket_id=att.ticket_id, comment_id=att.comment_id, uploaded_by=att.uploaded_by, - filename=att.filename, original_filename=att.original_filename, mime_type=att.mime_type, + id=att.id, ticket_id=att.ticket_id, comment_id=att.comment_id, + uploaded_by=att.uploaded_by, filename=att.filename, + original_filename=att.original_filename, mime_type=att.mime_type, file_size=att.file_size, file_path=att.file_path, uploaded_by_name=f"{att.uploaded_by_user.first_name} {att.uploaded_by_user.last_name}" if att.uploaded_by_user else "Unknown", - created_at=att.created_at, download_url=f"/api/v1/tickets/{ticket_id}/attachments/{att.id}/download" + created_at=att.created_at, + download_url=f"/api/v1/tickets/{ticket_id}/attachments/{att.id}/download" ) for att in attachments ] + @router.post("/{ticket_id}/attachments", status_code=status.HTTP_201_CREATED) -async def upload_attachment(ticket_id: str, file: UploadFile = File(...), db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), current_tenant: Tenant = Depends(get_current_tenant)): +async def upload_attachment( + ticket_id: str, file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), + current_tenant: Tenant = Depends(get_current_tenant) +): """Subir un archivo adjunto a un ticket""" ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") - result = await db.execute(select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id)) + + query = select(Ticket).where(Ticket.id == ticket_uuid) + if current_user.role != UserRole.ADMIN: + query = query.where(Ticket.tenant_id == current_tenant.id) + result = await db.execute(query) ticket = result.scalar_one_or_none() - + if not ticket: raise HTTPException(status_code=404, detail="Ticket no encontrado") - if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id: + if current_user.role.is_client and ticket.created_by != current_user.id: raise HTTPException(status_code=404, detail="Ticket no encontrado") - - file_metadata = await file_handler.save_upload(file, current_tenant.id, ticket_uuid) - + + # Para el file_handler usamos el tenant real del ticket + upload_tenant_id = ticket.tenant_id + file_metadata = await file_handler.save_upload(file, upload_tenant_id, ticket_uuid) + attachment = TicketAttachment( - id=uuid.uuid4(), ticket_id=ticket_uuid, uploaded_by=current_user.id, filename=file_metadata["filename"], - original_filename=file_metadata["original_filename"], mime_type=file_metadata["mime_type"], - file_size=file_metadata["file_size"], file_path=file_metadata["file_path"], - md5_hash=file_metadata["md5_hash"], sha256_hash=file_metadata["sha256_hash"], created_at=datetime.utcnow() + id=uuid.uuid4(), ticket_id=ticket_uuid, uploaded_by=current_user.id, + filename=file_metadata["filename"], original_filename=file_metadata["original_filename"], + mime_type=file_metadata["mime_type"], file_size=file_metadata["file_size"], + file_path=file_metadata["file_path"], md5_hash=file_metadata["md5_hash"], + sha256_hash=file_metadata["sha256_hash"], created_at=datetime.utcnow() ) - + db.add(attachment) await db.commit() await db.refresh(attachment, ["uploaded_by_user"]) - + return { "success": True, "message": "Archivo subido exitosamente", "data": AttachmentResponse( id=attachment.id, ticket_id=attachment.ticket_id, comment_id=attachment.comment_id, - uploaded_by=attachment.uploaded_by, filename=attachment.filename, original_filename=attachment.original_filename, - mime_type=attachment.mime_type, file_size=attachment.file_size, file_path=attachment.file_path, + uploaded_by=attachment.uploaded_by, filename=attachment.filename, + original_filename=attachment.original_filename, mime_type=attachment.mime_type, + file_size=attachment.file_size, file_path=attachment.file_path, uploaded_by_name=f"{attachment.uploaded_by_user.first_name} {attachment.uploaded_by_user.last_name}", - created_at=attachment.created_at, download_url=f"/api/v1/tickets/{ticket_id}/attachments/{attachment.id}/download" + created_at=attachment.created_at, + download_url=f"/api/v1/tickets/{ticket_id}/attachments/{attachment.id}/download" ) } + @router.get("/{ticket_id}/attachments/{attachment_id}/download") -async def download_attachment(ticket_id: str, attachment_id: str, db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), current_tenant: Tenant = Depends(get_current_tenant)): +async def download_attachment( + ticket_id: str, attachment_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), + current_tenant: Tenant = Depends(get_current_tenant) +): """Descargar un archivo adjunto""" import logging logger = logging.getLogger(__name__) - - logger.info(f"Download request - ticket_id: {ticket_id}, attachment_id: {attachment_id}") - + ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") attachment_uuid = validate_uuid_param(attachment_id, "attachment ID") - - result = await db.execute(select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_tenant.id)) + + query = select(Ticket).where(Ticket.id == ticket_uuid) + if current_user.role != UserRole.ADMIN: + query = query.where(Ticket.tenant_id == current_tenant.id) + result = await db.execute(query) ticket = result.scalar_one_or_none() - + if not ticket: - logger.error(f"Ticket not found - ticket_id: {ticket_id}") raise HTTPException(status_code=404, detail="Ticket no encontrado") - if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id: + if current_user.role.is_client and ticket.created_by != current_user.id: raise HTTPException(status_code=404, detail="Ticket no encontrado") - - result = await db.execute(select(TicketAttachment).where(TicketAttachment.id == attachment_uuid, TicketAttachment.ticket_id == ticket_uuid)) + + result = await db.execute( + select(TicketAttachment).where( + TicketAttachment.id == attachment_uuid, + TicketAttachment.ticket_id == ticket_uuid + ) + ) attachment = result.scalar_one_or_none() - + if not attachment: - logger.error(f"Attachment not found - attachment_id: {attachment_id}") raise HTTPException(status_code=404, detail="Adjunto no encontrado") - - logger.info(f"Attachment found - file_path: {attachment.file_path}, original_filename: {attachment.original_filename}") - + try: file_path = file_handler.get_file_path(attachment.file_path) - logger.info(f"Absolute file path: {file_path}") - if not file_path.exists(): - logger.error(f"File does not exist at path: {file_path}") raise HTTPException(status_code=404, detail="Archivo no encontrado en el sistema") - except Exception as e: logger.error(f"Error getting file path: {str(e)}") raise - - logger.info(f"Returning file: {attachment.original_filename}") - return FileResponse(path=file_path, filename=attachment.original_filename, media_type=attachment.mime_type) + + return FileResponse( + path=file_path, + filename=attachment.original_filename, + media_type=attachment.mime_type + ) @router.get("/{ticket_id}/issues", response_model=List[IssueResponse]) async def get_ticket_issues( @@ -425,11 +543,9 @@ async def get_ticket_issues( """Obtener asuntos de un ticket.""" 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, - 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: query = query.where(Ticket.created_by == current_user.id) @@ -452,21 +568,17 @@ async def get_ticket_issues( return [ IssueResponse( - id=issue.id, - ticket_id=issue.ticket_id, - tenant_id=issue.tenant_id, - content=issue.content, - priority=issue.priority.value, + 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, + created_at=issue.created_at, updated_at=issue.updated_at, ) for issue in issues ] @@ -481,14 +593,9 @@ async def create_ticket_issue( current_user: User = Depends(get_current_user), current_tenant: Tenant = Depends(get_current_tenant), ): - """ - Crear un asunto de escalación en un ticket. - - Solo puede crearlo el dueño del ticket o el CLIENT_ADMIN del tenant. - """ + """Crear un asunto de escalación en un ticket.""" ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") - # 1. Verificar que el ticket existe y pertenece al tenant result = await db.execute( select(Ticket).where( Ticket.id == ticket_uuid, @@ -499,23 +606,21 @@ async def create_ticket_issue( if not ticket_obj: 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_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( 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 = [] if issue.tagged_user_ids: result = await db.execute( select(User).where( User.id.in_(issue.tagged_user_ids), - User.tenant_id == current_user.tenant_id, User.is_active == True, ) ) @@ -526,10 +631,9 @@ async def create_ticket_issue( if missing: raise HTTPException( 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_filename = None attachment_mime_type = None @@ -539,19 +643,13 @@ async def create_ticket_issue( attachment_filename = file_metadata["original_filename"] attachment_mime_type = file_metadata["mime_type"] - # 5. Crear el asunto new_issue = TicketIssue( - id=uuid.uuid4(), - ticket_id=ticket_uuid, - tenant_id=current_user.tenant_id, - created_by=current_user.id, - content=issue.content, - priority=TicketPriority[issue.priority], - attachment_path=attachment_path, - attachment_filename=attachment_filename, + id=uuid.uuid4(), ticket_id=ticket_uuid, + tenant_id=current_user.tenant_id, created_by=current_user.id, + content=issue.content, priority=TicketPriority[issue.priority], + attachment_path=attachment_path, attachment_filename=attachment_filename, attachment_mime_type=attachment_mime_type, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + created_at=datetime.utcnow(), updated_at=datetime.utcnow(), ) new_issue.tagged_users = tagged_users db.add(new_issue) @@ -559,27 +657,18 @@ async def create_ticket_issue( await db.commit() await db.refresh(new_issue, ["created_by_user", "tagged_users"]) - # 6. Audit log 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, + 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, + "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, + 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=[ @@ -588,6 +677,5 @@ async def create_ticket_issue( ], 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, + created_at=new_issue.created_at, updated_at=new_issue.updated_at, ) \ No newline at end of file diff --git a/frontend-internal/src/lib/stores/auth.ts b/frontend-internal/src/lib/stores/auth.ts index 15684c3..07ebb6c 100644 --- a/frontend-internal/src/lib/stores/auth.ts +++ b/frontend-internal/src/lib/stores/auth.ts @@ -54,13 +54,13 @@ function createAuthStore() { try { const response = await fetch('/api/v1/auth/me', { credentials: 'include', - headers: { 'X-App': 'client' } + headers: { 'X-App': 'internal' } }); if (response.ok) { const user = await response.json(); set({ user, token: null, isAuthenticated: true, isLoading: false }); } - } catch (error) {} + } catch (error) { } } }, login: async (credentials: LoginRequest): Promise => { @@ -94,12 +94,12 @@ function createAuthStore() { method: 'POST', credentials: 'include', headers: { - 'X-App': 'client', + 'X-App': 'internal', 'X-Tenant-Slug': slug, ...(token ? { 'Authorization': `Bearer ${token}` } : {}) } }); - } catch {} + } catch { } set(initialState); if (typeof window !== 'undefined') { window.location.href = '/login'; diff --git a/frontend-internal/vite.config.js b/frontend-internal/vite.config.js index 4aaea33..0004d11 100644 --- a/frontend-internal/vite.config.js +++ b/frontend-internal/vite.config.js @@ -1,35 +1,36 @@ import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; + export default defineConfig({ - plugins: [sveltekit()], - server: { - port: parseInt(process.env.PORT || '3001'), - host: '0.0.0.0', - watch: { - usePolling: true, - interval: 500 - }, - hmr: { - host: 'localhost', - clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3001') - }, - fs: { - allow: ['/app', '.'], - strict: false - }, - proxy: { - '/api': { - target: process.env.PUBLIC_API_URL || 'http://backend:8000', - changeOrigin: true, - rewrite: (path) => path.replace(/^\/api/, '') - } - } + plugins: [sveltekit()], + server: { + port: parseInt(process.env.PORT || '3001'), + host: '0.0.0.0', + watch: { + usePolling: true, + interval: 500 }, - preview: { - port: parseInt(process.env.PORT || '3001'), - host: '0.0.0.0' + hmr: { + host: 'localhost', + clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3001') }, - build: { - target: 'esnext' + fs: { + allow: ['/app', '.'], + strict: false + }, + proxy: { + '/api': { + target: process.env.PUBLIC_API_URL || 'http://backend:8000', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, '') + } } -}); + }, + preview: { + port: parseInt(process.env.PORT || '3001'), + host: '0.0.0.0' + }, + build: { + target: 'esnext' + } +}); \ No newline at end of file