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
150 lines
5.9 KiB
Python
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()}
|