"""Support ticket endpoints.""" import os import threading from datetime import datetime, timezone, timedelta from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, desc from app.auth.dependencies import require_super_admin, get_current_user from app.database import get_db from app.models.user import HubUser, UserRole from app.models.ticket import SupportTicket, TicketReply, TicketStatus, TicketPriority, TicketCategory router = APIRouter(prefix="/api/tickets", tags=["tickets"]) class TicketCreate(BaseModel): subject: str body: str category: TicketCategory = TicketCategory.general class TicketUpdate(BaseModel): status: Optional[TicketStatus] = None priority: Optional[TicketPriority] = None assigned_to: Optional[str] = None class ReplyCreate(BaseModel): body: str is_internal: bool = False class BulkClose(BaseModel): ticket_ids: list[str] def _sla_status(ticket: SupportTicket) -> str: if ticket.status in (TicketStatus.resolved, TicketStatus.closed): return "resolved" if ticket.first_response_at: return "responded" age_h = (datetime.now(timezone.utc) - ticket.created_at).total_seconds() / 3600 if age_h > 48: return "breached" if age_h > 24: return "at_risk" return "on_track" def _ticket_out(t: SupportTicket, school_name: str | None = None) -> dict: return { "id": t.id, "school_id": t.school_id, "school_name": school_name, "ticket_number": t.ticket_number, "subject": t.subject, "body": t.body, "category": t.category.value, "status": t.status.value, "priority": t.priority.value, "assigned_to": t.assigned_to, "sla_status": _sla_status(t), "first_response_at": t.first_response_at.isoformat() if t.first_response_at else None, "resolved_at": t.resolved_at.isoformat() if t.resolved_at else None, "created_at": t.created_at.isoformat(), "updated_at": t.updated_at.isoformat(), } def _next_ticket_number(count: int) -> str: from datetime import date return f"TKT-{date.today().year}-{count + 1:05d}" def _notify_async(ticket_id: str, message_body: str, is_reply: bool, from_admin: bool): """Send ticket email notifications in a background thread.""" import os as _os from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from app.models.ticket import SupportTicket from app.models.school import School from app.models.user import HubUser, UserRole from app.services.email import send_email db_url = _os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub") engine = create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True) db = sessionmaker(bind=engine)() try: t = db.get(SupportTicket, ticket_id) if not t: return hub_url = _os.getenv("HUB_BASE_URL", "http://localhost:8090") ticket_url = f"{hub_url}/tickets/{t.id}" base_ctx = { "ticket_number": t.ticket_number, "subject": t.subject, "category": t.category.value, "priority": t.priority.value, "status": t.status.value, "message_body": message_body, "ticket_url": ticket_url, } if is_reply and from_admin: school = db.get(School, t.school_id) if school and school.billing_email: send_email( to=school.billing_email, subject=f"[TapTrack] Reply on {t.ticket_number}: {t.subject}", body=f"Support replied to ticket {t.ticket_number}.", template_name="email/ticket_notification.html", context={**base_ctx, "email_title": f"Reply on Ticket {t.ticket_number}", "recipient_name": school.contact_name or school.name, "message_label": "Support Reply"}, email_type="other", school_id=t.school_id, ) else: admins = db.execute( select(HubUser).where(HubUser.role == UserRole.super_admin, HubUser.is_active == True) ).scalars().all() title = f"New Ticket: {t.ticket_number}" if not is_reply else f"Customer Reply on {t.ticket_number}" for admin in admins: send_email( to=admin.email, subject=f"[TapTrack] {title}: {t.subject}", body=f"{title}\n\n{message_body}", template_name="email/ticket_notification.html", context={**base_ctx, "email_title": title, "recipient_name": admin.full_name, "message_label": "Message" if not is_reply else "Customer Reply"}, email_type="other", school_id=t.school_id, ) except Exception as e: import logging logging.getLogger(__name__).warning("Ticket notification error: %s", e) finally: db.close() engine.dispose() @router.get("") async def list_tickets( school_id: Optional[str] = Query(None), status: Optional[TicketStatus] = Query(None), priority: Optional[TicketPriority] = Query(None), page: int = Query(1, ge=1), per_page: int = Query(25), current_user: HubUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): from app.models.school import School stmt = select(SupportTicket).order_by(desc(SupportTicket.updated_at)) if current_user.role != UserRole.super_admin: stmt = stmt.where(SupportTicket.school_id == current_user.school_id) elif school_id: stmt = stmt.where(SupportTicket.school_id == school_id) if status: stmt = stmt.where(SupportTicket.status == status) if priority: stmt = stmt.where(SupportTicket.priority == priority) total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one() tickets = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all() sids = {t.school_id for t in tickets} school_map: dict[str, str] = {} if sids: schools = (await db.execute(select(School).where(School.id.in_(sids)))).scalars().all() school_map = {s.id: s.name for s in schools} return {"items": [_ticket_out(t, school_map.get(t.school_id)) for t in tickets], "total": total, "page": page, "per_page": per_page} @router.post("", status_code=201) async def create_ticket( body: TicketCreate, current_user: HubUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): if not current_user.school_id: raise HTTPException(400, "No school associated with your account") count = (await db.execute(select(func.count()).select_from(SupportTicket))).scalar_one() ticket = SupportTicket( school_id=current_user.school_id, submitted_by=current_user.id, ticket_number=_next_ticket_number(count), subject=body.subject, body=body.body, category=body.category, ) db.add(ticket) await db.commit() threading.Thread(target=_notify_async, args=(ticket.id, body.body, False, False), daemon=True).start() from app.models.school import School school = (await db.execute(select(School).where(School.id == current_user.school_id))).scalar_one_or_none() return _ticket_out(ticket, school.name if school else None) @router.get("/{ticket_id}") async def get_ticket( ticket_id: str, current_user: HubUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): from app.models.school import School t = (await db.execute(select(SupportTicket).where(SupportTicket.id == ticket_id))).scalar_one_or_none() if not t: raise HTTPException(404) if current_user.role != UserRole.super_admin and t.school_id != current_user.school_id: raise HTTPException(403) replies = (await db.execute( select(TicketReply).where(TicketReply.ticket_id == ticket_id).order_by(TicketReply.created_at) )).scalars().all() visible = [r for r in replies if not r.is_internal or current_user.role == UserRole.super_admin] school = (await db.execute(select(School).where(School.id == t.school_id))).scalar_one_or_none() return { **_ticket_out(t, school.name if school else None), "replies": [{"id": r.id, "body": r.body, "is_internal": r.is_internal, "author_id": r.author_id, "created_at": r.created_at.isoformat()} for r in visible], } @router.put("/{ticket_id}") async def update_ticket( ticket_id: str, body: TicketUpdate, _admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db), ): t = (await db.execute(select(SupportTicket).where(SupportTicket.id == ticket_id))).scalar_one_or_none() if not t: raise HTTPException(404) for field, value in body.model_dump(exclude_none=True).items(): setattr(t, field, value) if body.status in (TicketStatus.resolved, TicketStatus.closed) and not t.resolved_at: t.resolved_at = datetime.now(timezone.utc) await db.commit() return _ticket_out(t) @router.post("/{ticket_id}/replies", status_code=201) async def add_reply( ticket_id: str, body: ReplyCreate, current_user: HubUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): t = (await db.execute(select(SupportTicket).where(SupportTicket.id == ticket_id))).scalar_one_or_none() if not t: raise HTTPException(404) if current_user.role != UserRole.super_admin and t.school_id != current_user.school_id: raise HTTPException(403) is_internal = body.is_internal and current_user.role == UserRole.super_admin reply = TicketReply(ticket_id=ticket_id, author_id=current_user.id, body=body.body, is_internal=is_internal) db.add(reply) if current_user.role == UserRole.super_admin and not t.first_response_at: t.first_response_at = datetime.now(timezone.utc) if t.status == TicketStatus.open: t.status = TicketStatus.in_progress await db.commit() if not is_internal: from_admin = current_user.role == UserRole.super_admin threading.Thread(target=_notify_async, args=(t.id, body.body, True, from_admin), daemon=True).start() return {"id": reply.id, "body": reply.body, "is_internal": is_internal, "created_at": reply.created_at.isoformat()} @router.post("/bulk-close") async def bulk_close_tickets( body: BulkClose, _admin: HubUser = Depends(require_super_admin), db: AsyncSession = Depends(get_db), ): now = datetime.now(timezone.utc) tickets = (await db.execute( select(SupportTicket).where(SupportTicket.id.in_(body.ticket_ids)) )).scalars().all() closed = 0 for t in tickets: if t.status != TicketStatus.closed: t.status = TicketStatus.closed if not t.resolved_at: t.resolved_at = now closed += 1 await db.commit() return {"closed": closed, "total": len(tickets)}