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