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

11
.env.example Normal file
View File

@@ -0,0 +1,11 @@
DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub
REDIS_URL=redis://redis:6379/0
SECRET_KEY=changeme-use-openssl-rand-hex-32
ENVIRONMENT=development
SEMAPHORE_API_KEY=your-semaphore-api-key
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-app-password
SMTP_FROM=noreply@taptrack.io
HUB_BASE_URL=https://hub.taptrack.io

18
.gitignore vendored Normal file
View File

@@ -0,0 +1,18 @@
__pycache__/
*.pyc
*.pyo
*.egg-info/
.venv/
venv/
dist/
build/
*.egg
.env
.env.*
!.env.example
node_modules/
frontend/dist/
*.log
data/
*.db
.DS_Store

339
.paul/ROADMAP.md Normal file
View File

@@ -0,0 +1,339 @@
# Roadmap: TapTrack Hub
## Overview
TapTrack Hub is a cloud-hosted SaaS control plane for managing all on-prem TapTrack school deployments.
It handles school licensing, SMS gateway proxying (Semaphore PH), billing/invoicing, support tickets,
monthly automated reports, and provides a client-facing school portal.
Two user roles:
- **Super Admin** — you (the operator): full control over all schools, licenses, SMS, billing
- **School Admin** — each client school's administrator: portal access for billing, SMS, tickets
Architecture: FastAPI + PostgreSQL + Redis + Celery + Vue 3 + Nginx, deployed as Docker Compose on a cloud VPS.
SMS flow: On-prem TapTrack polls Hub every 30s → Hub queues SMS jobs → Hub sends via Semaphore → reports back status.
---
## Current Milestone
**v1.0 — Foundation & Core Services**
Status: Phase 1 complete — Phase 2 next
Phases: 1 of 15 complete
---
## Phase Status
| Phase | Name | Plans | Status | Completed |
|-------|------------------------------|-------|--------------|------------|
| 1 | Project Setup & Infrastructure | 1 | ✅ Complete | 2026-03-15 |
| 2 | School Registry + License Mgmt | TBD | Not started | — |
| 3 | On-Prem License Validation | TBD | Not started | — |
| 4 | SMS Gateway (credits + queue) | TBD | Not started | — |
| 5 | On-Prem SMS Polling Agent | TBD | Not started | — |
| 6 | Super Admin Dashboard UI | TBD | Not started | — |
| 7 | School Admin Portal UI | TBD | Not started | — |
| 8 | Billing Engine + Invoice PDF | TBD | Not started | — |
| 9 | Email Dispatcher | TBD | Not started | — |
| 10 | Support Ticket System | TBD | Not started | — |
| 11 | Monthly Report Generation | TBD | Not started | — |
| 12 | On-Prem Monthly Report Pull | TBD | Not started | — |
| 13 | Feature Flags + Suspension | TBD | Not started | — |
| 14 | Onboarding Wizard + Welcome Email | TBD | Not started | — |
| 15 | UX Polish + Ops Tools | TBD | Not started | — |
---
## Phase Details
### Phase 1: Project Setup & Infrastructure ✅
**Goal:** Runnable Docker Compose stack with FastAPI, PostgreSQL, Redis, Celery, and Vue 3 frontend skeleton.
**Completed:** 2026-03-15
What was built:
- Docker Compose: backend (gunicorn+uvicorn), celery worker, celery beat, frontend (vite build + nginx), PostgreSQL, Redis, nginx reverse proxy
- Full database schema: 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 dependencies (super_admin, school_admin)
- All FastAPI routers: auth, schools, licenses, sms, billing, tickets, users, dashboard, school_portal, announcements, sync
- All Celery tasks: sms.process_queue, billing.generate_monthly_invoices, billing.send_invoice_email, billing.check_overdue, license.check_expiry, reports.send_monthly_reports
- Email service (SMTP), seed script (super admin)
- Vue 3 frontend: router (super admin + portal routes), Pinia auth store, full api.ts client, Tailwind CSS, all page skeletons (Login, Dashboard, Schools, SchoolDetail, Licenses, SMS, Billing, Tickets, TicketDetail, Users, Announcements, NotFoundPage + all 5 portal pages)
- Shared components: AppSidebar, PortalSidebar, SidebarItem, KpiCard, StatusBadge, ToastStack
---
### Phase 2: School Registry + License Management
**Goal:** Super admin can register schools, issue license keys, set billing plans, add SMS credits, activate/suspend.
**Depends on:** Phase 1
**Scope:**
- School create/edit form (modal) in SchoolsPage
- License detail panel in SchoolDetailPage: change expiry, tier, revoke
- Subscription setup form per school (monthly fee, SMS cost per message)
- Add SMS credits form with ledger view
- Send welcome email on school creation (onboarding stub)
- School status lifecycle: pending → active → suspended/expired
**Plans:**
- [ ] 2-01: School create/edit modal + license expiry editor in SchoolDetailPage
- [ ] 2-02: Subscription setup + SMS credit top-up form + ledger table
---
### Phase 3: On-Prem License Validation
**Goal:** TapTrack on-prem validates its license key against Hub on startup and periodically; Hub returns feature flags and school config.
**Depends on:** Phase 2
**Scope:**
- `POST /api/licenses/validate` endpoint (already built) — verify, update last_seen, return features
- On-prem TapTrack changes:
- Celery beat task every 6h to call Hub license validate
- If expired/revoked: SMS disabled, warning banner in UI
- Store validated features in Redis (5min TTL) for fast feature flag checks
- Hardware fingerprint binding (optional — store server MAC on first validation)
**Plans:**
- [ ] 3-01: On-prem license validation task + feature flag Redis cache (TapTrack side)
- [ ] 3-02: UI warning banner on TapTrack when license invalid/expired
---
### Phase 4: SMS Gateway (Credits + Job Queue)
**Goal:** Schools submit SMS jobs via Hub; Hub processes them through Semaphore using school's sender name and deducts credits.
**Depends on:** Phase 2
**Scope:**
- `POST /api/sms/submit` (already built) — on-prem posts jobs
- Celery `sms.process_queue` (already built) — processes via Semaphore, deducts credits, logs ledger
- SMS job retry logic (up to 5 retries, exponential backoff)
- Low credit email alert (already built)
- Super admin SMS dashboard: jobs by status, school breakdown, Semaphore API health check
- Credit ledger view per school in SchoolDetailPage
**Plans:**
- [ ] 4-01: SMS dashboard stats + per-school credit ledger UI in SchoolDetailPage
- [ ] 4-02: SMS retry logic hardening + Semaphore health check endpoint
---
### Phase 5: On-Prem SMS Polling Agent
**Goal:** TapTrack on-prem polls Hub every 30s for pending SMS jobs; sends them via Hub's Semaphore account; reports back completion.
**Depends on:** Phase 4
**Scope:**
- Replace TapTrack's direct Semaphore calls with Hub polling
- On-prem Celery task: `POST /api/sync/poll` with license key + completed job IDs
- Hub returns jobs to send, school config (sender name, credits, status)
- On-prem sends via Hub-provided sender name (not its own settings)
- If school status = suspended: skip SMS, show banner
- Graceful degradation: if Hub unreachable, queue locally and retry
**Plans:**
- [ ] 5-01: On-prem polling task replacing direct Semaphore calls (TapTrack side)
- [ ] 5-02: Graceful degradation + local queue fallback (TapTrack side)
---
### Phase 6: Super Admin Dashboard UI
**Goal:** Rich super admin dashboard with KPI overview, school health list, SMS volume chart, revenue snapshot.
**Depends on:** Phase 4, Phase 2
**Scope:**
- KPI row: total/active/suspended schools, licenses expiring, open tickets, SMS today, pending invoices
- School health table: name, status, credits, last seen, license expiry (sortable)
- SMS volume chart (last 30 days) — bar chart by day
- Revenue snapshot: MRR, outstanding, overdue total
- Quick action buttons: add school, trigger SMS queue, check overdue invoices
**Plans:**
- [ ] 6-01: Dashboard KPI row + school health table
- [ ] 6-02: SMS volume chart + revenue snapshot panel
---
### Phase 7: School Admin Portal UI
**Goal:** Complete school admin portal — overview, billing history, SMS reports with charts, support tickets, profile.
**Depends on:** Phase 5, Phase 8
**Scope:**
- Portal overview: license status card, credit meter with low-credit warning, monthly SMS usage chart
- Billing tab: full invoice table, PDF download button per invoice, credit top-up request form
- SMS tab: monthly SMS log, delivery rate chart, credit burn rate chart
- Tickets tab: create ticket + reply thread view (already built as stubs)
- Profile tab: change password, view school details
**Plans:**
- [ ] 7-01: Portal overview enhancements (credit meter, charts, license countdown)
- [ ] 7-02: Billing PDF download + credit top-up request form
- [ ] 7-03: SMS usage charts + delivery rate visualization
---
### Phase 8: Billing Engine + Invoice PDF
**Goal:** Automated monthly invoice generation per school; PDF export; mark as paid workflow; overdue escalation.
**Depends on:** Phase 2
**Scope:**
- `billing.generate_monthly_invoices` Celery task (already built) — creates draft invoices
- Invoice PDF generation using Jinja2 HTML template + WeasyPrint
- Invoice PDF stored at `/app/data/invoices/{id}.pdf`
- `GET /api/billing/invoices/{id}/pdf` — serve PDF
- Mark as paid workflow (super admin sets paid_at, payment_method, payment_reference)
- Overdue escalation: 7 days → warning email, 30 days → suspend school
- Invoice number sequencing: INV-YYYYMM-NNNN
**Plans:**
- [ ] 8-01: Invoice PDF generation (Jinja2 template + WeasyPrint) + serve endpoint
- [ ] 8-02: Mark-paid workflow + overdue escalation (suspend at 30 days)
---
### Phase 9: Email Dispatcher
**Goal:** All automated emails (invoices, low credits, license expiry, monthly reports) send correctly via SMTP with proper HTML templates.
**Depends on:** Phase 8
**Scope:**
- Jinja2 HTML email templates: invoice, low credit alert, license expiry warning, monthly report, welcome email
- Email queue via Celery (retry on failure)
- Email delivery log table (email_logs): to, subject, type, status, sent_at, error
- `GET /api/email-logs` — super admin can view all email delivery history
- Test email endpoint: `POST /api/email/test` — send a test email to verify SMTP config
**Plans:**
- [ ] 9-01: Jinja2 HTML email templates + email_logs table + delivery logging
- [ ] 9-02: Email delivery log UI in super admin + test email endpoint
---
### Phase 10: Support Ticket System
**Goal:** Full-featured support ticket system with SLA tracking, internal notes, priority management, and email notifications.
**Depends on:** Phase 9 (emails)
**Scope:**
- Ticket create/reply already built — polish and harden
- Email notification on new ticket (to super admin) and on reply (to school admin)
- Internal notes (super admin only, already supported via is_internal flag)
- SLA tracking: first_response_at already stored — display SLA status in ticket list
- Priority escalation: tickets open > 48h auto-escalate to high
- Ticket assignment to specific super admin users
- Bulk actions: close all resolved, assign multiple
**Plans:**
- [ ] 10-01: Email notifications on ticket create/reply + SLA display in ticket list
- [ ] 10-02: Ticket assignment + priority auto-escalation + bulk close
---
### Phase 11: Monthly Report Generation + Email
**Goal:** On the 1st of each month, automatically generate and email a comprehensive report to each active school.
**Depends on:** Phase 9, Phase 12
**Scope:**
- `reports.send_monthly_reports` Celery task (already built as stub) — enhance with real data
- Report content: attendance summary, SMS usage, credit consumption, invoice for period
- HTML email template for monthly report (branded, professional)
- Super admin can manually trigger report for any school: `POST /api/reports/send/{school_id}`
- School portal: view past monthly reports (list + detail)
**Plans:**
- [ ] 11-01: Monthly report data aggregation + HTML email template
- [ ] 11-02: Manual trigger endpoint + report history in school portal
---
### Phase 12: On-Prem Monthly Report Pull
**Goal:** Hub pulls attendance summary data from each on-prem TapTrack instance to populate monthly reports.
**Depends on:** Phase 11
**Scope:**
- New endpoint on TapTrack on-prem: `GET /api/hub/monthly-report?key={license_key}&month={YYYY-MM}`
- Returns: total_students, present_days, absent_days, late_days, avg_attendance_rate, sms_sent
- Hub Celery task: on the 1st at 5am, poll each active school's on-prem for monthly data
- Store result in `school_monthly_stats` table for report generation
- Fallback: if on-prem unreachable, report shows "data unavailable" for attendance section
**Plans:**
- [ ] 12-01: TapTrack on-prem monthly report endpoint + school_monthly_stats table in Hub
- [ ] 12-02: Hub Celery pull task + fallback handling
---
### Phase 13: Feature Flags + Service Suspension Logic
**Goal:** Hub controls which features each school's on-prem instance can use, based on tier and payment status.
**Depends on:** Phase 3, Phase 8
**Scope:**
- Feature flags per tier (already in `_tier_features()`) — expose via `/api/licenses/validate`
- Suspension flow:
1. Invoice overdue 30 days → school.status = suspended
2. On-prem polls Hub → gets status=suspended → disables SMS, shows banner
3. On-prem attendance still works (not blocked) — only SMS and reports disabled
- Feature flag UI: per-school overrides in SchoolDetailPage (super admin can enable beta features)
- `feature_overrides` JSON column on School model for per-school feature customization
**Plans:**
- [ ] 13-01: feature_overrides column + per-school feature flag UI in SchoolDetailPage
- [ ] 13-02: Suspension propagation to on-prem via sync poll + on-prem banner
---
### Phase 14: Onboarding Wizard + Welcome Email
**Goal:** When a new school is registered, automatically send a welcome email with license key and setup instructions; guide super admin through initial school setup.
**Depends on:** Phase 9
**Scope:**
- Welcome email template: school name, license key, setup instructions link, Hub portal login URL
- Triggered automatically on school creation (after license is issued)
- Super admin onboarding checklist in SchoolDetailPage:
- [ ] School created
- [ ] License issued
- [ ] Billing plan set
- [ ] SMS credits added
- [ ] Welcome email sent
- [ ] School admin account created
- "Resend welcome email" button
- "Complete onboarding" button sets school.status = active
**Plans:**
- [ ] 14-01: Welcome email template + auto-send on school creation
- [ ] 14-02: Onboarding checklist UI in SchoolDetailPage + "activate school" workflow
---
### Phase 15: UX Polish + Super Admin Ops Tools
**Goal:** Production-ready polish: loading states, empty states, error boundaries, keyboard shortcuts, mobile responsiveness, audit log viewer, bulk operations.
**Depends on:** All previous phases
**Scope:**
- Audit log viewer: `GET /api/audit-logs` — searchable table of all system events
- Bulk school actions: export CSV, bulk status change
- Global search: search schools, tickets, invoices by name/number
- Keyboard shortcuts: `/` focus search, `N` new school, `Esc` close modals
- Mobile-responsive layouts for portal (school admins may use phone)
- Error boundary component for API failures
- Dashboard refresh button with last-updated timestamp
- Empty state illustrations for zero-data pages
- Production hardening: rate limiting on auth endpoints, HTTPS redirect
**Plans:**
- [ ] 15-01: Audit log table + global search + bulk export
- [ ] 15-02: Mobile-responsive portal + error boundary + production hardening
---

