Files
TapTrack-Hub/backend/app/routers/sms.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

110 lines
4.4 KiB
Python

"""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,
}