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

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