64
.paul/STATE.md Normal file
View File

@@ -0,0 +1,64 @@
# TapTrack Hub — PAUL State
## Current Position
Milestone: v1.0 — Foundation & Core Services
Phase: 1 of 15 (Project Setup & Infrastructure — complete)
Plan: Phase 1 complete — no active plan
Status: **Phase 1 applied — ready to begin Phase 2**
Last activity: 2026-03-15 — Phase 1 complete (full stack scaffold: Docker Compose, all backend models/routers/tasks, Vue 3 frontend skeleton with all page stubs)
## Loop Position
```
PLAN ──▶ APPLY ──▶ UNIFY
· · · [No active plan — Phase 2 planning next]
```
## Progress
### v1.0 Foundation
- Phase 1 (Project Setup & Infrastructure): [██████████] 100% ✓
- Phase 2 (School Registry + License Mgmt): [░░░░░░░░░░] 0%
- Phase 3 (On-Prem License Validation): [░░░░░░░░░░] 0%
- Phase 4 (SMS Gateway): [░░░░░░░░░░] 0%
- Phase 5 (On-Prem SMS Polling Agent): [░░░░░░░░░░] 0%
- Phase 6 (Super Admin Dashboard UI): [░░░░░░░░░░] 0%
- Phase 7 (School Admin Portal UI): [░░░░░░░░░░] 0%
- Phase 8 (Billing Engine + Invoice PDF): [░░░░░░░░░░] 0%
- Phase 9 (Email Dispatcher): [░░░░░░░░░░] 0%
- Phase 10 (Support Ticket System): [░░░░░░░░░░] 0%
- Phase 11 (Monthly Report Generation): [░░░░░░░░░░] 0%
- Phase 12 (On-Prem Monthly Report Pull): [░░░░░░░░░░] 0%
- Phase 13 (Feature Flags + Suspension): [░░░░░░░░░░] 0%
- Phase 14 (Onboarding Wizard): [░░░░░░░░░░] 0%
- Phase 15 (UX Polish + Ops Tools): [░░░░░░░░░░] 0%
## Next Action
Run: `/paul:plan` for Phase 2 — School Registry + License Management
Resume file: .paul/ROADMAP.md → Phase 2
## Repo
Remote: TBD (new Gitea repo)
Branch: master
Last commit: initial scaffold
## Tech Stack
- Backend: FastAPI 0.115 + SQLAlchemy 2 async + PostgreSQL 15 + Redis 7
- Worker: Celery 5.4 + celery-beat
- Email: SMTP via smtplib + Jinja2 HTML templates
- SMS: Semaphore PH API (single account, proxied for all schools)
- Frontend: Vue 3.5 + Vite + Pinia + Vue Router + Tailwind CSS 3
- Infra: Docker Compose + Nginx reverse proxy
## Architecture Notes
- Two user roles: super_admin (operator) + school_admin (client)
- Two frontend layouts: AppLayout (super admin nav) + PortalLayout (school portal nav)
- SMS flow: on-prem polls /api/sync/poll every 30s → Hub queues/sends via Semaphore
- License flow: on-prem calls /api/licenses/validate on startup + every 6h
- No inbound firewall rules needed — all communication is on-prem → Hub outbound

View File

@@ -0,0 +1,89 @@
---
phase: 01-setup
plan: 01
type: execute
autonomous: true
status: complete
completed: 2026-03-15
---
## Goal
Bootstrap the full TapTrack Hub project: Docker Compose stack, complete database schema,
FastAPI backend skeleton with all routers and Celery tasks, Vue 3 frontend skeleton
with all page stubs, PAUL state/roadmap files.
## What Was Built
### Infrastructure
- Docker Compose: backend (gunicorn+uvicorn), celery worker, celery beat, frontend (vite+nginx),
PostgreSQL 15, Redis 7, nginx reverse proxy (port 8080)
- backend/Dockerfile, frontend/Dockerfile, nginx/nginx.conf
### Backend
- app/database.py — async SQLAlchemy engine + Base
- app/config.py — Settings (env vars: DB, Redis, SMTP, Semaphore, JWT)
- app/worker.py — Celery app + beat schedule (5 tasks)
- app/auth/ — password hashing (bcrypt), JWT encode/decode, FastAPI dependencies
(get_current_user, require_super_admin, require_school_admin)
### Database Models (8 tables)
- hub_users (id, email, full_name, hashed_password, role, school_id, is_active)
- schools (id, name, slug, address, tier, status, sms_credits, sms_sender_name, ...)
- licenses (id, school_id, key, status, tier, expires_at, last_validated_at, last_seen_ip)
- sms_jobs (id, school_id, recipient_phone, message, sender_name, status, retry_count)
- sms_credit_ledger (id, school_id, tx_type, amount, balance_after, description)
- invoices + invoice_line_items (full billing schema)
- school_subscriptions (monthly_fee, sms_cost_per_message, cycle, next_billing_date)
- support_tickets + ticket_replies (subject, body, category, status, priority, is_internal)
- audit_logs (actor, action, entity, detail, ip_address)
- announcements (title, body, is_active, expires_at)
### Routers (11)
- auth: POST /login, GET /me, PUT /me/password
- schools: CRUD + POST /{id}/credits (SMS top-up)
- licenses: list, update, revoke, POST /validate (for on-prem)
- sms: POST /submit (on-prem), GET /jobs, GET /credits/{school_id}
- billing: invoices CRUD + send-email, subscriptions upsert
- tickets: CRUD + replies
- users: CRUD (super admin)
- dashboard: GET /summary (KPIs)
- school_portal: GET /portal/overview (school-scoped)
- announcements: CRUD
- sync: POST /sync/poll (on-prem 30s poll)
### Celery Tasks (6)
- sms.process_queue — send pending jobs via Semaphore, deduct credits, log ledger
- sms.send_low_credit_alert — email school when balance < threshold
- billing.generate_monthly_invoices — create draft invoices on 1st of month
- billing.send_invoice_email — send invoice to billing contact
- billing.check_overdue — mark unpaid invoices as overdue daily
- license.check_expiry — email expiry warnings at 30/14/7 days
- reports.send_monthly_reports — email report stub to all active schools
### Services
- app/services/email.py — SMTP send_email helper
### Seed Script
- backend/seed.py — creates default super admin: admin@taptrack.io / admin123!
### Frontend (Vue 3 + Vite + Tailwind + Pinia)
- router: super admin routes (/, /dashboard, /schools, /licenses, /sms, /billing, /tickets, /users, /announcements)
+ portal routes (/portal, /portal/billing, /portal/sms, /portal/tickets, /portal/profile)
- stores/auth.ts — Pinia auth store with localStorage persistence
- lib/api.ts — full axios client for all endpoints
- composables/useToast.ts — toast notification system
- layouts: AppLayout (super admin), PortalLayout (school), AuthLayout (login)
- components: AppSidebar, PortalSidebar, SidebarItem, KpiCard, StatusBadge, ToastStack
- pages: LoginPage, DashboardPage, SchoolsPage, SchoolDetailPage, LicensesPage,
SmsPage, BillingPage, TicketsPage, TicketDetailPage, UsersPage, AnnouncementsPage,
NotFoundPage
- portal pages: PortalOverviewPage, PortalBillingPage, PortalSmsPage, PortalTicketsPage, PortalProfilePage
## Acceptance Criteria (All Met)
- [x] Docker Compose starts cleanly (backend, celery, frontend, db, redis, nginx)
- [x] GET /api/health returns {"status":"ok"}
- [x] Database tables created on startup via SQLAlchemy create_all
- [x] Super admin seed script creates admin account
- [x] Frontend builds and serves on port 8080
- [x] Login page renders; JWT auth flow works
- [x] All 15 PAUL phase directories created

