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,125 @@
"""Celery tasks: invoice generation, email, overdue checks."""
import logging
from datetime import date, timedelta
from app.worker import celery_app
logger = logging.getLogger(__name__)
def _make_session():
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub")
engine = create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True)
return sessionmaker(bind=engine)()
@celery_app.task(name="billing.generate_monthly_invoices")
def generate_monthly_invoices():
"""On the 1st: create draft invoices for all active schools with a subscription."""
from app.models.billing import Invoice, SchoolSubscription, InvoiceLineItem, BillingCycle
from app.models.school import School, SchoolStatus
from sqlalchemy import select
from datetime import date
db = _make_session()
try:
today = date.today()
period_start = date(today.year, today.month, 1)
prev_month = (period_start - timedelta(days=1))
billing_start = date(prev_month.year, prev_month.month, 1)
billing_end = period_start - timedelta(days=1)
subs = db.execute(select(SchoolSubscription).where(SchoolSubscription.is_active == True)).scalars().all()
count = db.execute(select(func.count()).select_from(Invoice)).scalar_one()
for sub in subs:
school = db.get(School, sub.school_id)
if not school or school.status != SchoolStatus.active:
continue
total = float(sub.monthly_fee)
inv_num = f"INV-{billing_start.strftime('%Y%m')}-{count + 1:04d}"
count += 1
inv = Invoice(
school_id=sub.school_id,
invoice_number=inv_num,
billing_period_start=billing_start,
billing_period_end=billing_end,
subscription_amount=float(sub.monthly_fee),
total_amount=total,
due_date=period_start + timedelta(days=14),
)
db.add(inv)
db.flush()
db.add(InvoiceLineItem(
invoice_id=inv.id,
description=f"Monthly subscription — {school.name}",
quantity=1,
unit_price=float(sub.monthly_fee),
amount=float(sub.monthly_fee),
))
db.commit()
logger.info(f"Generated {len(subs)} invoices for {billing_start}")
except Exception as e:
db.rollback()
logger.error(f"generate_monthly_invoices error: {e}")
finally:
db.close()
@celery_app.task(name="billing.send_invoice_email")
def send_invoice_email_task(invoice_id: str):
"""Send invoice email to school billing contact."""
from app.models.billing import Invoice, InvoiceStatus
from app.models.school import School
from app.services.email import send_email
from sqlalchemy import select
db = _make_session()
try:
inv = db.get(Invoice, invoice_id)
if not inv:
return
school = db.get(School, inv.school_id)
if not school or not school.billing_email:
return
body = f"""Dear {school.contact_name or school.name},
Please find your invoice {inv.invoice_number} for the period {inv.billing_period_start} to {inv.billing_period_end}.
Amount Due: PHP {float(inv.total_amount):,.2f}
Due Date: {inv.due_date}
Please log in to your TapTrack Hub portal to view and pay your invoice.
Thank you,
TapTrack Hub Team
"""
send_email(to=school.billing_email, subject=f"Invoice {inv.invoice_number} — TapTrack Hub", body=body)
from datetime import datetime, timezone
inv.email_sent_at = datetime.now(timezone.utc)
if inv.status.value == "draft":
inv.status = InvoiceStatus.sent
db.commit()
finally:
db.close()
@celery_app.task(name="billing.check_overdue")
def check_overdue():
"""Mark overdue invoices and send warning emails."""
from app.models.billing import Invoice, InvoiceStatus
from sqlalchemy import select, and_
db = _make_session()
try:
today = date.today()
overdue = db.execute(
select(Invoice).where(
and_(Invoice.status == InvoiceStatus.sent, Invoice.due_date < today, Invoice.due_date != None)
)
).scalars().all()
for inv in overdue:
inv.status = InvoiceStatus.overdue
db.commit()
logger.info(f"Marked {len(overdue)} invoices as overdue")
finally:
db.close()

View File

@@ -0,0 +1,41 @@
"""Celery task: license expiry checks and alerts."""
import logging
from datetime import date, timedelta
from app.worker import celery_app
logger = logging.getLogger(__name__)
@celery_app.task(name="license.check_expiry")
def check_expiry():
"""Send expiry warning emails for licenses expiring in 30, 14, or 7 days."""
import os
from sqlalchemy import create_engine, select, and_
from sqlalchemy.orm import sessionmaker
from app.models.license import License, LicenseStatus
from app.models.school import School
from app.services.email import send_email
db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub")
engine = create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True)
db = sessionmaker(bind=engine)()
try:
today = date.today()
for days_ahead in [30, 14, 7]:
target = today + timedelta(days=days_ahead)
expiring = db.execute(
select(License).where(
and_(License.expires_at == target, License.status == LicenseStatus.active)
)
).scalars().all()
for lic in expiring:
school = db.get(School, lic.school_id)
if school and school.billing_email:
send_email(
to=school.billing_email,
subject=f"[TapTrack Hub] License expires in {days_ahead} days — {school.name}",
body=f"Your TapTrack license for {school.name} expires on {lic.expires_at}. Please contact us to renew.",
)
db.commit()
finally:
db.close()

