"""Tickets endpoints - ServiceManagerWeb""" from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from sqlalchemy.orm import selectinload from typing import List, Optional from datetime import datetime, timedelta import uuid from app.models.issue import TicketIssue, ticket_issue_tagged_users from app.api.schemas.issue import IssueCreate, IssueResponse from app.core.database import get_db from app.api.deps import get_current_user, get_current_tenant from app.models.ticket import Ticket, TicketStatus, TicketPriority from app.models.user import User, UserRole from app.models.tenant import Tenant from app.models.category import Category from app.models.system import System 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, TicketCloseRequest, CommentCreate, CommentResponse ) from app.core.file_handler import file_handler from app.api.v1.helpers import ( validate_uuid_param, apply_client_permissions, apply_enum_filter, safe_audit_log, generate_next_ticket_number, calculate_sla_deadlines, ticket_to_dict ) from app.services.audit_service import AuditService from app.services.ticket_service import TicketService, get_ticket_service router = APIRouter() @router.post("/", response_model=TicketResponse, status_code=status.HTTP_201_CREATED) async def create_ticket( ticket: TicketCreate, current_user: User = Depends(get_current_user), ticket_service: TicketService = Depends(get_ticket_service), ): """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)): """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( selectinload(Ticket.category), selectinload(Ticket.affected_system), 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)): """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) query = apply_enum_filter(query, Ticket.status, status_filter, TicketStatus, "status") query = apply_enum_filter(query, Ticket.priority, priority_filter, TicketPriority, "priority") if tenant_id_filter: query = query.where(Ticket.tenant_id == validate_uuid_param(tenant_id_filter, "tenant ID")) if category_filter: query = query.where(Ticket.category_id == validate_uuid_param(category_filter, "category ID")) if assigned_to_filter: query = query.where(Ticket.assigned_to == validate_uuid_param(assigned_to_filter, "assigned user ID")) if search: search_pattern = f"%{search}%" query = query.where((Ticket.subject.ilike(search_pattern)) | (Ticket.description.ilike(search_pattern))) if date_from: try: date_from_parsed = datetime.fromisoformat(date_from) query = query.where(Ticket.created_at >= date_from_parsed) except ValueError: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid date_from format. Use YYYY-MM-DD") if date_to: try: date_to_parsed = datetime.fromisoformat(date_to) + timedelta(days=1) 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: from app.models.category import Category as CategoryModel cat_result = await db.execute(select(CategoryModel).where(CategoryModel.id.in_(category_ids))) categories_map = {c.id: c.name for c in cat_result.scalars().all()} return [ {"id": str(ticket.id), "ticket_number": ticket.ticket_number, "subject": ticket.subject, "description": ticket.description, "status": ticket.status.value, "priority": ticket.priority.value, "tenant_id": str(ticket.tenant_id), "tenant_name": tenant.name, "tenant_slug": tenant.slug, "category_id": str(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}", "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} 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)): """Obtener un ticket por ID""" ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id) if current_user.role.is_client: query = query.where(Ticket.created_by == current_user.id) 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)): """Actualizar un ticket""" ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id) if current_user.role.is_client: query = 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} update_data = ticket.dict(exclude_unset=True) for field, value in update_data.items(): if field == "status" and value: setattr(db_ticket, field, TicketStatus[value.upper()]) elif field == "priority" and value: setattr(db_ticket, field, TicketPriority[value.upper()]) elif field in ["category_id", "affected_system_id", "assigned_to"] and value: 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, action="ticket.update", resource_type="ticket", resource_id=db_ticket.id, 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)): """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, content=f"Ticket cerrado: {close_request.resolution_notes}", 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, 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}) 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)): """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 = 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()) 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} 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)): """Crear un comentario en un ticket""" ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id) if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]: query = 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") 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), "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)): """Eliminar un ticket (solo admin/manager)""" 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} 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) 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)): """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)) 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: 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())) 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, 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" ) 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)): """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)) 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: raise HTTPException(status_code=404, detail="Ticket no encontrado") file_metadata = await file_handler.save_upload(file, current_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() ) 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_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" ) } @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)): """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)) 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: 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)) 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) @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") # Verificar que el ticket existe y pertenece al tenant query = select(Ticket).where( Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id, ) if current_user.role.is_client: query = query.where(Ticket.created_by == current_user.id) result = await db.execute(query) ticket_obj = result.scalars().first() if not ticket_obj: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ticket no encontrado") issues_query = ( select(TicketIssue) .where(TicketIssue.ticket_id == ticket_uuid) .options( selectinload(TicketIssue.created_by_user), selectinload(TicketIssue.tagged_users), ) .order_by(TicketIssue.created_at.desc()) ) result = await db.execute(issues_query) issues = result.scalars().all() return [ IssueResponse( id=issue.id, ticket_id=issue.ticket_id, tenant_id=issue.tenant_id, content=issue.content, priority=issue.priority.value, created_by=issue.created_by, created_by_name=f"{issue.created_by_user.first_name} {issue.created_by_user.last_name}", tagged_users=[ {"id": u.id, "full_name": f"{u.first_name} {u.last_name}", "email": u.email} for u in issue.tagged_users ], attachment_filename=issue.attachment_filename, attachment_mime_type=issue.attachment_mime_type, created_at=issue.created_at, updated_at=issue.updated_at, ) for issue in issues ] @router.post("/{ticket_id}/issues", response_model=IssueResponse, status_code=status.HTTP_201_CREATED) async def create_ticket_issue( ticket_id: str, issue: IssueCreate, file: Optional[UploadFile] = File(None), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), current_tenant: Tenant = Depends(get_current_tenant), ): """ Crear un asunto de escalación en un ticket. Solo puede crearlo el dueño del ticket o el CLIENT_ADMIN del tenant. """ 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, Ticket.tenant_id == current_user.tenant_id, ) ) ticket_obj = result.scalars().first() if not ticket_obj: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ticket no encontrado") # 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 if not is_ticket_owner and not is_client_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Solo el creador del ticket o el administrador del tenant 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, ) ) 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 en el tenant: {missing}", ) # 4. Manejar adjunto opcional attachment_path = None attachment_filename = None attachment_mime_type = None if file and file.filename: file_metadata = await file_handler.save_upload(file, current_tenant.id, ticket_uuid) attachment_path = file_metadata["file_path"] attachment_filename = file_metadata["original_filename"] attachment_mime_type = file_metadata["mime_type"] # 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, attachment_mime_type=attachment_mime_type, created_at=datetime.utcnow(), updated_at=datetime.utcnow(), ) new_issue.tagged_users = tagged_users db.add(new_issue) await db.commit() await db.refresh(new_issue, ["created_by_user", "tagged_users"]) # 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, 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, )