Files
TapTrack-Hub/backend/app/routers/tickets.py
kevin-asprec 73a17aaf9a feat(phase-1): TapTrack Hub initial scaffold
Full project scaffold for TapTrack Hub — cloud SaaS control plane
for managing on-prem TapTrack school deployments.

## Infrastructure
- Docker Compose: backend (gunicorn+uvicorn), Celery worker + beat,
  frontend (Vite build + nginx), PostgreSQL 15, Redis 7, nginx proxy
- Dockerfile for backend and frontend, nginx reverse proxy config

## Backend (FastAPI + SQLAlchemy async + Celery)
Database schema (10 tables):
  hub_users, schools, licenses, sms_jobs, sms_credit_ledger,
  invoices, invoice_line_items, school_subscriptions,
  support_tickets, ticket_replies, audit_logs, announcements

Auth: JWT (python-jose) + bcrypt + role-based FastAPI dependencies
  (get_current_user, require_super_admin, require_school_admin)

Routers (11): auth, schools, licenses, sms, billing, tickets,
  users, dashboard, school_portal, announcements, sync

Celery tasks (6):
  sms.process_queue, billing.generate_monthly_invoices,
  billing.send_invoice_email, billing.check_overdue,
  license.check_expiry, reports.send_monthly_reports

Services: SMTP email helper (smtplib + Jinja2)
Seed script: creates super admin admin@taptrack.io

## Frontend (Vue 3 + Vite + Pinia + Tailwind CSS)
Router: 14 routes across super admin + school portal layouts
Stores: Pinia auth store with localStorage persistence
API client: full axios client for all backend endpoints
Layouts: AppLayout (super admin), PortalLayout (school), AuthLayout
Components: AppSidebar, PortalSidebar, SidebarItem, KpiCard,
  StatusBadge, ToastStack
Pages: Login, Dashboard, Schools, SchoolDetail, Licenses, SMS,
  Billing, Tickets, TicketDetail, Users, Announcements, 404
Portal pages: Overview, Billing, SMS Reports, Tickets, Profile

## PAUL Planning Files
- .paul/ROADMAP.md: full 15-phase roadmap with detailed scope
- .paul/STATE.md: current position, tech stack, architecture notes
- .paul/phases/01-setup/01-PLAN.md: complete Phase 1 plan (done)
- .paul/phases/02 through 15: README stubs for all future phases
2026-03-16 07:26:06 +08:00

150 lines
5.9 KiB
Python

"""Support ticket endpoints."""
import uuid
from datetime import datetime, timezone
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
def _ticket_out(t: SupportTicket) -> dict:
return {
"id": t.id, "school_id": t.school_id, "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,
"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}"
@router.get("")
async def list_tickets(
school_id: Optional[str] = Query(None),
status: Optional[TicketStatus] = 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),
):
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)
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()
return {"items": [_ticket_out(t) 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()
return _ticket_out(ticket)
@router.get("/{ticket_id}")
async def get_ticket(
ticket_id: str,
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)
replies_res = await db.execute(select(TicketReply).where(TicketReply.ticket_id == ticket_id).order_by(TicketReply.created_at))
replies = replies_res.scalars().all()
visible_replies = [r for r in replies if not r.is_internal or current_user.role == UserRole.super_admin]
return {
**_ticket_out(t),
"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_replies
],
}
@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)
# Set first response time (super admin only)
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()
return {"id": reply.id, "body": reply.body, "created_at": reply.created_at.isoformat()}