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

0
backend/app/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,41 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from jose import JWTError
from app.auth.jwt import decode_token
from app.database import get_db
from app.models.user import HubUser, UserRole
bearer = HTTPBearer(auto_error=False)
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
db: AsyncSession = Depends(get_db),
) -> HubUser:
if not credentials:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
try:
payload = decode_token(credentials.credentials)
user_id: str = payload.get("sub")
if not user_id:
raise HTTPException(status_code=401, detail="Invalid token")
except JWTError:
raise HTTPException(status_code=401, detail="Invalid token")
result = await db.execute(select(HubUser).where(HubUser.id == user_id))
user = result.scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(status_code=401, detail="User not found or inactive")
return user
async def require_super_admin(user: HubUser = Depends(get_current_user)) -> HubUser:
if user.role != UserRole.super_admin:
raise HTTPException(status_code=403, detail="Super admin access required")
return user
async def require_school_admin(user: HubUser = Depends(get_current_user)) -> HubUser:
if user.role not in (UserRole.super_admin, UserRole.school_admin):
raise HTTPException(status_code=403, detail="School admin access required")
return user

12
backend/app/auth/jwt.py Normal file
View File

@@ -0,0 +1,12 @@
from datetime import datetime, timezone, timedelta
from jose import jwt, JWTError
from app.config import settings
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def decode_token(token: str) -> dict:
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])

View File

@@ -0,0 +1,9 @@
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
return pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)

25
backend/app/config.py Normal file
View File

@@ -0,0 +1,25 @@
import os
class Settings:
SECRET_KEY: str = os.getenv("SECRET_KEY", "dev-secret-change-me")
ENVIRONMENT: str = os.getenv("ENVIRONMENT", "development")
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub")
REDIS_URL: str = os.getenv("REDIS_URL", "redis://redis:6379/0")
SEMAPHORE_API_KEY: str = os.getenv("SEMAPHORE_API_KEY", "")
SEMAPHORE_URL: str = "https://api.semaphore.co/api/v4/messages"
SMTP_HOST: str = os.getenv("SMTP_HOST", "")
SMTP_PORT: int = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER: str = os.getenv("SMTP_USER", "")
SMTP_PASSWORD: str = os.getenv("SMTP_PASSWORD", "")
SMTP_FROM: str = os.getenv("SMTP_FROM", "noreply@taptrack.io")
HUB_BASE_URL: str = os.getenv("HUB_BASE_URL", "http://localhost:8080")
# JWT
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 # 24 hours
ALGORITHM: str = "HS256"
# License
LICENSE_KEY_PREFIX: str = "TTUB"
DEFAULT_SMS_CREDITS: float = 0.0
settings = Settings()

20
backend/app/database.py Normal file
View File

@@ -0,0 +1,20 @@
import os
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub",
)
engine = create_async_engine(DATABASE_URL, echo=False, pool_pre_ping=True)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
async with AsyncSessionLocal() as session:
yield session

50
backend/app/main.py Normal file
View File