View File

@@ -0,0 +1,20 @@
# Phase 2: School Registry + License Management
**Status:** Not started
**Depends on:** Phase 1
## Goal
Super admin can register schools, issue license keys, set billing plans, add SMS credits,
and manage the school lifecycle (pending → active → suspended/expired).
## Plans
- [ ] 2-01: School create/edit modal + license expiry editor in SchoolDetailPage
- [ ] 2-02: Subscription setup + SMS credit top-up form + ledger table
## Key Endpoints (already built, need UI)
- POST /api/schools — create school (auto-issues license)
- PUT /api/schools/{id} — update school details
- PUT /api/licenses/{id} — set expiry, tier, max_students
- POST /api/licenses/{id}/revoke — revoke license
- POST /api/schools/{id}/credits — add SMS credits
- PUT /api/billing/subscriptions/{school_id} — set monthly fee

View File

@@ -0,0 +1,9 @@
# Phase 03: On-Prem License Validation
**Status:** Not started
## Goal
TapTrack validates license key against Hub on startup; returns feature flags and school config.
## Plans
- [ ] TBD — run /paul:plan when Phase 2 is complete

View File

@@ -0,0 +1,9 @@
# Phase 04: SMS Gateway (Credits + Queue)
**Status:** Not started
## Goal
Schools submit SMS jobs via Hub; Hub sends via Semaphore; credits deducted per message.
## Plans
- [ ] TBD — run /paul:plan when Phase 3 is complete

View File

@@ -0,0 +1,9 @@
# Phase 05: On-Prem SMS Polling Agent
**Status:** Not started
## Goal
TapTrack polls Hub every 30s for pending SMS jobs; sends them; reports back completion.
## Plans
- [ ] TBD — run /paul:plan when Phase 4 is complete

View File

@@ -0,0 +1,9 @@
# Phase 06: Super Admin Dashboard UI
**Status:** Not started
## Goal
Rich dashboard with KPI cards, school health table, SMS volume chart, revenue snapshot.
## Plans
- [ ] TBD — run /paul:plan when Phase 5 is complete

View File

@@ -0,0 +1,9 @@
# Phase 07: School Admin Portal UI
**Status:** Not started
## Goal
Complete portal with credit meter, charts, billing PDF download, SMS reports.
## Plans
- [ ] TBD — run /paul:plan when Phase 6 is complete

View File

View File

@@ -0,0 +1,9 @@
# Phase 10: Support Ticket System
**Status:** Not started
## Goal
Email notifications on tickets, SLA tracking, internal notes, priority escalation.
## Plans
- [ ] TBD — run /paul:plan when Phase 9 is complete

View File

@@ -0,0 +1,9 @@
# Phase 11: Monthly Report Generation
**Status:** Not started
## Goal
Auto monthly report email to schools with attendance summary and SMS usage.
## Plans
- [ ] TBD — run /paul:plan when Phase 10 is complete

View File

@@ -0,0 +1,9 @@
# Phase 12: On-Prem Monthly Report Pull
**Status:** Not started
## Goal
Hub pulls attendance data from each TapTrack instance for monthly reports.
## Plans
- [ ] TBD — run /paul:plan when Phase 11 is complete

View File

@@ -0,0 +1,9 @@
# Phase 13: Feature Flags + Suspension
**Status:** Not started
## Goal
Per-school feature flag overrides; suspension propagation to on-prem.
## Plans
- [ ] TBD — run /paul:plan when Phase 12 is complete

View File

@@ -0,0 +1,9 @@
# Phase 14: Onboarding Wizard
**Status:** Not started
## Goal
Welcome email with license key; onboarding checklist UI in SchoolDetailPage.
## Plans
- [ ] TBD — run /paul:plan when Phase 13 is complete

View File

@@ -0,0 +1,9 @@
# Phase 15: UX Polish + Ops Tools
**Status:** Not started
## Goal
Audit log viewer, global search, bulk export, mobile responsive portal, production hardening.
## Plans
- [ ] TBD — run /paul:plan when Phase 14 is complete

19
backend/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev gcc curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["gunicorn", "app.main:app", \
"-k", "uvicorn.workers.UvicornWorker", \
"--workers", "2", \
"--bind", "0.0.0.0:8000", \
"--timeout", "120", \
"--access-logfile", "-"]

38
backend/alembic.ini Normal file
View File

@@ -0,0 +1,38 @@
[alembic]
script_location = migrations
prepend_sys_path = .
sqlalchemy.url = postgresql://postgres:postgres@db:5432/taptrack_hub
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

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

40
backend/migrations/env.py Normal file
View File

@@ -0,0 +1,40 @@
import asyncio
import os
from logging.config import fileConfig
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import pool
from alembic import context
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
from app.database import Base
from app.models import user, school, license, sms, billing, ticket, audit, announcement # noqa
target_metadata = Base.metadata
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub")
def run_migrations_offline():
context.configure(url=DATABASE_URL.replace("+asyncpg", ""), target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations():
connectable = create_async_engine(DATABASE_URL, poolclass=pool.NullPool)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online():
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

18
backend/requirements.txt Normal file
View File

@@ -0,0 +1,18 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
gunicorn==23.0.0
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
alembic==1.14.0
pydantic[email]==2.10.3
pydantic-settings==2.7.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.20
celery==5.4.0
redis==5.2.1
httpx==0.28.1
jinja2==3.1.4
weasyprint==62.3
python-slugify==8.0.4
psycopg2-binary==2.9.10

37
backend/seed.py Normal file
View File

@@ -0,0 +1,37 @@
"""Seed the database with a default super admin account."""
import asyncio
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from sqlalchemy import select
from app.database import Base
from app.models.user import HubUser, UserRole
from app.auth.password import hash_password
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub")
async def seed():
engine = create_async_engine(DATABASE_URL)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as db:
existing = (await db.execute(select(HubUser).where(HubUser.role == UserRole.super_admin))).scalar_one_or_none()
if existing:
print(f"Super admin already exists: {existing.email}")
return
admin = HubUser(
email="admin@taptrack.io",
full_name="TapTrack Admin",
hashed_password=hash_password("admin123!"),
role=UserRole.super_admin,
)
db.add(admin)
await db.commit()
print(f"Created super admin: admin@taptrack.io / admin123!")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(seed())

115
docker-compose.yml Normal file
View File

@@ -0,0 +1,115 @@
version: "3.9"
services:
db:
image: postgres:15-alpine
restart: unless-stopped
environment:
POSTGRES_DB: taptrack_hub
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis_data:/data
backend:
build: ./backend
restart: unless-stopped
environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub
REDIS_URL: redis://redis:6379/0
SECRET_KEY: changeme-in-production-use-openssl-rand-hex-32
ENVIRONMENT: development
SEMAPHORE_API_KEY: ""
SMTP_HOST: ""
SMTP_PORT: "587"
SMTP_USER: ""
SMTP_PASSWORD: ""
SMTP_FROM: "noreply@taptrack.io"
HUB_BASE_URL: "http://localhost:8080"
volumes:
- ./backend:/app
- backend_data:/app/data
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
celery:
build: ./backend
command: celery -A app.worker worker --loglevel=info --concurrency=2
restart: unless-stopped
environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub
REDIS_URL: redis://redis:6379/0
SECRET_KEY: changeme-in-production-use-openssl-rand-hex-32
ENVIRONMENT: development
SEMAPHORE_API_KEY: ""
SMTP_HOST: ""
SMTP_PORT: "587"
SMTP_USER: ""
SMTP_PASSWORD: ""
SMTP_FROM: "noreply@taptrack.io"
HUB_BASE_URL: "http://localhost:8080"
volumes:
- ./backend:/app
- backend_data:/app/data
depends_on:
- db
- redis
celery-beat:
build: ./backend
command: celery -A app.worker beat --loglevel=info
restart: unless-stopped
environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub
REDIS_URL: redis://redis:6379/0
SECRET_KEY: changeme-in-production-use-openssl-rand-hex-32
ENVIRONMENT: development
SEMAPHORE_API_KEY: ""
SMTP_HOST: ""
SMTP_PORT: "587"
SMTP_USER: ""
SMTP_PASSWORD: ""
SMTP_FROM: "noreply@taptrack.io"
HUB_BASE_URL: "http://localhost:8080"
volumes:
- ./backend:/app
- backend_data:/app/data
depends_on:
- db
- redis
frontend:
build: ./frontend
restart: unless-stopped
depends_on:
- backend
nginx:
image: nginx:alpine
restart: unless-stopped
ports:
- "8080:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- backend
- frontend
volumes:
db_data:
redis_data:
backend_data:

12
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

14
frontend/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TapTrack Hub</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

10
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,10 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / { try_files $uri $uri/ /index.html; }
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

26
frontend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "taptrack-hub-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"lucide-vue-next": "^0.469.0",
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite": "^6.0.7",
"vue-tsc": "^2.2.0"
}
}

View File

@@ -0,0 +1,3 @@
export default {
plugins: { tailwindcss: {}, autoprefixer: {} },
}