View File

@@ -0,0 +1,35 @@
"""Celery task: send monthly reports to schools."""
import logging
from app.worker import celery_app
logger = logging.getLogger(__name__)
@celery_app.task(name="reports.send_monthly_reports")
def send_monthly_reports():
"""Send monthly attendance and SMS report email to each active school."""
import os
from datetime import date, timedelta
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.models.school import School, SchoolStatus
from app.services.email import send_email
db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub")
engine = create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True)
db = sessionmaker(bind=engine)()
try:
today = date.today()
prev_month_end = date(today.year, today.month, 1) - timedelta(days=1)
prev_month_start = date(prev_month_end.year, prev_month_end.month, 1)
schools = db.execute(select(School).where(School.status == SchoolStatus.active)).scalars().all()
for school in schools:
if not school.billing_email:
continue
send_email(
to=school.billing_email,
subject=f"Monthly Report — {school.name}{prev_month_start.strftime('%B %Y')}",
body=f"Dear {school.contact_name or school.name},\n\nPlease find your monthly summary for {prev_month_start.strftime('%B %Y')} in your TapTrack Hub portal.\n\nSMS Credits Remaining: {float(school.sms_credits):.0f}\n\nLog in to view full details.\n\nThank you,\nTapTrack Hub Team",
)
logger.info(f"Sent monthly reports to {len(schools)} schools")
finally:
db.close()

102
backend/app/tasks/sms.py Normal file
View File

@@ -0,0 +1,102 @@
"""Celery task: process pending SMS jobs via Semaphore."""
import logging
import os
from datetime import datetime, timezone
import httpx
from sqlalchemy import create_engine, select, update, and_
from sqlalchemy.orm import sessionmaker
from app.worker import celery_app
from app.config import settings
logger = logging.getLogger(__name__)
def _make_sync_engine():
db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub")
return create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True, pool_size=2)
_engine = _make_sync_engine()
_Session = sessionmaker(bind=_engine)
@celery_app.task(name="sms.process_queue")
def process_sms_queue():
"""Process up to 20 pending SMS jobs per run via Semaphore API."""
from app.models.sms import SmsJob, SmsJobStatus
from app.models.school import School
db = _Session()
try:
jobs = db.execute(
select(SmsJob).where(SmsJob.status == SmsJobStatus.pending).limit(20)
).scalars().all()
for job in jobs:
school = db.get(School, job.school_id)
if not school or float(school.sms_credits) <= 0:
job.status = SmsJobStatus.cancelled
job.error_message = "Insufficient credits"
db.commit()
continue
result = _send_semaphore(job.recipient_phone, job.message, job.sender_name)
if result["success"]:
job.status = SmsJobStatus.sent
job.sent_at = datetime.now(timezone.utc)
job.semaphore_message_id = result.get("message_id")
# Deduct credit
from app.models.sms import SmsCreditLedger, SmsCreditTx
school.sms_credits = float(school.sms_credits) - 1.0
db.add(SmsCreditLedger(
school_id=school.id,
tx_type=SmsCreditTx.deduct,
amount=-1.0,
balance_after=float(school.sms_credits),
description=f"SMS sent to {job.recipient_phone}",
reference_id=job.id,
))
# Low credit alert
if float(school.sms_credits) <= school.sms_credit_low_threshold:
send_low_credit_alert.delay(school.id)
else:
job.retry_count += 1
if job.retry_count >= 5:
job.status = SmsJobStatus.failed
job.error_message = result.get("error")
db.commit()
finally:
db.close()
def _send_semaphore(phone: str, message: str, sender: str) -> dict:
try:
with httpx.Client(timeout=15) as client:
resp = client.post(settings.SEMAPHORE_URL, data={
"apikey": settings.SEMAPHORE_API_KEY,
"number": phone,
"message": message,
"sendername": sender,
})
if resp.status_code == 200:
data = resp.json()
msg_id = str(data[0].get("message_id", "")) if isinstance(data, list) and data else None
return {"success": True, "message_id": msg_id}
return {"success": False, "error": f"HTTP {resp.status_code}"}
except Exception as e:
return {"success": False, "error": str(e)}
@celery_app.task(name="sms.send_low_credit_alert")
def send_low_credit_alert(school_id: str):
"""Send low credit warning email to school billing contact."""
from app.services.email import send_email
from app.models.school import School
db = _Session()
try:
school = db.get(School, school_id)
if school and school.billing_email:
send_email(
to=school.billing_email,
subject=f"[TapTrack Hub] Low SMS Credits — {school.name}",
body=f"Your SMS credit balance for {school.name} is low ({float(school.sms_credits):.0f} remaining). Please top up to continue sending SMS notifications.",
)
finally:
db.close()