@@ -0,0 +1,50 @@
"""TapTrack Hub — FastAPI application entry point."""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.database import engine, Base
# Import all models so Alembic/SQLAlchemy picks them up
from app.models import user, school, license, sms, billing, ticket, audit, announcement # noqa
from app.routers import auth, schools, licenses, sms as sms_router, billing as billing_router
from app.routers import tickets, users, dashboard, school_portal, announcements, sync
@asynccontextmanager
async def lifespan(app: FastAPI):
# Create tables if not exists (dev convenience — use Alembic in prod)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
app = FastAPI(
title="TapTrack Hub",
description="Cloud control plane for TapTrack on-prem deployments",
version="1.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Routers
app.include_router(auth.router)
app.include_router(schools.router)
app.include_router(licenses.router)
app.include_router(sms_router.router)
app.include_router(billing_router.router)
app.include_router(tickets.router)
app.include_router(users.router)
app.include_router(dashboard.router)
app.include_router(school_portal.router)
app.include_router(announcements.router)
app.include_router(sync.router)
@app.get("/api/health")
async def health():
return {"status": "ok", "service": "taptrack-hub"}

View File

View File

@@ -0,0 +1,16 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Announcement(Base):
__tablename__ = "announcements"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
title: Mapped[str] = mapped_column(String(255), nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_by: Mapped[str | None] = mapped_column(String(36), ForeignKey("hub_users.id", ondelete="SET NULL"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View File

@@ -0,0 +1,21 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, DateTime, ForeignKey, Text, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
school_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("schools.id", ondelete="SET NULL"), nullable=True, index=True)
actor_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
actor_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
entity_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
entity_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
detail: Mapped[dict | None] = mapped_column(JSON, nullable=True)
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True)
school: Mapped["School | None"] = relationship("School", back_populates="audit_logs")

View File

@@ -0,0 +1,68 @@
import uuid
import enum
from datetime import datetime, timezone, date
from sqlalchemy import String, Boolean, DateTime, Date, Enum as SAEnum, ForeignKey, Text, Numeric, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class InvoiceStatus(str, enum.Enum):
draft = "draft"
sent = "sent"
paid = "paid"
overdue = "overdue"
cancelled = "cancelled"
class BillingCycle(str, enum.Enum):
monthly = "monthly"
annual = "annual"
class Invoice(Base):
__tablename__ = "invoices"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), nullable=False, index=True)
invoice_number: Mapped[str] = mapped_column(String(30), unique=True, nullable=False)
status: Mapped[InvoiceStatus] = mapped_column(SAEnum(InvoiceStatus), default=InvoiceStatus.draft, nullable=False)
billing_period_start: Mapped[date] = mapped_column(Date, nullable=False)
billing_period_end: Mapped[date] = mapped_column(Date, nullable=False)
subscription_amount: Mapped[float] = mapped_column(Numeric(12, 2), default=0.0, nullable=False)
sms_credit_amount: Mapped[float] = mapped_column(Numeric(12, 2), default=0.0, nullable=False)
other_amount: Mapped[float] = mapped_column(Numeric(12, 2), default=0.0, nullable=False)
total_amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
currency: Mapped[str] = mapped_column(String(3), default="PHP", nullable=False)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
payment_method: Mapped[str | None] = mapped_column(String(50), nullable=True)
payment_reference: Mapped[str | None] = mapped_column(String(100), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
pdf_path: Mapped[str | None] = mapped_column(String(255), nullable=True)
email_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
school: Mapped["School"] = relationship("School", back_populates="invoices")
line_items: Mapped[list["InvoiceLineItem"]] = relationship("InvoiceLineItem", back_populates="invoice", cascade="all, delete-orphan")
class InvoiceLineItem(Base):
__tablename__ = "invoice_line_items"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
invoice_id: Mapped[str] = mapped_column(String(36), ForeignKey("invoices.id", ondelete="CASCADE"), nullable=False)
description: Mapped[str] = mapped_column(String(255), nullable=False)
quantity: Mapped[float] = mapped_column(Numeric(10, 2), default=1.0, nullable=False)
unit_price: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
invoice: Mapped[Invoice] = relationship("Invoice", back_populates="line_items")
class SchoolSubscription(Base):
"""Stores billing plan per school."""
__tablename__ = "school_subscriptions"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), unique=True, nullable=False)
cycle: Mapped[BillingCycle] = mapped_column(SAEnum(BillingCycle), default=BillingCycle.monthly, nullable=False)
monthly_fee: Mapped[float] = mapped_column(Numeric(12, 2), default=0.0, nullable=False)
sms_cost_per_message: Mapped[float] = mapped_column(Numeric(8, 4), default=1.0, nullable=False)
next_billing_date: Mapped[date | None] = mapped_column(Date, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))

View File

@@ -0,0 +1,40 @@
import uuid
import enum
import secrets
import string
from datetime import datetime, timezone, date
from sqlalchemy import String, Boolean, DateTime, Date, Enum as SAEnum, ForeignKey, Text, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class LicenseStatus(str, enum.Enum):
active = "active"
expired = "expired"
revoked = "revoked"
trial = "trial"
def generate_license_key() -> str:
alphabet = string.ascii_uppercase + string.digits
segments = ["TTUB"] + [
"".join(secrets.choice(alphabet) for _ in range(5))
for _ in range(3)
]
return "-".join(segments)
class License(Base):
__tablename__ = "licenses"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), unique=True, nullable=False)
key: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, default=generate_license_key, index=True)
status: Mapped[LicenseStatus] = mapped_column(SAEnum(LicenseStatus), default=LicenseStatus.trial, nullable=False)
tier: Mapped[str] = mapped_column(String(20), default="standard", nullable=False)
issued_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
expires_at: Mapped[date | None] = mapped_column(Date, nullable=True)
last_validated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_seen_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
hardware_fingerprint: Mapped[str | None] = mapped_column(String(255), nullable=True)
max_students: Mapped[int] = mapped_column(Integer, default=500, nullable=False)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
school: Mapped["School"] = relationship("School", back_populates="license")