26
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,26 @@
<template>
<component :is="layout">
<RouterView />
</component>
<ToastStack />
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import ToastStack from '@/components/ui/ToastStack.vue'
import AppLayout from '@/layouts/AppLayout.vue'
import PortalLayout from '@/layouts/PortalLayout.vue'
import AuthLayout from '@/layouts/AuthLayout.vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
authStore.loadFromStorage()
const route = useRoute()
const layout = computed(() => {
if (route.meta.layout === 'portal') return PortalLayout
if (route.meta.layout === 'app') return AppLayout
return AuthLayout
})
</script>

View File

@@ -0,0 +1,10 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* { box-sizing: border-box; }
body { font-family: 'Inter', system-ui, sans-serif; margin: 0; background: #F8FAFC; color: #111827; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #CBD5E1; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #94A3B8; }

View File

@@ -0,0 +1,37 @@
<template>
<aside class="w-60 bg-sidebar flex flex-col h-full shrink-0">
<div class="px-5 py-4 border-b border-slate-700">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-lg bg-blue-500 flex items-center justify-center">
<Layers :size="16" class="text-white" />
</div>
<div>
<p class="text-white text-sm font-bold leading-tight">TapTrack Hub</p>
<p class="text-slate-400 text-xs">Control Plane</p>
</div>
</div>
</div>
<nav class="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
<SidebarItem v-for="item in navItems" :key="item.to" v-bind="item" />
</nav>
<div class="px-3 py-3 border-t border-slate-700">
<div class="px-3 py-2 text-xs text-slate-500">v1.0.0 Super Admin</div>
</div>
</aside>
</template>
<script setup lang="ts">
import { LayoutDashboard, Building2, KeyRound, MessageSquare, Receipt, Ticket, Users, Megaphone, Layers } from 'lucide-vue-next'
import SidebarItem from './SidebarItem.vue'
const navItems = [
{ label: 'Dashboard', to: '/dashboard', icon: LayoutDashboard },
{ label: 'Schools', to: '/schools', icon: Building2 },
{ label: 'Licenses', to: '/licenses', icon: KeyRound },
{ label: 'SMS Gateway', to: '/sms', icon: MessageSquare },
{ label: 'Billing', to: '/billing', icon: Receipt },
{ label: 'Support', to: '/tickets', icon: Ticket },
{ label: 'Users', to: '/users', icon: Users },
{ label: 'Announcements', to: '/announcements', icon: Megaphone },
]
</script>

View File

@@ -0,0 +1,31 @@
<template>
<aside class="w-60 bg-sidebar flex flex-col h-full shrink-0">
<div class="px-5 py-4 border-b border-slate-700">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-lg bg-emerald-500 flex items-center justify-center">
<School :size="16" class="text-white" />
</div>
<div>
<p class="text-white text-sm font-bold leading-tight">School Portal</p>
<p class="text-slate-400 text-xs">TapTrack Hub</p>
</div>
</div>
</div>
<nav class="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
<SidebarItem v-for="item in navItems" :key="item.to" v-bind="item" />
</nav>
</aside>
</template>
<script setup lang="ts">
import { LayoutDashboard, Receipt, MessageSquare, Ticket, UserCircle, School } from 'lucide-vue-next'
import SidebarItem from './SidebarItem.vue'
const navItems = [
{ label: 'Overview', to: '/portal', icon: LayoutDashboard },
{ label: 'Billing', to: '/portal/billing', icon: Receipt },
{ label: 'SMS Reports', to: '/portal/sms', icon: MessageSquare },
{ label: 'Support', to: '/portal/tickets', icon: Ticket },
{ label: 'Profile', to: '/portal/profile', icon: UserCircle },
]
</script>

View File

@@ -0,0 +1,21 @@
<template>
<RouterLink
:to="to"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150"
:class="isActive
? 'bg-blue-600 text-white'
: 'text-slate-400 hover:bg-slate-700 hover:text-white'"
>
<component :is="icon" :size="16" class="shrink-0" />
<span>{{ label }}</span>
</RouterLink>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const props = defineProps<{ label: string; to: string; icon: any }>()
const route = useRoute()
const isActive = computed(() => route.path === props.to || route.path.startsWith(props.to + '/'))
</script>

View File

@@ -0,0 +1,31 @@
<template>
<div class="bg-white rounded-xl p-5 flex flex-col gap-3" style="box-shadow:0 2px 8px #0000000A">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wide">{{ label }}</span>
<div class="w-8 h-8 rounded-lg flex items-center justify-center" :class="iconBg">
<component :is="iconComponent" :size="16" :class="iconColor" />
</div>
</div>
<p class="text-2xl font-bold text-slate-900">{{ value.toLocaleString() }}</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Building2, CheckCircle, KeyRound, Ticket, MessageSquare, Receipt, Users } from 'lucide-vue-next'
const props = defineProps<{ label: string; value: number; icon: string; color: string }>()
const iconMap: Record<string, any> = { Building2, CheckCircle, KeyRound, Ticket, MessageSquare, Receipt, Users }
const iconComponent = computed(() => iconMap[props.icon] ?? Building2)
const colorMap: Record<string, [string, string]> = {
blue: ['bg-blue-100', 'text-blue-600'],
green: ['bg-emerald-100','text-emerald-600'],
amber: ['bg-amber-100', 'text-amber-600'],
red: ['bg-red-100', 'text-red-500'],
purple: ['bg-purple-100', 'text-purple-600'],
}
const iconBg = computed(() => colorMap[props.color]?.[0] ?? 'bg-slate-100')
const iconColor = computed(() => colorMap[props.color]?.[1] ?? 'text-slate-600')
</script>

View File

@@ -0,0 +1,26 @@
<template>
<span class="inline-block text-xs font-semibold px-2.5 py-0.5 rounded-full" :class="classes">
{{ status }}
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ status: string }>()
const classes = computed(() => ({
active: 'bg-emerald-100 text-emerald-700',
pending: 'bg-amber-100 text-amber-700',
suspended: 'bg-red-100 text-red-700',
expired: 'bg-slate-100 text-slate-500',
open: 'bg-blue-100 text-blue-700',
in_progress: 'bg-purple-100 text-purple-700',
resolved: 'bg-emerald-100 text-emerald-700',
closed: 'bg-slate-100 text-slate-500',
paid: 'bg-emerald-100 text-emerald-700',
sent: 'bg-blue-100 text-blue-700',
draft: 'bg-slate-100 text-slate-600',
overdue: 'bg-red-100 text-red-700',
trial: 'bg-purple-100 text-purple-700',
revoked: 'bg-red-100 text-red-700',
}[props.status] ?? 'bg-slate-100 text-slate-600'))
</script>

View File

@@ -0,0 +1,36 @@
<template>
<Teleport to="body">
<div class="fixed bottom-5 right-5 z-[9999] flex flex-col gap-2 pointer-events-none" style="min-width:280px;max-width:380px">
<TransitionGroup name="toast">
<div
v-for="t in toasts"
:key="t.id"
class="flex items-start gap-3 px-4 py-3 rounded-xl shadow-xl text-sm font-medium pointer-events-auto"
:class="{
'bg-emerald-600 text-white': t.type === 'success',
'bg-red-600 text-white': t.type === 'error',
'bg-blue-600 text-white': t.type === 'info',
'bg-amber-500 text-white': t.type === 'warning',
}"
>
<CheckCircle v-if="t.type === 'success'" :size="16" class="shrink-0 mt-0.5" />
<XCircle v-else-if="t.type === 'error'" :size="16" class="shrink-0 mt-0.5" />
<Info v-else :size="16" class="shrink-0 mt-0.5" />
<span>{{ t.message }}</span>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { CheckCircle, XCircle, Info } from 'lucide-vue-next'
import { toasts } from '@/composables/useToast'
</script>
<style scoped>
.toast-enter-active { transition: all 0.25s ease; }
.toast-leave-active { transition: all 0.2s ease; }
.toast-enter-from { opacity: 0; transform: translateX(100%); }
.toast-leave-to { opacity: 0; transform: translateX(100%); }
</style>

View File

@@ -0,0 +1,22 @@
import { ref } from 'vue'
interface Toast { id: number; type: 'success' | 'error' | 'info' | 'warning'; message: string }
const toasts = ref<Toast[]>([])
let nextId = 1
export function useToast() {
const show = (type: Toast['type'], message: string, duration = 4000) => {
const id = nextId++
toasts.value.push({ id, type, message })
setTimeout(() => { toasts.value = toasts.value.filter(t => t.id !== id) }, duration)
}
return {
success: (msg: string) => show('success', msg),
error: (msg: string) => show('error', msg),
info: (msg: string) => show('info', msg),
warning: (msg: string) => show('warning', msg),
}
}
export { toasts }

View File

@@ -0,0 +1,50 @@
<template>
<div class="flex h-screen overflow-hidden bg-slate-50">
<AppSidebar />
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
<!-- Top bar -->
<header class="h-14 bg-white border-b border-slate-200 flex items-center justify-between px-6 shrink-0">
<div class="text-sm text-slate-500">
TapTrack Hub
<span class="mx-1.5 text-slate-300">·</span>
<span class="text-slate-800 font-medium">{{ pageTitle }}</span>
</div>
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-slate-700">{{ authStore.fullName }}</span>
<button @click="logout" class="text-slate-400 hover:text-slate-700 transition-colors" title="Logout">
<LogOut :size="18" />
</button>
</div>
</header>
<!-- Page content -->
<main class="flex-1 overflow-y-auto p-6">
<RouterView />
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { LogOut } from 'lucide-vue-next'
import AppSidebar from '@/components/sidebar/AppSidebar.vue'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const pageTitles: Record<string, string> = {
dashboard: 'Dashboard', schools: 'Schools', 'school-detail': 'School Detail',
licenses: 'Licenses', sms: 'SMS Gateway', billing: 'Billing',
tickets: 'Support Tickets', 'ticket-detail': 'Ticket Detail',
users: 'Users', announcements: 'Announcements',
}
const pageTitle = computed(() => pageTitles[route.name as string] ?? '')
function logout() {
authStore.logout()
router.push('/login')
}
</script>

View File

@@ -0,0 +1,5 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 flex items-center justify-center p-4">
<RouterView />
</div>
</template>

View File

@@ -0,0 +1,43 @@
<template>
<div class="flex h-screen overflow-hidden bg-slate-50">
<PortalSidebar />
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
<header class="h-14 bg-white border-b border-slate-200 flex items-center justify-between px-6 shrink-0">
<div class="text-sm text-slate-500">
School Portal
<span class="mx-1.5 text-slate-300">·</span>
<span class="text-slate-800 font-medium">{{ pageTitle }}</span>
</div>
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-slate-700">{{ authStore.fullName }}</span>
<button @click="logout" class="text-slate-400 hover:text-slate-700 transition-colors">
<LogOut :size="18" />
</button>
</div>
</header>
<main class="flex-1 overflow-y-auto p-6">
<RouterView />
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { LogOut } from 'lucide-vue-next'
import PortalSidebar from '@/components/sidebar/PortalSidebar.vue'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const titles: Record<string, string> = {
portal: 'Overview', 'portal-billing': 'Billing',
'portal-sms': 'SMS Reports', 'portal-tickets': 'Support', 'portal-profile': 'Profile',
}
const pageTitle = computed(() => titles[route.name as string] ?? '')
function logout() { authStore.logout(); router.push('/login') }
</script>

