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
This commit is contained in:
kevin-asprec
2026-03-16 07:26:06 +08:00
commit 73a17aaf9a
107 changed files with 4764 additions and 0 deletions

View File

View File

@@ -0,0 +1,54 @@
"""Announcements — super admin creates, all users read."""
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, and_
from app.auth.dependencies import require_super_admin, get_current_user
from app.database import get_db
from app.models.user import HubUser
from app.models.announcement import Announcement
router = APIRouter(prefix="/api/announcements", tags=["announcements"])
class AnnouncementCreate(BaseModel):
title: str
body: str
expires_at: Optional[datetime] = None
@router.get("")
async def list_announcements(
current_user: HubUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = select(Announcement).where(
and_(Announcement.is_active == True,
(Announcement.expires_at == None) | (Announcement.expires_at > datetime.now(timezone.utc)))
).order_by(desc(Announcement.created_at)).limit(10)
items = (await db.execute(stmt)).scalars().all()
return [{"id": a.id, "title": a.title, "body": a.body, "created_at": a.created_at.isoformat()} for a in items]
@router.post("", status_code=201)
async def create_announcement(
body: AnnouncementCreate,
admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
ann = Announcement(title=body.title, body=body.body, created_by=admin.id, expires_at=body.expires_at)
db.add(ann)
await db.commit()
return {"id": ann.id, "title": ann.title, "created_at": ann.created_at.isoformat()}
@router.delete("/{ann_id}", status_code=204)
async def delete_announcement(
ann_id: str,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
ann = (await db.execute(select(Announcement).where(Announcement.id == ann_id))).scalar_one_or_none()
if not ann:
raise HTTPException(404)
ann.is_active = False
await db.commit()

View File

@@ -0,0 +1,73 @@
"""Authentication endpoints for TapTrack Hub."""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status, Request
from pydantic import BaseModel, EmailStr
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.auth.password import verify_password, hash_password
from app.auth.jwt import create_access_token
from app.auth.dependencies import get_current_user
from app.database import get_db
from app.models.user import HubUser, UserRole
router = APIRouter(prefix="/api/auth", tags=["auth"])
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
role: str
user_id: str
full_name: str
school_id: str | None
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(HubUser).where(HubUser.email == body.email))
user = result.scalar_one_or_none()
if not user or not verify_password(body.password, user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if not user.is_active:
raise HTTPException(status_code=401, detail="Account is inactive")
user.last_login_at = datetime.now(timezone.utc)
await db.commit()
token = create_access_token({"sub": user.id, "role": user.role.value})
return TokenResponse(
access_token=token,
role=user.role.value,
user_id=user.id,
full_name=user.full_name,
school_id=user.school_id,
)
@router.get("/me")
async def get_me(current_user: HubUser = Depends(get_current_user)):
return {
"id": current_user.id,
"email": current_user.email,
"full_name": current_user.full_name,
"role": current_user.role.value,
"school_id": current_user.school_id,
"is_active": current_user.is_active,
}
@router.put("/me/password", status_code=204)
async def change_password(
body: ChangePasswordRequest,
current_user: HubUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if len(body.new_password) < 8:
raise HTTPException(status_code=422, detail="Password must be at least 8 characters")
if not verify_password(body.current_password, current_user.hashed_password):
raise HTTPException(status_code=400, detail="Current password is incorrect")
current_user.hashed_password = hash_password(body.new_password)
await db.commit()

View File

@@ -0,0 +1,181 @@
"""Billing and invoice endpoints."""
from datetime import date, 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.billing import Invoice, InvoiceStatus, InvoiceLineItem, SchoolSubscription, BillingCycle
router = APIRouter(prefix="/api/billing", tags=["billing"])
class InvoiceCreate(BaseModel):
school_id: str
billing_period_start: date
billing_period_end: date
subscription_amount: float = 0.0
sms_credit_amount: float = 0.0
other_amount: float = 0.0
due_date: Optional[date] = None
notes: Optional[str] = None
line_items: list[dict] = []
class InvoiceUpdate(BaseModel):
status: Optional[InvoiceStatus] = None
paid_at: Optional[datetime] = None
payment_method: Optional[str] = None
payment_reference: Optional[str] = None
notes: Optional[str] = None
class SubscriptionUpsert(BaseModel):
monthly_fee: float
sms_cost_per_message: float = 1.0
cycle: BillingCycle = BillingCycle.monthly
next_billing_date: Optional[date] = None
def _inv_out(inv: Invoice) -> dict:
return {
"id": inv.id, "school_id": inv.school_id, "invoice_number": inv.invoice_number,
"status": inv.status.value,
"billing_period_start": inv.billing_period_start.isoformat(),
"billing_period_end": inv.billing_period_end.isoformat(),
"subscription_amount": float(inv.subscription_amount),
"sms_credit_amount": float(inv.sms_credit_amount),
"other_amount": float(inv.other_amount),
"total_amount": float(inv.total_amount),
"currency": inv.currency,
"due_date": inv.due_date.isoformat() if inv.due_date else None,
"paid_at": inv.paid_at.isoformat() if inv.paid_at else None,
"payment_method": inv.payment_method,
"payment_reference": inv.payment_reference,
"email_sent_at": inv.email_sent_at.isoformat() if inv.email_sent_at else None,
"created_at": inv.created_at.isoformat(),
"notes": inv.notes,
}
def _next_invoice_number(existing_count: int) -> str:
from datetime import date
return f"INV-{date.today().strftime('%Y%m')}-{existing_count + 1:04d}"
@router.get("/invoices")
async def list_invoices(
school_id: Optional[str] = Query(None),
status: Optional[InvoiceStatus] = 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(Invoice).order_by(desc(Invoice.created_at))
if current_user.role != UserRole.super_admin:
stmt = stmt.where(Invoice.school_id == current_user.school_id)
elif school_id:
stmt = stmt.where(Invoice.school_id == school_id)
if status:
stmt = stmt.where(Invoice.status == status)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
invoices = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all()
return {"items": [_inv_out(i) for i in invoices], "total": total, "page": page, "per_page": per_page}
@router.post("/invoices", status_code=201)
async def create_invoice(
body: InvoiceCreate,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
total = body.subscription_amount + body.sms_credit_amount + body.other_amount
count = (await db.execute(select(func.count()).select_from(Invoice))).scalar_one()
inv = Invoice(
school_id=body.school_id,
invoice_number=_next_invoice_number(count),
billing_period_start=body.billing_period_start,
billing_period_end=body.billing_period_end,
subscription_amount=body.subscription_amount,
sms_credit_amount=body.sms_credit_amount,
other_amount=body.other_amount,
total_amount=total,
due_date=body.due_date,
notes=body.notes,
)
db.add(inv)
await db.flush()
for item in body.line_items:
db.add(InvoiceLineItem(
invoice_id=inv.id,
description=item.get("description", ""),
quantity=item.get("quantity", 1),
unit_price=item.get("unit_price", 0),
amount=item.get("amount", 0),
))
await db.commit()
return _inv_out(inv)
@router.put("/invoices/{invoice_id}")
async def update_invoice(
invoice_id: str,
body: InvoiceUpdate,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
inv = (await db.execute(select(Invoice).where(Invoice.id == invoice_id))).scalar_one_or_none()
if not inv:
raise HTTPException(404, "Invoice not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(inv, field, value)
await db.commit()
return _inv_out(inv)
@router.post("/invoices/{invoice_id}/send-email")
async def send_invoice_email(
invoice_id: str,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
from app.tasks.billing import send_invoice_email_task
inv = (await db.execute(select(Invoice).where(Invoice.id == invoice_id))).scalar_one_or_none()
if not inv:
raise HTTPException(404, "Invoice not found")
send_invoice_email_task.delay(invoice_id)
return {"message": "Email queued"}
@router.get("/subscriptions/{school_id}")
async def get_subscription(
school_id: str,
current_user: HubUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if current_user.role != UserRole.super_admin and current_user.school_id != school_id:
raise HTTPException(403)
sub = (await db.execute(select(SchoolSubscription).where(SchoolSubscription.school_id == school_id))).scalar_one_or_none()
if not sub:
raise HTTPException(404, "No subscription found")
return {
"id": sub.id, "school_id": sub.school_id, "cycle": sub.cycle.value,
"monthly_fee": float(sub.monthly_fee), "sms_cost_per_message": float(sub.sms_cost_per_message),
"next_billing_date": sub.next_billing_date.isoformat() if sub.next_billing_date else None,
"is_active": sub.is_active,
}
@router.put("/subscriptions/{school_id}")
async def upsert_subscription(
school_id: str,
body: SubscriptionUpsert,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
sub = (await db.execute(select(SchoolSubscription).where(SchoolSubscription.school_id == school_id))).scalar_one_or_none()
if sub:
sub.monthly_fee = body.monthly_fee
sub.sms_cost_per_message = body.sms_cost_per_message
sub.cycle = body.cycle
if body.next_billing_date:
sub.next_billing_date = body.next_billing_date
else:
sub = SchoolSubscription(school_id=school_id, **body.model_dump())
db.add(sub)
await db.commit()
return {"monthly_fee": float(sub.monthly_fee), "cycle": sub.cycle.value}

View File

@@ -0,0 +1,53 @@
"""Super admin dashboard summary."""
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from datetime import date, timedelta
from app.auth.dependencies import require_super_admin
from app.database import get_db
from app.models.user import HubUser
from app.models.school import School, SchoolStatus
from app.models.license import License, LicenseStatus
from app.models.sms import SmsJob, SmsJobStatus
from app.models.billing import Invoice, InvoiceStatus
from app.models.ticket import SupportTicket, TicketStatus
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@router.get("/summary")
async def get_summary(
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
total_schools = (await db.execute(select(func.count()).select_from(School))).scalar_one()
active_schools = (await db.execute(select(func.count()).where(School.status == SchoolStatus.active))).scalar_one()
suspended_schools = (await db.execute(select(func.count()).where(School.status == SchoolStatus.suspended))).scalar_one()
expiring_soon = (await db.execute(
select(func.count()).where(
and_(License.expires_at != None, License.expires_at <= date.today() + timedelta(days=30),
License.status == LicenseStatus.active)
)
)).scalar_one()
open_tickets = (await db.execute(
select(func.count()).where(SupportTicket.status.in_([TicketStatus.open, TicketStatus.in_progress]))
)).scalar_one()
pending_invoices = (await db.execute(
select(func.count()).where(Invoice.status.in_([InvoiceStatus.sent, InvoiceStatus.overdue]))
)).scalar_one()
sms_today = (await db.execute(
select(func.count()).where(
and_(func.date(SmsJob.created_at) == date.today(), SmsJob.status == SmsJobStatus.sent)
)
)).scalar_one()
sms_pending = (await db.execute(
select(func.count()).where(SmsJob.status == SmsJobStatus.pending)
)).scalar_one()
return {
"schools": {"total": total_schools, "active": active_schools, "suspended": suspended_schools},
"licenses": {"expiring_soon": expiring_soon},
"tickets": {"open": open_tickets},
"invoices": {"pending": pending_invoices},
"sms": {"sent_today": sms_today, "pending": sms_pending},
}

View File

@@ -0,0 +1,116 @@
"""License management endpoints."""
from datetime import date, datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from app.auth.dependencies import require_super_admin
from app.database import get_db
from app.models.user import HubUser
from app.models.license import License, LicenseStatus
from app.models.school import School, SchoolStatus
router = APIRouter(prefix="/api/licenses", tags=["licenses"])
class LicenseUpdate(BaseModel):
status: Optional[LicenseStatus] = None
expires_at: Optional[date] = None
max_students: Optional[int] = None
notes: Optional[str] = None
@router.get("")
async def list_licenses(
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(License))
return [
{
"id": l.id, "school_id": l.school_id, "key": l.key,
"status": l.status.value, "tier": l.tier,
"issued_at": l.issued_at.isoformat(),
"expires_at": l.expires_at.isoformat() if l.expires_at else None,
"last_validated_at": l.last_validated_at.isoformat() if l.last_validated_at else None,
"last_seen_ip": l.last_seen_ip,
"max_students": l.max_students,
}
for l in result.scalars().all()
]
@router.put("/{license_id}")
async def update_license(
license_id: str,
body: LicenseUpdate,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
lic = (await db.execute(select(License).where(License.id == license_id))).scalar_one_or_none()
if not lic:
raise HTTPException(404, "License not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(lic, field, value)
await db.commit()
return {"id": lic.id, "status": lic.status.value, "expires_at": lic.expires_at.isoformat() if lic.expires_at else None}
@router.post("/{license_id}/revoke")
async def revoke_license(
license_id: str,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
lic = (await db.execute(select(License).where(License.id == license_id))).scalar_one_or_none()
if not lic:
raise HTTPException(404, "License not found")
lic.status = LicenseStatus.revoked
# Also suspend the school
school = (await db.execute(select(School).where(School.id == lic.school_id))).scalar_one_or_none()
if school:
school.status = SchoolStatus.suspended
await db.commit()
return {"message": "License revoked"}
@router.post("/validate")
async def validate_license(
request: Request,
db: AsyncSession = Depends(get_db),
):
"""Called by on-prem TapTrack to validate their license key. No auth required — uses key."""
body = await request.json()
key: str = body.get("key", "")
if not key:
raise HTTPException(400, "License key required")
lic = (await db.execute(select(License).where(License.key == key))).scalar_one_or_none()
if not lic:
return {"valid": False, "reason": "Key not found"}
if lic.status == LicenseStatus.revoked:
return {"valid": False, "reason": "License revoked"}
if lic.expires_at and lic.expires_at < date.today():
lic.status = LicenseStatus.expired
await db.commit()
return {"valid": False, "reason": "License expired", "expired_at": lic.expires_at.isoformat()}
# Update validation metadata
lic.last_validated_at = datetime.now(timezone.utc)
lic.last_seen_ip = request.client.host if request.client else None
school = (await db.execute(select(School).where(School.id == lic.school_id))).scalar_one_or_none()
await db.commit()
return {
"valid": True,
"school_id": lic.school_id,
"school_name": school.name if school else None,
"tier": lic.tier,
"max_students": lic.max_students,
"expires_at": lic.expires_at.isoformat() if lic.expires_at else None,
"sms_sender_name": school.sms_sender_name if school else "SCHOOL",
"sms_credits": float(school.sms_credits) if school else 0.0,
"features": _tier_features(lic.tier),
}
def _tier_features(tier: str) -> dict:
base = {"sms": True, "reports": True, "websocket": True, "multi_terminal": True}
if tier == "premium":
base.update({"api_keys": True, "webhooks": True, "bulk_enrollment": True})
elif tier == "basic":
base.update({"multi_terminal": False, "api_keys": False, "webhooks": False})
return base

View File

@@ -0,0 +1,63 @@
"""School admin portal — school-scoped read endpoints."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc, and_
from datetime import date, timedelta
from app.auth.dependencies import require_school_admin, get_current_user
from app.database import get_db
from app.models.user import HubUser, UserRole
from app.models.school import School
from app.models.license import License
from app.models.billing import Invoice
from app.models.sms import SmsJob, SmsJobStatus
from app.models.ticket import SupportTicket
router = APIRouter(prefix="/api/portal", tags=["school-portal"])
async def _get_school(current_user: HubUser, db: AsyncSession) -> School:
if not current_user.school_id:
raise HTTPException(400, "No school linked to your account")
school = (await db.execute(select(School).where(School.id == current_user.school_id))).scalar_one_or_none()
if not school:
raise HTTPException(404, "School not found")
return school
@router.get("/overview")
async def portal_overview(
current_user: HubUser = Depends(require_school_admin),
db: AsyncSession = Depends(get_db),
):
school = await _get_school(current_user, db)
lic = (await db.execute(select(License).where(License.school_id == school.id))).scalar_one_or_none()
pending_inv = (await db.execute(
select(func.count()).where(
and_(Invoice.school_id == school.id, Invoice.status.in_(["sent", "overdue"]))
)
)).scalar_one()
sms_this_month = (await db.execute(
select(func.count()).where(
and_(SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.sent,
func.date_trunc("month", SmsJob.sent_at) == func.date_trunc("month", func.current_date()))
)
)).scalar_one()
open_tickets = (await db.execute(
select(func.count()).where(
and_(SupportTicket.school_id == school.id, SupportTicket.status.in_(["open", "in_progress"]))
)
)).scalar_one()
return {
"school": {"id": school.id, "name": school.name, "status": school.status.value, "tier": school.tier.value},
"license": {
"key": lic.key if lic else None,
"status": lic.status.value if lic else None,
"expires_at": lic.expires_at.isoformat() if lic and lic.expires_at else None,
"last_seen": lic.last_validated_at.isoformat() if lic and lic.last_validated_at else None,
},
"sms_credits": float(school.sms_credits),
"sms_credit_low_threshold": school.sms_credit_low_threshold,
"sms_this_month": sms_this_month,
"pending_invoices": pending_inv,
"open_tickets": open_tickets,
}

View File

@@ -0,0 +1,181 @@
"""School registry endpoints — super admin only."""
import uuid
from datetime import datetime, timezone
from typing import Optional, Any
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, EmailStr
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from slugify import slugify
from app.auth.dependencies import require_super_admin, get_current_user
from app.database import get_db
from app.models.user import HubUser
from app.models.school import School, SchoolStatus, LicenseTier
from app.models.license import License, LicenseStatus
router = APIRouter(prefix="/api/schools", tags=["schools"])
class SchoolCreate(BaseModel):
name: str
address: Optional[str] = None
city: Optional[str] = None
contact_name: Optional[str] = None
contact_email: Optional[EmailStr] = None
contact_phone: Optional[str] = None
billing_email: Optional[EmailStr] = None
tier: LicenseTier = LicenseTier.standard
student_limit: int = 500
sms_sender_name: str = "SCHOOL"
notes: Optional[str] = None
class SchoolUpdate(BaseModel):
name: Optional[str] = None
address: Optional[str] = None
city: Optional[str] = None
contact_name: Optional[str] = None
contact_email: Optional[EmailStr] = None
contact_phone: Optional[str] = None
billing_email: Optional[EmailStr] = None
tier: Optional[LicenseTier] = None
student_limit: Optional[int] = None
sms_sender_name: Optional[str] = None
status: Optional[SchoolStatus] = None
notes: Optional[str] = None
def _school_out(s: School, license: License | None = None) -> dict:
return {
"id": s.id,
"name": s.name,
"slug": s.slug,
"address": s.address,
"city": s.city,
"contact_name": s.contact_name,
"contact_email": s.contact_email,
"contact_phone": s.contact_phone,
"billing_email": s.billing_email,
"status": s.status.value,
"tier": s.tier.value,
"student_limit": s.student_limit,
"sms_sender_name": s.sms_sender_name,
"sms_credits": float(s.sms_credits),
"sms_credit_low_threshold": s.sms_credit_low_threshold,
"created_at": s.created_at.isoformat(),
"notes": s.notes,
"license_key": license.key if license else None,
"license_status": license.status.value if license else None,
"license_expires_at": license.expires_at.isoformat() if license and license.expires_at else None,
"license_last_seen": license.last_validated_at.isoformat() if license and license.last_validated_at else None,
}
@router.get("")
async def list_schools(
page: int = Query(1, ge=1),
per_page: int = Query(25, ge=1, le=100),
search: Optional[str] = Query(None),
status: Optional[SchoolStatus] = Query(None),
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
stmt = select(School).order_by(desc(School.created_at))
if search:
stmt = stmt.where(School.name.ilike(f"%{search}%"))
if status:
stmt = stmt.where(School.status == status)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
schools = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all()
items = []
for s in schools:
lic_res = await db.execute(select(License).where(License.school_id == s.id))
lic = lic_res.scalar_one_or_none()
items.append(_school_out(s, lic))
return {"items": items, "total": total, "page": page, "per_page": per_page}
@router.post("", status_code=201)
async def create_school(
body: SchoolCreate,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
slug = slugify(body.name)
# Ensure slug uniqueness
existing = (await db.execute(select(School).where(School.slug == slug))).scalar_one_or_none()
if existing:
slug = f"{slug}-{uuid.uuid4().hex[:6]}"
school = School(
name=body.name,
slug=slug,
address=body.address,
city=body.city,
contact_name=body.contact_name,
contact_email=body.contact_email,
contact_phone=body.contact_phone,
billing_email=body.billing_email,
tier=body.tier,
student_limit=body.student_limit,
sms_sender_name=body.sms_sender_name[:11],
notes=body.notes,
status=SchoolStatus.pending,
)
db.add(school)
await db.flush()
# Auto-create license
lic = License(school_id=school.id, tier=body.tier.value, max_students=body.student_limit)
db.add(lic)
await db.commit()
await db.refresh(school)
return _school_out(school, lic)
@router.get("/{school_id}")
async def get_school(
school_id: str,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none()
if not school:
raise HTTPException(404, "School not found")
lic = (await db.execute(select(License).where(License.school_id == school_id))).scalar_one_or_none()
return _school_out(school, lic)
@router.put("/{school_id}")
async def update_school(
school_id: str,
body: SchoolUpdate,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none()
if not school:
raise HTTPException(404, "School not found")
for field, value in body.model_dump(exclude_none=True).items():
if field == "sms_sender_name":
value = value[:11]
setattr(school, field, value)
await db.commit()
lic = (await db.execute(select(License).where(License.school_id == school_id))).scalar_one_or_none()
return _school_out(school, lic)
@router.post("/{school_id}/credits")
async def add_sms_credits(
school_id: str,
amount: float,
description: Optional[str] = None,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
from app.models.sms import SmsCreditLedger, SmsCreditTx
school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none()
if not school:
raise HTTPException(404, "School not found")
school.sms_credits = float(school.sms_credits) + amount
ledger = SmsCreditLedger(
school_id=school_id,
tx_type=SmsCreditTx.topup,
amount=amount,
balance_after=float(school.sms_credits),
description=description or f"Manual top-up of {amount} credits",
)
db.add(ledger)
await db.commit()
return {"sms_credits": float(school.sms_credits), "added": amount}

109
backend/app/routers/sms.py Normal file
View File

@@ -0,0 +1,109 @@
"""SMS gateway endpoints."""
from typing import Optional
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc, and_
from app.auth.dependencies import require_super_admin, require_school_admin, get_current_user
from app.database import get_db
from app.models.user import HubUser, UserRole
from app.models.sms import SmsJob, SmsJobStatus, SmsCreditLedger
from app.models.school import School
router = APIRouter(prefix="/api/sms", tags=["sms"])
class SubmitSmsJob(BaseModel):
"""Called by on-prem TapTrack to submit SMS jobs to Hub."""
license_key: str
jobs: list[dict] # [{ recipient_phone, message, trigger }]
class ManualSmsRequest(BaseModel):
school_id: str
recipient_phone: str
message: str
@router.post("/submit", status_code=202)
async def submit_sms_jobs(
body: SubmitSmsJob,
db: AsyncSession = Depends(get_db),
):
"""On-prem posts SMS jobs for Hub to process via Semaphore."""
from app.models.license import License
lic = (await db.execute(select(License).where(License.key == body.license_key))).scalar_one_or_none()
if not lic:
raise HTTPException(403, "Invalid license key")
school = (await db.execute(select(School).where(School.id == lic.school_id))).scalar_one_or_none()
if not school or float(school.sms_credits) <= 0:
raise HTTPException(402, "Insufficient SMS credits")
created_ids = []
for job_data in body.jobs:
job = SmsJob(
school_id=school.id,
recipient_phone=job_data.get("recipient_phone", ""),
message=job_data.get("message", ""),
sender_name=school.sms_sender_name,
trigger=job_data.get("trigger"),
)
db.add(job)
created_ids.append(job.id)
await db.commit()
return {"queued": len(created_ids), "job_ids": created_ids}
@router.get("/jobs")
async def list_sms_jobs(
school_id: Optional[str] = Query(None),
status: Optional[SmsJobStatus] = Query(None),
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=200),
current_user: HubUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = select(SmsJob).order_by(desc(SmsJob.created_at))
if current_user.role != UserRole.super_admin:
stmt = stmt.where(SmsJob.school_id == current_user.school_id)
elif school_id:
stmt = stmt.where(SmsJob.school_id == school_id)
if status:
stmt = stmt.where(SmsJob.status == status)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
jobs = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all()
return {
"items": [
{
"id": j.id, "school_id": j.school_id, "recipient_phone": j.recipient_phone,
"message": j.message[:60] + "..." if len(j.message) > 60 else j.message,
"sender_name": j.sender_name, "status": j.status.value,
"trigger": j.trigger, "created_at": j.created_at.isoformat(),
"sent_at": j.sent_at.isoformat() if j.sent_at else None,
"retry_count": j.retry_count, "error_message": j.error_message,
}
for j in jobs
],
"total": total, "page": page, "per_page": per_page,
}
@router.get("/credits/{school_id}")
async def get_credit_ledger(
school_id: str,
page: int = Query(1, ge=1),
per_page: int = Query(50),
current_user: HubUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if current_user.role != UserRole.super_admin and current_user.school_id != school_id:
raise HTTPException(403)
stmt = select(SmsCreditLedger).where(SmsCreditLedger.school_id == school_id).order_by(desc(SmsCreditLedger.created_at))
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
rows = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all()
return {
"items": [
{"id": r.id, "tx_type": r.tx_type.value, "amount": float(r.amount),
"balance_after": float(r.balance_after), "description": r.description,
"created_at": r.created_at.isoformat()}
for r in rows
],
"total": total,
}

View File

@@ -0,0 +1,77 @@
"""On-prem sync endpoint — polled by TapTrack every 30s to get SMS jobs and config."""
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, update
from fastapi import Depends
from app.database import get_db
from app.models.license import License, LicenseStatus
from app.models.school import School
from app.models.sms import SmsJob, SmsJobStatus
router = APIRouter(prefix="/api/sync", tags=["sync"])
@router.post("/poll")
async def sync_poll(
request: Request,
db: AsyncSession = Depends(get_db),
):
"""
Called by on-prem TapTrack every 30s.
Returns pending SMS jobs and current config (sender_name, credits, feature flags).
Body: { license_key: str, report_sent_ids: [str] } (completed job IDs to mark as sent)
"""
body = await request.json()
key = body.get("license_key", "")
sent_ids = body.get("report_sent_ids", [])
lic = (await db.execute(select(License).where(License.key == key))).scalar_one_or_none()
if not lic or lic.status == LicenseStatus.revoked:
raise HTTPException(403, "Invalid or revoked license")
school = (await db.execute(select(School).where(School.id == lic.school_id))).scalar_one_or_none()
if not school:
raise HTTPException(404)
# Mark completed jobs
if sent_ids:
await db.execute(
update(SmsJob)
.where(and_(SmsJob.id.in_(sent_ids), SmsJob.school_id == school.id))
.values(status=SmsJobStatus.sent, sent_at=datetime.now(timezone.utc))
)
# Get pending jobs (max 50 per poll)
pending_jobs = (await db.execute(
select(SmsJob)
.where(and_(SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.pending))
.limit(50)
)).scalars().all()
# Mark as processing
job_ids = [j.id for j in pending_jobs]
if job_ids:
await db.execute(
update(SmsJob)
.where(SmsJob.id.in_(job_ids))
.values(status=SmsJobStatus.processing)
)
# Update last seen
lic.last_validated_at = datetime.now(timezone.utc)
lic.last_seen_ip = request.client.host if request.client else None
await db.commit()
return {
"sms_jobs": [
{"id": j.id, "recipient_phone": j.recipient_phone,
"message": j.message, "sender_name": j.sender_name}
for j in pending_jobs
],
"config": {
"sms_sender_name": school.sms_sender_name,
"sms_credits": float(school.sms_credits),
"school_status": school.status.value,
},
}

View File

@@ -0,0 +1,149 @@
"""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()}

View File

@@ -0,0 +1,78 @@
"""Hub user management — super admin only."""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, EmailStr
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from app.auth.dependencies import require_super_admin
from app.auth.password import hash_password
from app.database import get_db
from app.models.user import HubUser, UserRole
router = APIRouter(prefix="/api/users", tags=["users"])
class UserCreate(BaseModel):
email: EmailStr
full_name: str
password: str
role: UserRole = UserRole.school_admin
school_id: Optional[str] = None
class UserUpdate(BaseModel):
full_name: Optional[str] = None
is_active: Optional[bool] = None
school_id: Optional[str] = None
@router.get("")
async def list_users(
page: int = Query(1, ge=1),
per_page: int = Query(25),
search: Optional[str] = Query(None),
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
stmt = select(HubUser).order_by(desc(HubUser.created_at))
if search:
stmt = stmt.where(HubUser.email.ilike(f"%{search}%") | HubUser.full_name.ilike(f"%{search}%"))
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
users = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all()
return {
"items": [{"id": u.id, "email": u.email, "full_name": u.full_name, "role": u.role.value,
"school_id": u.school_id, "is_active": u.is_active,
"created_at": u.created_at.isoformat()} for u in users],
"total": total,
}
@router.post("", status_code=201)
async def create_user(
body: UserCreate,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
existing = (await db.execute(select(HubUser).where(HubUser.email == body.email))).scalar_one_or_none()
if existing:
raise HTTPException(409, "Email already exists")
if len(body.password) < 8:
raise HTTPException(422, "Password must be at least 8 characters")
user = HubUser(email=body.email, full_name=body.full_name,
hashed_password=hash_password(body.password),
role=body.role, school_id=body.school_id)
db.add(user)
await db.commit()
return {"id": user.id, "email": user.email, "full_name": user.full_name, "role": user.role.value}
@router.put("/{user_id}")
async def update_user(
user_id: str,
body: UserUpdate,
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
user = (await db.execute(select(HubUser).where(HubUser.id == user_id))).scalar_one_or_none()
if not user:
raise HTTPException(404)
for field, value in body.model_dump(exclude_none=True).items():
setattr(user, field, value)
await db.commit()
return {"id": user.id, "email": user.email, "is_active": user.is_active}