View File

@@ -0,0 +1,45 @@
import uuid
import enum
from datetime import datetime, timezone
from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum, Text, Numeric, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class SchoolStatus(str, enum.Enum):
active = "active"
suspended = "suspended"
expired = "expired"
pending = "pending"
class LicenseTier(str, enum.Enum):
basic = "basic"
standard = "standard"
premium = "premium"
class School(Base):
__tablename__ = "schools"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
address: Mapped[str | None] = mapped_column(Text, nullable=True)
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
contact_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
contact_phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
billing_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[SchoolStatus] = mapped_column(SAEnum(SchoolStatus), default=SchoolStatus.pending, nullable=False)
tier: Mapped[LicenseTier] = mapped_column(SAEnum(LicenseTier), default=LicenseTier.standard, nullable=False)
student_limit: Mapped[int] = mapped_column(Integer, default=500, nullable=False)
sms_sender_name: Mapped[str] = mapped_column(String(11), default="SCHOOL", nullable=False)
sms_credits: Mapped[float] = mapped_column(Numeric(12, 2), default=0.0, nullable=False)
sms_credit_low_threshold: Mapped[int] = mapped_column(Integer, default=50, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
admins: Mapped[list["HubUser"]] = relationship("HubUser", back_populates="school")
license: Mapped["License | None"] = relationship("License", back_populates="school", uselist=False)
sms_jobs: Mapped[list["SmsJob"]] = relationship("SmsJob", back_populates="school")
invoices: Mapped[list["Invoice"]] = relationship("Invoice", back_populates="school")
tickets: Mapped[list["SupportTicket"]] = relationship("SupportTicket", back_populates="school")
audit_logs: Mapped[list["AuditLog"]] = relationship("AuditLog", back_populates="school")

50
backend/app/models/sms.py Normal file
View File

@@ -0,0 +1,50 @@
import uuid
import enum
from datetime import datetime, timezone
from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum, ForeignKey, Text, Numeric, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class SmsJobStatus(str, enum.Enum):
pending = "pending"
processing = "processing"
sent = "sent"
failed = "failed"
cancelled = "cancelled"
class SmsCreditTx(str, enum.Enum):
topup = "topup"
deduct = "deduct"
refund = "refund"
adjustment = "adjustment"
class SmsJob(Base):
__tablename__ = "sms_jobs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), nullable=False, index=True)
recipient_phone: Mapped[str] = mapped_column(String(20), nullable=False)
message: Mapped[str] = mapped_column(Text, nullable=False)
sender_name: Mapped[str] = mapped_column(String(11), nullable=False)
status: Mapped[SmsJobStatus] = mapped_column(SAEnum(SmsJobStatus), default=SmsJobStatus.pending, nullable=False, index=True)
trigger: Mapped[str | None] = mapped_column(String(50), nullable=True) # "absent", "late", "manual"
semaphore_message_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
retry_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
school: Mapped["School"] = relationship("School", back_populates="sms_jobs")
class SmsCreditLedger(Base):
__tablename__ = "sms_credit_ledger"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), nullable=False, index=True)
tx_type: Mapped[SmsCreditTx] = mapped_column(SAEnum(SmsCreditTx), nullable=False)
amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
balance_after: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
reference_id: Mapped[str | None] = mapped_column(String(36), nullable=True) # invoice_id or sms_job_id
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
created_by: Mapped[str | None] = mapped_column(String(36), nullable=True) # hub_user_id

View File

