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