90
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,90 @@
import axios from 'axios'
const api = axios.create({ baseURL: '/api' })
api.interceptors.request.use((config) => {
const token = localStorage.getItem('hub_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
api.interceptors.response.use(
(r) => r,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('hub_token')
window.location.href = '/login'
}
return Promise.reject(error)
},
)
export default api
// ── Auth ──────────────────────────────────────────────────────────────────────
export const login = (email: string, password: string) =>
api.post('/auth/login', { email, password }).then(r => r.data)
export const getMe = () => api.get('/auth/me').then(r => r.data)
export const changePassword = (current_password: string, new_password: string) =>
api.put('/auth/me/password', { current_password, new_password })
// ── Dashboard ─────────────────────────────────────────────────────────────────
export const getDashboardSummary = () => api.get('/dashboard/summary').then(r => r.data)
// ── Schools ───────────────────────────────────────────────────────────────────
export interface School {
id: string; name: string; slug: string; status: string; tier: string
contact_email: string | null; contact_name: string | null; city: string | null
sms_credits: number; sms_sender_name: string; student_limit: number
license_key: string | null; license_status: string | null
license_expires_at: string | null; license_last_seen: string | null
created_at: string; billing_email: string | null; notes: string | null
}
export const getSchools = (params?: object) => api.get('/schools', { params }).then(r => r.data)
export const getSchool = (id: string) => api.get(`/schools/${id}`).then(r => r.data)
export const createSchool = (data: object) => api.post('/schools', data).then(r => r.data)
export const updateSchool = (id: string, data: object) => api.put(`/schools/${id}`, data).then(r => r.data)
export const addSmsCredits = (id: string, amount: number, description?: string) =>
api.post(`/schools/${id}/credits`, null, { params: { amount, description } }).then(r => r.data)
// ── Licenses ──────────────────────────────────────────────────────────────────
export const getLicenses = () => api.get('/licenses').then(r => r.data)
export const updateLicense = (id: string, data: object) => api.put(`/licenses/${id}`, data).then(r => r.data)
export const revokeLicense = (id: string) => api.post(`/licenses/${id}/revoke`).then(r => r.data)
// ── SMS ───────────────────────────────────────────────────────────────────────
export const getSmsJobs = (params?: object) => api.get('/sms/jobs', { params }).then(r => r.data)
export const getCreditLedger = (schoolId: string, params?: object) =>
api.get(`/sms/credits/${schoolId}`, { params }).then(r => r.data)
// ── Billing ───────────────────────────────────────────────────────────────────
export const getInvoices = (params?: object) => api.get('/billing/invoices', { params }).then(r => r.data)
export const createInvoice = (data: object) => api.post('/billing/invoices', data).then(r => r.data)
export const updateInvoice = (id: string, data: object) => api.put(`/billing/invoices/${id}`, data).then(r => r.data)
export const sendInvoiceEmail = (id: string) => api.post(`/billing/invoices/${id}/send-email`).then(r => r.data)
export const getSubscription = (schoolId: string) => api.get(`/billing/subscriptions/${schoolId}`).then(r => r.data)
export const upsertSubscription = (schoolId: string, data: object) =>
api.put(`/billing/subscriptions/${schoolId}`, data).then(r => r.data)
// ── Tickets ───────────────────────────────────────────────────────────────────
export const getTickets = (params?: object) => api.get('/tickets', { params }).then(r => r.data)
export const getTicket = (id: string) => api.get(`/tickets/${id}`).then(r => r.data)
export const createTicket = (data: object) => api.post('/tickets', data).then(r => r.data)
export const updateTicket = (id: string, data: object) => api.put(`/tickets/${id}`, data).then(r => r.data)
export const addTicketReply = (id: string, data: object) => api.post(`/tickets/${id}/replies`, data).then(r => r.data)
// ── Users ─────────────────────────────────────────────────────────────────────
export const getUsers = (params?: object) => api.get('/users', { params }).then(r => r.data)
export const createUser = (data: object) => api.post('/users', data).then(r => r.data)
export const updateUser = (id: string, data: object) => api.put(`/users/${id}`, data).then(r => r.data)
// ── Announcements ─────────────────────────────────────────────────────────────
export const getAnnouncements = () => api.get('/announcements').then(r => r.data)
export const createAnnouncement = (data: object) => api.post('/announcements', data).then(r => r.data)
export const deleteAnnouncement = (id: string) => api.delete(`/announcements/${id}`)
// ── School Portal ─────────────────────────────────────────────────────────────
export const getPortalOverview = () => api.get('/portal/overview').then(r => r.data)

10
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import './assets/main.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,46 @@
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-slate-900">Announcements</h1>
<button @click="showCreate = true"
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700">
<Plus :size="16" /> New Announcement
</button>
</div>
<div class="space-y-4">
<div v-if="loading" class="animate-pulse h-24 bg-white rounded-xl"></div>
<div v-for="a in announcements" :key="a.id" class="bg-white rounded-xl p-5" style="box-shadow:0 2px 8px #0000000A">
<div class="flex items-start justify-between">
<div>
<h3 class="font-semibold text-slate-900">{{ a.title }}</h3>
<p class="text-sm text-slate-600 mt-1">{{ a.body }}</p>
<p class="text-xs text-slate-400 mt-2">{{ new Date(a.created_at).toLocaleString() }}</p>
</div>
<button @click="remove(a.id)" class="text-slate-400 hover:text-red-500 transition-colors ml-4">
<Trash2 :size="16" />
</button>
</div>
</div>
<div v-if="!loading && announcements.length === 0" class="p-12 text-center text-slate-400 bg-white rounded-xl">No announcements</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import { getAnnouncements, deleteAnnouncement } from '@/lib/api'
import { useToast } from '@/composables/useToast'
const toast = useToast()
const announcements = ref<any[]>([])
const loading = ref(true)
const showCreate = ref(false)
onMounted(async () => { try { announcements.value = await getAnnouncements() } finally { loading.value = false } })
async function remove(id: string) {
try { await deleteAnnouncement(id); announcements.value = announcements.value.filter(a => a.id !== id); toast.success('Removed') }
catch { toast.error('Failed to remove') }
}
</script>

View File

@@ -0,0 +1,78 @@
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-slate-900">Billing</h1>
<button @click="showCreate = true"
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700">
<Plus :size="16" /> New Invoice
</button>
</div>
<div class="flex gap-3">
<select v-model="statusFilter" class="border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">All Statuses</option>
<option value="draft">Draft</option>
<option value="sent">Sent</option>
<option value="paid">Paid</option>
<option value="overdue">Overdue</option>
</select>
</div>
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
<div v-if="loading" class="p-8 text-center text-sm text-slate-400 animate-pulse">Loading</div>
<div v-else-if="invoices.length === 0" class="p-12 text-center text-slate-400">No invoices found</div>
<table v-else class="w-full text-sm">
<thead class="bg-slate-50 border-b border-slate-100">
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
<th class="px-5 py-3">Invoice #</th>
<th class="px-5 py-3">School</th>
<th class="px-5 py-3">Period</th>
<th class="px-5 py-3">Amount</th>
<th class="px-5 py-3">Status</th>
<th class="px-5 py-3">Due</th>
<th class="px-5 py-3">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
<tr v-for="inv in invoices" :key="inv.id" class="hover:bg-slate-50">
<td class="px-5 py-3 font-mono text-xs font-semibold">{{ inv.invoice_number }}</td>
<td class="px-5 py-3 text-xs text-slate-600">{{ inv.school_id }}</td>
<td class="px-5 py-3 text-xs text-slate-500">{{ inv.billing_period_start }} {{ inv.billing_period_end }}</td>
<td class="px-5 py-3 font-semibold">PHP {{ Number(inv.total_amount).toLocaleString() }}</td>
<td class="px-5 py-3"><StatusBadge :status="inv.status" /></td>
<td class="px-5 py-3 text-xs text-slate-500">{{ inv.due_date || '—' }}</td>
<td class="px-5 py-3">
<button @click="sendEmail(inv.id)" class="text-xs text-blue-600 hover:underline">Send Email</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { Plus } from 'lucide-vue-next'
import { getInvoices, sendInvoiceEmail } from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { useToast } from '@/composables/useToast'
const toast = useToast()
const invoices = ref<any[]>([])
const loading = ref(false)
const statusFilter = ref('')
const showCreate = ref(false)
async function fetchInvoices() {
loading.value = true
try { const r = await getInvoices({ status: statusFilter.value || undefined }); invoices.value = r.items }
finally { loading.value = false }
}
async function sendEmail(id: string) {
try { await sendInvoiceEmail(id); toast.success('Invoice email queued') }
catch { toast.error('Failed to send email') }
}
watch(statusFilter, fetchInvoices)
onMounted(fetchInvoices)
</script>

View File

@@ -0,0 +1,57 @@
<template>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-slate-900">Dashboard</h1>
<p class="text-sm text-slate-500 mt-0.5">{{ today }}</p>
</div>
<!-- KPI Cards -->
<div v-if="loading" class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4 animate-pulse">
<div v-for="i in 5" :key="i" class="bg-white rounded-xl p-5 h-24" style="box-shadow:0 2px 8px #0000000A"></div>
</div>
<div v-else class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4">
<KpiCard label="Total Schools" :value="summary?.schools?.total ?? 0" icon="Building2" color="blue" />
<KpiCard label="Active Schools" :value="summary?.schools?.active ?? 0" icon="CheckCircle" color="green" />
<KpiCard label="Expiring Licenses" :value="summary?.licenses?.expiring_soon ?? 0" icon="KeyRound" color="amber" />
<KpiCard label="Open Tickets" :value="summary?.tickets?.open ?? 0" icon="Ticket" color="red" />
<KpiCard label="SMS Today" :value="summary?.sms?.sent_today ?? 0" icon="MessageSquare" color="purple" />
</div>
<!-- Secondary row -->
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<!-- Pending invoices -->
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
<h2 class="text-base font-semibold text-slate-900 mb-1">Billing</h2>
<p class="text-3xl font-bold text-slate-900">{{ summary?.invoices?.pending ?? 0 }}</p>
<p class="text-sm text-slate-500 mt-1">Pending invoices</p>
</div>
<!-- SMS Queue -->
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
<h2 class="text-base font-semibold text-slate-900 mb-1">SMS Queue</h2>
<p class="text-3xl font-bold text-slate-900">{{ summary?.sms?.pending ?? 0 }}</p>
<p class="text-sm text-slate-500 mt-1">Jobs awaiting dispatch</p>
</div>
<!-- Suspended schools -->
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
<h2 class="text-base font-semibold text-slate-900 mb-1">Suspended</h2>
<p class="text-3xl font-bold text-red-500">{{ summary?.schools?.suspended ?? 0 }}</p>
<p class="text-sm text-slate-500 mt-1">Schools suspended</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getDashboardSummary } from '@/lib/api'
import KpiCard from '@/components/ui/KpiCard.vue'
const summary = ref<any>(null)
const loading = ref(true)
const today = new Date().toLocaleDateString('en-PH', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
onMounted(async () => {
try { summary.value = await getDashboardSummary() }
finally { loading.value = false }
})
</script>

View File

@@ -0,0 +1,41 @@
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-900">Licenses</h1>
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
<div v-if="loading" class="p-8 text-center text-slate-400 text-sm animate-pulse">Loading</div>
<table v-else class="w-full text-sm">
<thead class="bg-slate-50 border-b border-slate-100">
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
<th class="px-5 py-3">License Key</th>
<th class="px-5 py-3">School ID</th>
<th class="px-5 py-3">Status</th>
<th class="px-5 py-3">Tier</th>
<th class="px-5 py-3">Expires</th>
<th class="px-5 py-3">Last Validated</th>
<th class="px-5 py-3">Last IP</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
<tr v-for="l in licenses" :key="l.id" class="hover:bg-slate-50">
<td class="px-5 py-3 font-mono text-xs">{{ l.key }}</td>
<td class="px-5 py-3 text-xs text-slate-500">{{ l.school_id }}</td>
<td class="px-5 py-3"><StatusBadge :status="l.status" /></td>
<td class="px-5 py-3 capitalize">{{ l.tier }}</td>
<td class="px-5 py-3 text-slate-500 text-xs">{{ l.expires_at ? new Date(l.expires_at).toLocaleDateString() : 'Never' }}</td>
<td class="px-5 py-3 text-slate-500 text-xs">{{ l.last_validated_at ? new Date(l.last_validated_at).toLocaleString() : '—' }}</td>
<td class="px-5 py-3 text-slate-500 text-xs font-mono">{{ l.last_seen_ip || '—' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getLicenses } from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue'
const licenses = ref<any[]>([])
const loading = ref(true)
onMounted(async () => { try { licenses.value = await getLicenses() } finally { loading.value = false } })
</script>

View File

@@ -0,0 +1,60 @@
<template>
<div class="w-full max-w-md">
<div class="bg-white rounded-2xl shadow-2xl overflow-hidden">
<div class="bg-gradient-to-r from-slate-800 to-slate-900 px-8 py-8 text-center">
<div class="w-12 h-12 rounded-xl bg-blue-500 flex items-center justify-center mx-auto mb-3">
<Layers :size="24" class="text-white" />
</div>
<h1 class="text-xl font-bold text-white">TapTrack Hub</h1>
<p class="text-slate-400 text-sm mt-1">Cloud Control Plane</p>
</div>
<form @submit.prevent="handleLogin" class="px-8 py-8 space-y-5">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">Email</label>
<input v-model="email" type="email" required autocomplete="email"
class="w-full border border-slate-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="admin@taptrack.io" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">Password</label>
<input v-model="password" type="password" required autocomplete="current-password"
class="w-full border border-slate-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<p v-if="error" class="text-sm text-red-600 bg-red-50 rounded-lg px-3 py-2">{{ error }}</p>
<button type="submit" :disabled="loading"
class="w-full py-2.5 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold transition-colors disabled:opacity-60">
{{ loading ? 'Signing in…' : 'Sign In' }}
</button>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { Layers } from 'lucide-vue-next'
import { login } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const email = ref('')
const password = ref('')
const loading = ref(false)
const error = ref('')
async function handleLogin() {
loading.value = true
error.value = ''
try {
const data = await login(email.value, password.value)
authStore.setAuth(data)
router.push(data.role === 'super_admin' ? '/dashboard' : '/portal')
} catch (e: any) {
error.value = e?.response?.data?.detail ?? 'Login failed'
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,9 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-slate-50">
<div class="text-center">
<p class="text-6xl font-bold text-slate-200">404</p>
<p class="text-xl font-semibold text-slate-700 mt-4">Page not found</p>
<RouterLink to="/" class="mt-6 inline-block text-sm text-blue-600 hover:underline">Go home</RouterLink>
</div>
</div>
</template>

View File

@@ -0,0 +1,76 @@
<template>
<div class="space-y-6">
<div class="flex items-center gap-4">
<button @click="$router.back()" class="text-slate-400 hover:text-slate-700 transition-colors">
<ArrowLeft :size="20" />
</button>
<div>
<h1 class="text-2xl font-bold text-slate-900">{{ school?.name ?? '…' }}</h1>
<p class="text-sm text-slate-500 mt-0.5">{{ school?.slug }}</p>
</div>
<StatusBadge v-if="school" :status="school.status" class="ml-2" />
</div>
<div v-if="loading" class="animate-pulse h-48 bg-white rounded-xl" style="box-shadow:0 2px 8px #0000000A"></div>
<div v-else-if="school" class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<!-- Info card -->
<div class="xl:col-span-2 bg-white rounded-xl p-6 space-y-4" style="box-shadow:0 2px 8px #0000000A">
<h2 class="text-base font-semibold text-slate-900">School Information</h2>
<div class="grid grid-cols-2 gap-4 text-sm">
<div><p class="text-slate-500">Contact</p><p class="font-medium">{{ school.contact_name || '—' }}</p></div>
<div><p class="text-slate-500">Email</p><p class="font-medium">{{ school.contact_email || '—' }}</p></div>
<div><p class="text-slate-500">City</p><p class="font-medium">{{ school.city || '—' }}</p></div>
<div><p class="text-slate-500">Billing Email</p><p class="font-medium">{{ school.billing_email || '—' }}</p></div>
<div><p class="text-slate-500">Tier</p><p class="font-medium capitalize">{{ school.tier }}</p></div>
<div><p class="text-slate-500">Student Limit</p><p class="font-medium">{{ school.student_limit.toLocaleString() }}</p></div>
<div><p class="text-slate-500">SMS Sender</p><p class="font-medium font-mono">{{ school.sms_sender_name }}</p></div>
<div>
<p class="text-slate-500">SMS Credits</p>
<p class="font-medium" :class="school.sms_credits < 50 ? 'text-red-600' : ''">
{{ school.sms_credits.toLocaleString() }}
</p>
</div>
</div>
</div>
<!-- License card -->
<div class="bg-white rounded-xl p-6 space-y-3" style="box-shadow:0 2px 8px #0000000A">
<h2 class="text-base font-semibold text-slate-900">License</h2>
<div class="text-sm space-y-2">
<div class="flex justify-between"><span class="text-slate-500">Key</span><span class="font-mono text-xs">{{ school.license_key || '—' }}</span></div>
<div class="flex justify-between"><span class="text-slate-500">Status</span><StatusBadge :status="school.license_status || 'pending'" /></div>
<div class="flex justify-between"><span class="text-slate-500">Expires</span><span>{{ school.license_expires_at ? new Date(school.license_expires_at).toLocaleDateString() : 'Never' }}</span></div>
<div class="flex justify-between"><span class="text-slate-500">Last Seen</span><span>{{ school.license_last_seen ? new Date(school.license_last_seen).toLocaleDateString() : '—' }}</span></div>
</div>
<button @click="copyKey" v-if="school.license_key"
class="w-full mt-2 py-1.5 text-xs font-medium border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors flex items-center justify-center gap-1.5">
<Copy :size="12" /> Copy License Key
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ArrowLeft, Copy } from 'lucide-vue-next'
import { getSchool } from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { useToast } from '@/composables/useToast'
const route = useRoute()
const toast = useToast()
const school = ref<any>(null)
const loading = ref(true)
onMounted(async () => {
try { school.value = await getSchool(route.params.id as string) }
finally { loading.value = false }
})
function copyKey() {
navigator.clipboard.writeText(school.value?.license_key ?? '')
toast.success('License key copied')
}
</script>

View File

@@ -0,0 +1,120 @@
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-slate-900">Schools</h1>
<p class="text-sm text-slate-500 mt-0.5">{{ total }} registered schools</p>
</div>
<button @click="showCreate = true"
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-colors">
<Plus :size="16" /> Add School
</button>
</div>
<!-- Filters -->
<div class="flex gap-3 flex-wrap">
<input v-model="search" type="text" placeholder="Search schools…"
class="border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-64" />
<select v-model="statusFilter" class="border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="pending">Pending</option>
<option value="suspended">Suspended</option>
<option value="expired">Expired</option>
</select>
</div>
<!-- Table -->
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
<div v-if="loading" class="p-8 text-center text-slate-400 text-sm animate-pulse">Loading schools</div>
<div v-else-if="schools.length === 0" class="p-12 text-center text-slate-400">
<Building2 :size="40" class="mx-auto mb-3 opacity-30" />
<p>No schools found</p>
</div>
<table v-else class="w-full text-sm">
<thead class="bg-slate-50 border-b border-slate-100">
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
<th class="px-5 py-3">School</th>
<th class="px-5 py-3">Status</th>
<th class="px-5 py-3">Tier</th>
<th class="px-5 py-3">SMS Credits</th>
<th class="px-5 py-3">License</th>
<th class="px-5 py-3">Last Seen</th>
<th class="px-5 py-3">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
<tr v-for="s in schools" :key="s.id" class="hover:bg-slate-50 transition-colors">
<td class="px-5 py-3">
<p class="font-semibold text-slate-900">{{ s.name }}</p>
<p class="text-xs text-slate-400">{{ s.city || s.slug }}</p>
</td>
<td class="px-5 py-3">
<StatusBadge :status="s.status" />
</td>
<td class="px-5 py-3 capitalize text-slate-600">{{ s.tier }}</td>
<td class="px-5 py-3">
<span :class="s.sms_credits < 50 ? 'text-red-600 font-semibold' : 'text-slate-700'">
{{ s.sms_credits.toLocaleString() }}
</span>
</td>
<td class="px-5 py-3">
<span v-if="s.license_status" class="text-xs font-semibold px-2 py-0.5 rounded-full"
:class="s.license_status === 'active' ? 'bg-emerald-100 text-emerald-700' : 'bg-red-100 text-red-700'">
{{ s.license_status }}
</span>
<span v-else class="text-slate-400 text-xs"></span>
</td>
<td class="px-5 py-3 text-xs text-slate-500">
{{ s.license_last_seen ? new Date(s.license_last_seen).toLocaleDateString() : '—' }}
</td>
<td class="px-5 py-3">
<RouterLink :to="`/schools/${s.id}`" class="text-blue-600 hover:underline text-xs font-medium">
View
</RouterLink>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div v-if="total > perPage" class="flex items-center justify-between text-sm text-slate-600">
<span>Showing {{ (page - 1) * perPage + 1 }}{{ Math.min(page * perPage, total) }} of {{ total }}</span>
<div class="flex gap-2">
<button :disabled="page <= 1" @click="page--" class="px-3 py-1.5 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-40">Prev</button>
<button :disabled="page * perPage >= total" @click="page++" class="px-3 py-1.5 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-40">Next</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { Plus, Building2 } from 'lucide-vue-next'
import { getSchools, type School } from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue'
const schools = ref<School[]>([])
const total = ref(0)
const page = ref(1)
const perPage = 25
const search = ref('')
const statusFilter = ref('')
const loading = ref(false)
const showCreate = ref(false)
let debounce: any = null
async function fetchSchools() {
loading.value = true
try {
const res = await getSchools({ page: page.value, per_page: perPage, search: search.value || undefined, status: statusFilter.value || undefined })
schools.value = res.items
total.value = res.total
} finally { loading.value = false }
}
watch(search, () => { clearTimeout(debounce); debounce = setTimeout(() => { page.value = 1; fetchSchools() }, 300) })
watch([page, statusFilter], fetchSchools)
onMounted(fetchSchools)
</script>

View File

@@ -0,0 +1,56 @@
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-900">SMS Gateway</h1>
<div class="flex gap-3 flex-wrap">
<select v-model="statusFilter" class="border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="sent">Sent</option>
<option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
<div v-if="loading" class="p-8 text-center text-sm text-slate-400 animate-pulse">Loading</div>
<div v-else-if="jobs.length === 0" class="p-12 text-center text-slate-400">No SMS jobs found</div>
<table v-else class="w-full text-sm">
<thead class="bg-slate-50 border-b border-slate-100">
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
<th class="px-5 py-3">Recipient</th>
<th class="px-5 py-3">Message</th>
<th class="px-5 py-3">Sender</th>
<th class="px-5 py-3">Status</th>
<th class="px-5 py-3">Trigger</th>
<th class="px-5 py-3">Created</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
<tr v-for="j in jobs" :key="j.id" class="hover:bg-slate-50">
<td class="px-5 py-3 font-mono text-xs">{{ j.recipient_phone }}</td>
<td class="px-5 py-3 text-slate-600 max-w-xs truncate">{{ j.message }}</td>
<td class="px-5 py-3 font-mono text-xs">{{ j.sender_name }}</td>
<td class="px-5 py-3"><StatusBadge :status="j.status" /></td>
<td class="px-5 py-3 text-slate-500 text-xs">{{ j.trigger || '—' }}</td>
<td class="px-5 py-3 text-slate-500 text-xs">{{ new Date(j.created_at).toLocaleString() }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { getSmsJobs } from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue'
const jobs = ref<any[]>([])
const loading = ref(false)
const statusFilter = ref('')
async function fetchJobs() {
loading.value = true
try { const r = await getSmsJobs({ status: statusFilter.value || undefined }); jobs.value = r.items }
finally { loading.value = false }
}
watch(statusFilter, fetchJobs)
onMounted(fetchJobs)
</script>

View File

@@ -0,0 +1,88 @@
<template>
<div class="space-y-6 max-w-3xl">
<div class="flex items-center gap-3">
<button @click="$router.back()" class="text-slate-400 hover:text-slate-700"><ArrowLeft :size="20" /></button>
<div>
<h1 class="text-xl font-bold text-slate-900">{{ ticket?.subject ?? '…' }}</h1>
<p class="text-xs text-slate-400 mt-0.5">{{ ticket?.ticket_number }}</p>
</div>
<StatusBadge v-if="ticket" :status="ticket.status" class="ml-auto" />
</div>
<div v-if="loading" class="animate-pulse h-40 bg-white rounded-xl"></div>
<template v-else-if="ticket">
<!-- Original message -->
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
<p class="text-sm text-slate-700 whitespace-pre-wrap">{{ ticket.body }}</p>
<p class="text-xs text-slate-400 mt-3">{{ new Date(ticket.created_at).toLocaleString() }}</p>
</div>
<!-- Replies -->
<div v-for="reply in ticket.replies" :key="reply.id"
class="rounded-xl p-5 text-sm"
:class="reply.is_internal ? 'bg-amber-50 border border-amber-200' : 'bg-white'"
style="box-shadow:0 2px 8px #0000000A">
<div class="flex items-center justify-between mb-2">
<span class="font-semibold text-slate-800 text-xs">{{ reply.author_id }}</span>
<span v-if="reply.is_internal" class="text-xs font-semibold text-amber-600 bg-amber-100 px-2 py-0.5 rounded-full">Internal</span>
</div>
<p class="text-slate-700 whitespace-pre-wrap">{{ reply.body }}</p>
<p class="text-xs text-slate-400 mt-2">{{ new Date(reply.created_at).toLocaleString() }}</p>
</div>
<!-- Reply form -->
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
<h3 class="text-sm font-semibold text-slate-900 mb-3">Add Reply</h3>
<textarea v-model="replyBody" rows="4" placeholder="Type your reply…"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"></textarea>
<div class="flex items-center gap-3 mt-3">
<label v-if="isSuperAdmin" class="flex items-center gap-2 text-sm text-slate-600 cursor-pointer">
<input type="checkbox" v-model="isInternal" class="accent-amber-500" /> Internal note
</label>
<button @click="submitReply" :disabled="!replyBody.trim() || replying"
class="ml-auto px-5 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50">
{{ replying ? 'Sending…' : 'Send Reply' }}
</button>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { ArrowLeft } from 'lucide-vue-next'
import { getTicket, addTicketReply } from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
const route = useRoute()
const authStore = useAuthStore()
const toast = useToast()
const isSuperAdmin = computed(() => authStore.isSuperAdmin)
const ticket = ref<any>(null)
const loading = ref(true)
const replyBody = ref('')
const isInternal = ref(false)
const replying = ref(false)
async function loadTicket() {
try { ticket.value = await getTicket(route.params.id as string) }
finally { loading.value = false }
}
async function submitReply() {
replying.value = true
try {
await addTicketReply(ticket.value.id, { body: replyBody.value, is_internal: isInternal.value })
replyBody.value = ''
toast.success('Reply sent')
await loadTicket()
} catch { toast.error('Failed to send reply') }
finally { replying.value = false }
}
onMounted(loadTicket)
</script>

View File

@@ -0,0 +1,74 @@
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-slate-900">Support Tickets</h1>
<button v-if="!isSuperAdmin" @click="showCreate = true"
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700">
<Plus :size="16" /> New Ticket
</button>
</div>
<div class="flex gap-3">
<select v-model="statusFilter" class="border border-slate-200 rounded-lg px-3 py-2 text-sm">
<option value="">All</option>
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
</select>
</div>
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
<div v-if="loading" class="p-8 text-center text-sm text-slate-400 animate-pulse">Loading</div>
<div v-else-if="tickets.length === 0" class="p-12 text-center text-slate-400">No tickets found</div>
<table v-else class="w-full text-sm">
<thead class="bg-slate-50 border-b border-slate-100">
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
<th class="px-5 py-3">Ticket #</th>
<th class="px-5 py-3">Subject</th>
<th class="px-5 py-3">Category</th>
<th class="px-5 py-3">Status</th>
<th class="px-5 py-3">Priority</th>
<th class="px-5 py-3">Created</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
<tr v-for="t in tickets" :key="t.id" class="hover:bg-slate-50 cursor-pointer" @click="$router.push(`/tickets/${t.id}`)">
<td class="px-5 py-3 font-mono text-xs font-semibold text-blue-600">{{ t.ticket_number }}</td>
<td class="px-5 py-3 font-medium text-slate-900 max-w-xs truncate">{{ t.subject }}</td>
<td class="px-5 py-3 capitalize text-slate-600 text-xs">{{ t.category }}</td>
<td class="px-5 py-3"><StatusBadge :status="t.status" /></td>
<td class="px-5 py-3">
<span class="text-xs font-semibold capitalize px-2 py-0.5 rounded-full"
:class="t.priority === 'urgent' ? 'bg-red-100 text-red-700' : t.priority === 'high' ? 'bg-amber-100 text-amber-700' : 'bg-slate-100 text-slate-600'">
{{ t.priority }}
</span>
</td>
<td class="px-5 py-3 text-xs text-slate-500">{{ new Date(t.created_at).toLocaleDateString() }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, computed } from 'vue'
import { Plus } from 'lucide-vue-next'
import { getTickets } from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const isSuperAdmin = computed(() => authStore.isSuperAdmin)
const tickets = ref<any[]>([])
const loading = ref(false)
const statusFilter = ref('')
const showCreate = ref(false)
async function fetchTickets() {
loading.value = true
try { const r = await getTickets({ status: statusFilter.value || undefined }); tickets.value = r.items }
finally { loading.value = false }
}
watch(statusFilter, fetchTickets)
onMounted(fetchTickets)
</script>

View File

@@ -0,0 +1,55 @@
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-slate-900">Hub Users</h1>
<button @click="showCreate = true"
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700">
<Plus :size="16" /> Add User
</button>
</div>
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
<div v-if="loading" class="p-8 text-center text-sm text-slate-400 animate-pulse">Loading</div>
<table v-else class="w-full text-sm">
<thead class="bg-slate-50 border-b border-slate-100">
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
<th class="px-5 py-3">Name</th>
<th class="px-5 py-3">Email</th>
<th class="px-5 py-3">Role</th>
<th class="px-5 py-3">School</th>
<th class="px-5 py-3">Active</th>
<th class="px-5 py-3">Created</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
<tr v-for="u in users" :key="u.id" class="hover:bg-slate-50">
<td class="px-5 py-3 font-medium text-slate-900">{{ u.full_name }}</td>
<td class="px-5 py-3 text-slate-600">{{ u.email }}</td>
<td class="px-5 py-3">
<span class="text-xs font-semibold px-2 py-0.5 rounded-full"
:class="u.role === 'super_admin' ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'">
{{ u.role }}
</span>
</td>
<td class="px-5 py-3 text-xs text-slate-500">{{ u.school_id || '—' }}</td>
<td class="px-5 py-3">
<span class="text-xs font-semibold px-2 py-0.5 rounded-full" :class="u.is_active ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-500'">
{{ u.is_active ? 'Active' : 'Inactive' }}
</span>
</td>
<td class="px-5 py-3 text-xs text-slate-500">{{ new Date(u.created_at).toLocaleDateString() }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Plus } from 'lucide-vue-next'
import { getUsers } from '@/lib/api'
const users = ref<any[]>([])
const loading = ref(true)
const showCreate = ref(false)
onMounted(async () => { try { const r = await getUsers(); users.value = r.items } finally { loading.value = false } })
</script>

View File

View File

@@ -0,0 +1,38 @@
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-900">Billing History</h1>
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
<div v-if="loading" class="p-8 text-center text-sm text-slate-400 animate-pulse">Loading</div>
<div v-else-if="invoices.length === 0" class="p-12 text-center text-slate-400">No invoices yet</div>
<table v-else class="w-full text-sm">
<thead class="bg-slate-50 border-b border-slate-100">
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
<th class="px-5 py-3">Invoice #</th>
<th class="px-5 py-3">Period</th>
<th class="px-5 py-3">Amount</th>
<th class="px-5 py-3">Status</th>
<th class="px-5 py-3">Due Date</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
<tr v-for="inv in invoices" :key="inv.id" class="hover:bg-slate-50">
<td class="px-5 py-3 font-mono text-xs font-semibold">{{ inv.invoice_number }}</td>
<td class="px-5 py-3 text-xs text-slate-500">{{ inv.billing_period_start }} {{ inv.billing_period_end }}</td>
<td class="px-5 py-3 font-semibold">PHP {{ Number(inv.total_amount).toLocaleString() }}</td>
<td class="px-5 py-3"><StatusBadge :status="inv.status" /></td>
<td class="px-5 py-3 text-xs text-slate-500">{{ inv.due_date || '—' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getInvoices } from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue'
const invoices = ref<any[]>([])
const loading = ref(true)
onMounted(async () => { try { const r = await getInvoices(); invoices.value = r.items } finally { loading.value = false } })
</script>

View File

@@ -0,0 +1,49 @@
<template>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-slate-900">Overview</h1>
<p class="text-sm text-slate-500 mt-0.5">{{ data?.school?.name }}</p>
</div>
<div v-if="loading" class="grid grid-cols-2 md:grid-cols-4 gap-4 animate-pulse">
<div v-for="i in 4" :key="i" class="bg-white rounded-xl h-24" style="box-shadow:0 2px 8px #0000000A"></div>
</div>
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-4">
<KpiCard label="SMS Credits" :value="data?.sms_credits ?? 0" icon="MessageSquare" color="blue" />
<KpiCard label="SMS This Month" :value="data?.sms_this_month ?? 0" icon="MessageSquare" color="green" />
<KpiCard label="Open Tickets" :value="data?.open_tickets ?? 0" icon="Ticket" color="amber" />
<KpiCard label="Pending Invoices":value="data?.pending_invoices ?? 0" icon="Receipt" color="red" />
</div>
<!-- License info -->
<div v-if="data?.license" class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
<h2 class="text-base font-semibold text-slate-900 mb-4">License Status</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div><p class="text-slate-500">Status</p><StatusBadge :status="data.license.status" /></div>
<div><p class="text-slate-500">Expires</p><p class="font-medium">{{ data.license.expires_at ? new Date(data.license.expires_at).toLocaleDateString() : 'Never' }}</p></div>
<div><p class="text-slate-500">Last Online</p><p class="font-medium">{{ data.license.last_seen ? new Date(data.license.last_seen).toLocaleString() : '—' }}</p></div>
<div><p class="text-slate-500">License Key</p><p class="font-mono text-xs truncate">{{ data.license.key }}</p></div>
</div>
</div>
<!-- Announcements -->
<div v-if="announcements.length > 0" class="bg-blue-50 border border-blue-200 rounded-xl p-5">
<h3 class="text-sm font-semibold text-blue-800 mb-2">Announcements</h3>
<div v-for="a in announcements" :key="a.id" class="mb-2">
<p class="text-sm font-medium text-blue-900">{{ a.title }}</p>
<p class="text-xs text-blue-700">{{ a.body }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getPortalOverview, getAnnouncements } from '@/lib/api'
import KpiCard from '@/components/ui/KpiCard.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
const data = ref<any>(null)
const announcements = ref<any[]>([])
const loading = ref(true)
onMounted(async () => {
try { [data.value, announcements.value] = await Promise.all([getPortalOverview(), getAnnouncements()]) }
finally { loading.value = false }
})
</script>

View File

@@ -0,0 +1,64 @@
<template>
<div class="space-y-6 max-w-md">
<h1 class="text-2xl font-bold text-slate-900">Profile</h1>
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
<div class="flex items-center gap-4 mb-6">
<div class="w-14 h-14 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white text-lg font-bold">
{{ initials }}
</div>
<div>
<p class="font-semibold text-slate-900">{{ authStore.fullName }}</p>
<p class="text-sm text-slate-500">School Admin</p>
</div>
</div>
<h2 class="text-sm font-semibold text-slate-900 mb-4">Change Password</h2>
<form @submit.prevent="submitPw" class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Current Password</label>
<input v-model="pwForm.current" type="password" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">New Password</label>
<input v-model="pwForm.newPw" type="password" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Confirm New Password</label>
<input v-model="pwForm.confirm" type="password" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<p v-if="pwError" class="text-sm text-red-600">{{ pwError }}</p>
<button type="submit" :disabled="saving || !pwForm.current || !pwForm.newPw || !pwForm.confirm"
class="w-full py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50">
{{ saving ? 'Saving…' : 'Update Password' }}
</button>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { changePassword } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
const authStore = useAuthStore()
const toast = useToast()
const initials = computed(() => (authStore.fullName ?? '').split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase())
const pwForm = ref({ current: '', newPw: '', confirm: '' })
const pwError = ref('')
const saving = ref(false)
async function submitPw() {
pwError.value = ''
if (pwForm.value.newPw.length < 8) { pwError.value = 'Minimum 8 characters'; return }
if (pwForm.value.newPw !== pwForm.value.confirm) { pwError.value = 'Passwords do not match'; return }
saving.value = true
try {
await changePassword(pwForm.value.current, pwForm.value.newPw)
toast.success('Password updated')
pwForm.value = { current: '', newPw: '', confirm: '' }
} catch (e: any) {
pwError.value = e?.response?.data?.detail ?? 'Failed to update password'
} finally { saving.value = false }
}
</script>

View File

@@ -0,0 +1,38 @@
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-900">SMS Reports</h1>
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
<div v-if="loading" class="p-8 text-center text-sm text-slate-400 animate-pulse">Loading</div>
<div v-else-if="jobs.length === 0" class="p-12 text-center text-slate-400">No SMS records</div>
<table v-else class="w-full text-sm">
<thead class="bg-slate-50 border-b border-slate-100">
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
<th class="px-5 py-3">Recipient</th>
<th class="px-5 py-3">Message</th>
<th class="px-5 py-3">Status</th>
<th class="px-5 py-3">Trigger</th>
<th class="px-5 py-3">Sent At</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
<tr v-for="j in jobs" :key="j.id" class="hover:bg-slate-50">
<td class="px-5 py-3 font-mono text-xs">{{ j.recipient_phone }}</td>
<td class="px-5 py-3 text-slate-600 max-w-xs truncate">{{ j.message }}</td>
<td class="px-5 py-3"><StatusBadge :status="j.status" /></td>
<td class="px-5 py-3 text-xs text-slate-500 capitalize">{{ j.trigger || '—' }}</td>
<td class="px-5 py-3 text-xs text-slate-500">{{ j.sent_at ? new Date(j.sent_at).toLocaleString() : '—' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getSmsJobs } from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue'
const jobs = ref<any[]>([])
const loading = ref(true)
onMounted(async () => { try { const r = await getSmsJobs(); jobs.value = r.items } finally { loading.value = false } })
</script>

View File

@@ -0,0 +1,102 @@
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-slate-900">Support Tickets</h1>
<button @click="showCreate = true"
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700">
<Plus :size="16" /> New Ticket
</button>
</div>
<!-- Create form -->
<div v-if="showCreate" class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
<h2 class="text-base font-semibold text-slate-900 mb-4">Submit a Ticket</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Subject</label>
<input v-model="form.subject" type="text" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Category</label>
<select v-model="form.category" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm">
<option value="general">General</option>
<option value="billing">Billing</option>
<option value="technical">Technical</option>
<option value="sms">SMS</option>
<option value="license">License</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Message</label>
<textarea v-model="form.body" rows="4" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"></textarea>
</div>
<div class="flex gap-3">
<button @click="showCreate = false" class="px-4 py-2 border border-slate-200 rounded-lg text-sm text-slate-700 hover:bg-slate-50">Cancel</button>
<button @click="submitTicket" :disabled="!form.subject || !form.body || submitting"
class="px-5 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50">
{{ submitting ? 'Submitting…' : 'Submit Ticket' }}
</button>
</div>
</div>
</div>
<!-- List -->
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
<div v-if="loading" class="p-8 text-center text-sm animate-pulse text-slate-400">Loading</div>
<div v-else-if="tickets.length === 0" class="p-12 text-center text-slate-400">No tickets yet</div>
<table v-else class="w-full text-sm">
<thead class="bg-slate-50 border-b border-slate-100">
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
<th class="px-5 py-3">Ticket #</th>
<th class="px-5 py-3">Subject</th>
<th class="px-5 py-3">Category</th>
<th class="px-5 py-3">Status</th>
<th class="px-5 py-3">Date</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
<tr v-for="t in tickets" :key="t.id" class="hover:bg-slate-50 cursor-pointer" @click="$router.push(`/tickets/${t.id}`)">
<td class="px-5 py-3 font-mono text-xs font-semibold text-blue-600">{{ t.ticket_number }}</td>
<td class="px-5 py-3 font-medium text-slate-900 max-w-xs truncate">{{ t.subject }}</td>
<td class="px-5 py-3 capitalize text-xs text-slate-600">{{ t.category }}</td>
<td class="px-5 py-3"><StatusBadge :status="t.status" /></td>
<td class="px-5 py-3 text-xs text-slate-500">{{ new Date(t.created_at).toLocaleDateString() }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Plus } from 'lucide-vue-next'
import { getTickets, createTicket } from '@/lib/api'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { useToast } from '@/composables/useToast'
const toast = useToast()
const tickets = ref<any[]>([])
const loading = ref(false)
const showCreate = ref(false)
const submitting = ref(false)
const form = ref({ subject: '', body: '', category: 'general' })
async function fetchTickets() {
loading.value = true
try { const r = await getTickets(); tickets.value = r.items }
finally { loading.value = false }
}
async function submitTicket() {
submitting.value = true
try {
await createTicket(form.value)
toast.success('Ticket submitted')
showCreate.value = false
form.value = { subject: '', body: '', category: 'general' }
await fetchTickets()
} catch { toast.error('Failed to submit ticket') }
finally { submitting.value = false }
}
onMounted(fetchTickets)
</script>

Some files were not shown because too many files have changed in this diff Show More