@@ -0,0 +1,58 @@
import uuid
import enum
from datetime import datetime, timezone
from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum, ForeignKey, Text, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class TicketStatus(str, enum.Enum):
open = "open"
in_progress = "in_progress"
resolved = "resolved"
closed = "closed"
class TicketPriority(str, enum.Enum):
low = "low"
medium = "medium"
high = "high"
urgent = "urgent"
class TicketCategory(str, enum.Enum):
billing = "billing"
technical = "technical"
sms = "sms"
license = "license"
general = "general"
class SupportTicket(Base):
__tablename__ = "support_tickets"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), nullable=False, index=True)
submitted_by: Mapped[str] = mapped_column(String(36), ForeignKey("hub_users.id", ondelete="SET NULL"), nullable=True)
ticket_number: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
subject: Mapped[str] = mapped_column(String(255), nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
category: Mapped[TicketCategory] = mapped_column(SAEnum(TicketCategory), default=TicketCategory.general, nullable=False)
status: Mapped[TicketStatus] = mapped_column(SAEnum(TicketStatus), default=TicketStatus.open, nullable=False)
priority: Mapped[TicketPriority] = mapped_column(SAEnum(TicketPriority), default=TicketPriority.medium, nullable=False)
assigned_to: Mapped[str | None] = mapped_column(String(36), ForeignKey("hub_users.id", ondelete="SET NULL"), nullable=True)
first_response_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
school: Mapped["School"] = relationship("School", back_populates="tickets")
replies: Mapped[list["TicketReply"]] = relationship("TicketReply", back_populates="ticket", cascade="all, delete-orphan")
class TicketReply(Base):
__tablename__ = "ticket_replies"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
ticket_id: Mapped[str] = mapped_column(String(36), ForeignKey("support_tickets.id", ondelete="CASCADE"), nullable=False)
author_id: Mapped[str] = mapped_column(String(36), ForeignKey("hub_users.id", ondelete="SET NULL"), nullable=True)
body: Mapped[str] = mapped_column(Text, nullable=False)
is_internal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
ticket: Mapped[SupportTicket] = relationship("SupportTicket", back_populates="replies")

View File

@@ -0,0 +1,25 @@
import uuid
import enum
from datetime import datetime, timezone
from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum, ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class UserRole(str, enum.Enum):
super_admin = "super_admin"
school_admin = "school_admin"
class HubUser(Base):
__tablename__ = "hub_users"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[UserRole] = mapped_column(SAEnum(UserRole), nullable=False, default=UserRole.school_admin)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
school_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("schools.id", ondelete="SET NULL"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
school: Mapped["School | None"] = relationship("School", back_populates="admins")

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}

View File

View File

@@ -0,0 +1,32 @@
"""Simple SMTP email service."""
import smtplib
import logging
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from app.config import settings
logger = logging.getLogger(__name__)
def send_email(to: str, subject: str, body: str, html: str | None = None) -> bool:
"""Send email via configured SMTP. Returns True on success."""
if not settings.SMTP_HOST:
logger.warning(f"SMTP not configured — would send to {to}: {subject}")
return False
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = settings.SMTP_FROM
msg["To"] = to
msg.attach(MIMEText(body, "plain"))
if html:
msg.attach(MIMEText(html, "html"))
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as smtp:
smtp.starttls()
if settings.SMTP_USER:
smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
smtp.sendmail(settings.SMTP_FROM, [to], msg.as_string())
logger.info(f"Email sent to {to}: {subject}")
return True
except Exception as e:
logger.error(f"Email failed to {to}: {e}")
return False

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()

55
backend/app/worker.py Normal file
View File

@@ -0,0 +1,55 @@
"""Celery worker + beat schedule for TapTrack Hub."""
import os
from celery import Celery
from celery.schedules import crontab
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
celery_app = Celery(
"taptrack_hub",
broker=REDIS_URL,
backend=REDIS_URL,
include=[
"app.tasks.sms",
"app.tasks.billing",
"app.tasks.reports",
"app.tasks.license",
],
)
celery_app.conf.update(
task_serializer="json",
accept_content=["json"],
result_serializer="json",
timezone="Asia/Manila",
enable_utc=True,
beat_schedule={
# Process pending SMS jobs every 30 seconds
"process-sms-queue": {
"task": "sms.process_queue",
"schedule": 30.0,
},
# Check license expiry every day at 8am
"check-license-expiry": {
"task": "license.check_expiry",
"schedule": crontab(hour=8, minute=0),
},
# Generate monthly invoices on the 1st at 6am
"generate-monthly-invoices": {
"task": "billing.generate_monthly_invoices",
"schedule": crontab(day_of_month=1, hour=6, minute=0),
},
# Send monthly reports on the 1st at 7am
"send-monthly-reports": {
"task": "reports.send_monthly_reports",
"schedule": crontab(day_of_month=1, hour=7, minute=0),
},
# Check for overdue invoices daily at 9am
"check-overdue-invoices": {
"task": "billing.check_overdue",
"schedule": crontab(hour=9, minute=0),
},
},
)
app = celery_app