From 73a17aaf9ade644630ab10958ca9873c1df4a3a0 Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Mon, 16 Mar 2026 07:26:06 +0800 Subject: [PATCH] feat(phase-1): TapTrack Hub initial scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 11 + .gitignore | 18 + .paul/ROADMAP.md | 339 ++++++++++++++++++ .paul/STATE.md | 64 ++++ .paul/phases/01-setup/01-PLAN.md | 89 +++++ .paul/phases/02-school-registry/README.md | 20 ++ .paul/phases/03-onprem-license/README.md | 9 + .paul/phases/04-sms-gateway/README.md | 9 + .paul/phases/05-onprem-sms-agent/README.md | 9 + .../phases/06-super-admin-dashboard/README.md | 9 + .paul/phases/07-school-portal/README.md | 9 + .paul/phases/08-billing-engine/README.md | 0 .paul/phases/09-email-dispatcher/README.md | 0 .paul/phases/10-support-tickets/README.md | 9 + .paul/phases/11-monthly-reports/README.md | 9 + .paul/phases/12-onprem-report-pull/README.md | 9 + .paul/phases/13-feature-flags/README.md | 9 + .paul/phases/14-onboarding-wizard/README.md | 9 + .paul/phases/15-ux-polish/README.md | 9 + backend/Dockerfile | 19 + backend/alembic.ini | 38 ++ backend/app/__init__.py | 0 backend/app/auth/__init__.py | 0 backend/app/auth/dependencies.py | 41 +++ backend/app/auth/jwt.py | 12 + backend/app/auth/password.py | 9 + backend/app/config.py | 25 ++ backend/app/database.py | 20 ++ backend/app/main.py | 50 +++ backend/app/models/__init__.py | 0 backend/app/models/announcement.py | 16 + backend/app/models/audit.py | 21 ++ backend/app/models/billing.py | 68 ++++ backend/app/models/license.py | 40 +++ backend/app/models/school.py | 45 +++ backend/app/models/sms.py | 50 +++ backend/app/models/ticket.py | 58 +++ backend/app/models/user.py | 25 ++ backend/app/routers/__init__.py | 0 backend/app/routers/announcements.py | 54 +++ backend/app/routers/auth.py | 73 ++++ backend/app/routers/billing.py | 181 ++++++++++ backend/app/routers/dashboard.py | 53 +++ backend/app/routers/licenses.py | 116 ++++++ backend/app/routers/school_portal.py | 63 ++++ backend/app/routers/schools.py | 181 ++++++++++ backend/app/routers/sms.py | 109 ++++++ backend/app/routers/sync.py | 77 ++++ backend/app/routers/tickets.py | 149 ++++++++ backend/app/routers/users.py | 78 ++++ backend/app/services/__init__.py | 0 backend/app/services/email.py | 32 ++ backend/app/tasks/__init__.py | 0 backend/app/tasks/billing.py | 125 +++++++ backend/app/tasks/license.py | 41 +++ backend/app/tasks/reports.py | 35 ++ backend/app/tasks/sms.py | 102 ++++++ backend/app/worker.py | 55 +++ backend/migrations/env.py | 40 +++ backend/migrations/script.py.mako | 26 ++ backend/requirements.txt | 18 + backend/seed.py | 37 ++ docker-compose.yml | 115 ++++++ frontend/Dockerfile | 12 + frontend/index.html | 14 + frontend/nginx.conf | 10 + frontend/package.json | 26 ++ frontend/postcss.config.js | 3 + frontend/src/App.vue | 26 ++ frontend/src/assets/main.css | 10 + .../src/components/sidebar/AppSidebar.vue | 37 ++ .../src/components/sidebar/PortalSidebar.vue | 31 ++ .../src/components/sidebar/SidebarItem.vue | 21 ++ frontend/src/components/ui/KpiCard.vue | 31 ++ frontend/src/components/ui/StatusBadge.vue | 26 ++ frontend/src/components/ui/ToastStack.vue | 36 ++ frontend/src/composables/useToast.ts | 22 ++ frontend/src/layouts/AppLayout.vue | 50 +++ frontend/src/layouts/AuthLayout.vue | 5 + frontend/src/layouts/PortalLayout.vue | 43 +++ frontend/src/lib/api.ts | 90 +++++ frontend/src/main.ts | 10 + frontend/src/pages/AnnouncementsPage.vue | 46 +++ frontend/src/pages/BillingPage.vue | 78 ++++ frontend/src/pages/DashboardPage.vue | 57 +++ frontend/src/pages/LicensesPage.vue | 41 +++ frontend/src/pages/LoginPage.vue | 60 ++++ frontend/src/pages/NotFoundPage.vue | 9 + frontend/src/pages/SchoolDetailPage.vue | 76 ++++ frontend/src/pages/SchoolsPage.vue | 120 +++++++ frontend/src/pages/SmsPage.vue | 56 +++ frontend/src/pages/TicketDetailPage.vue | 88 +++++ frontend/src/pages/TicketsPage.vue | 74 ++++ frontend/src/pages/UsersPage.vue | 55 +++ frontend/src/pages/portal/.gitkeep | 0 .../src/pages/portal/PortalBillingPage.vue | 38 ++ .../src/pages/portal/PortalOverviewPage.vue | 49 +++ .../src/pages/portal/PortalProfilePage.vue | 64 ++++ frontend/src/pages/portal/PortalSmsPage.vue | 38 ++ .../src/pages/portal/PortalTicketsPage.vue | 102 ++++++ frontend/src/router/index.ts | 125 +++++++ frontend/src/stores/auth.ts | 50 +++ frontend/tailwind.config.ts | 15 + frontend/tsconfig.json | 22 ++ frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 17 + nginx/nginx.conf | 34 ++ 107 files changed, 4764 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .paul/ROADMAP.md create mode 100644 .paul/STATE.md create mode 100644 .paul/phases/01-setup/01-PLAN.md create mode 100644 .paul/phases/02-school-registry/README.md create mode 100644 .paul/phases/03-onprem-license/README.md create mode 100644 .paul/phases/04-sms-gateway/README.md create mode 100644 .paul/phases/05-onprem-sms-agent/README.md create mode 100644 .paul/phases/06-super-admin-dashboard/README.md create mode 100644 .paul/phases/07-school-portal/README.md create mode 100644 .paul/phases/08-billing-engine/README.md create mode 100644 .paul/phases/09-email-dispatcher/README.md create mode 100644 .paul/phases/10-support-tickets/README.md create mode 100644 .paul/phases/11-monthly-reports/README.md create mode 100644 .paul/phases/12-onprem-report-pull/README.md create mode 100644 .paul/phases/13-feature-flags/README.md create mode 100644 .paul/phases/14-onboarding-wizard/README.md create mode 100644 .paul/phases/15-ux-polish/README.md create mode 100644 backend/Dockerfile create mode 100644 backend/alembic.ini create mode 100644 backend/app/__init__.py create mode 100644 backend/app/auth/__init__.py create mode 100644 backend/app/auth/dependencies.py create mode 100644 backend/app/auth/jwt.py create mode 100644 backend/app/auth/password.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/announcement.py create mode 100644 backend/app/models/audit.py create mode 100644 backend/app/models/billing.py create mode 100644 backend/app/models/license.py create mode 100644 backend/app/models/school.py create mode 100644 backend/app/models/sms.py create mode 100644 backend/app/models/ticket.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/announcements.py create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/routers/billing.py create mode 100644 backend/app/routers/dashboard.py create mode 100644 backend/app/routers/licenses.py create mode 100644 backend/app/routers/school_portal.py create mode 100644 backend/app/routers/schools.py create mode 100644 backend/app/routers/sms.py create mode 100644 backend/app/routers/sync.py create mode 100644 backend/app/routers/tickets.py create mode 100644 backend/app/routers/users.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/email.py create mode 100644 backend/app/tasks/__init__.py create mode 100644 backend/app/tasks/billing.py create mode 100644 backend/app/tasks/license.py create mode 100644 backend/app/tasks/reports.py create mode 100644 backend/app/tasks/sms.py create mode 100644 backend/app/worker.py create mode 100644 backend/migrations/env.py create mode 100644 backend/migrations/script.py.mako create mode 100644 backend/requirements.txt create mode 100644 backend/seed.py create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/assets/main.css create mode 100644 frontend/src/components/sidebar/AppSidebar.vue create mode 100644 frontend/src/components/sidebar/PortalSidebar.vue create mode 100644 frontend/src/components/sidebar/SidebarItem.vue create mode 100644 frontend/src/components/ui/KpiCard.vue create mode 100644 frontend/src/components/ui/StatusBadge.vue create mode 100644 frontend/src/components/ui/ToastStack.vue create mode 100644 frontend/src/composables/useToast.ts create mode 100644 frontend/src/layouts/AppLayout.vue create mode 100644 frontend/src/layouts/AuthLayout.vue create mode 100644 frontend/src/layouts/PortalLayout.vue create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/pages/AnnouncementsPage.vue create mode 100644 frontend/src/pages/BillingPage.vue create mode 100644 frontend/src/pages/DashboardPage.vue create mode 100644 frontend/src/pages/LicensesPage.vue create mode 100644 frontend/src/pages/LoginPage.vue create mode 100644 frontend/src/pages/NotFoundPage.vue create mode 100644 frontend/src/pages/SchoolDetailPage.vue create mode 100644 frontend/src/pages/SchoolsPage.vue create mode 100644 frontend/src/pages/SmsPage.vue create mode 100644 frontend/src/pages/TicketDetailPage.vue create mode 100644 frontend/src/pages/TicketsPage.vue create mode 100644 frontend/src/pages/UsersPage.vue create mode 100644 frontend/src/pages/portal/.gitkeep create mode 100644 frontend/src/pages/portal/PortalBillingPage.vue create mode 100644 frontend/src/pages/portal/PortalOverviewPage.vue create mode 100644 frontend/src/pages/portal/PortalProfilePage.vue create mode 100644 frontend/src/pages/portal/PortalSmsPage.vue create mode 100644 frontend/src/pages/portal/PortalTicketsPage.vue create mode 100644 frontend/src/router/index.ts create mode 100644 frontend/src/stores/auth.ts create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 nginx/nginx.conf diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..86d30dc --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7184177 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md new file mode 100644 index 0000000..12c2a3b --- /dev/null +++ b/.paul/ROADMAP.md @@ -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 + +--- diff --git a/.paul/STATE.md b/.paul/STATE.md new file mode 100644 index 0000000..4e1090e --- /dev/null +++ b/.paul/STATE.md @@ -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 diff --git a/.paul/phases/01-setup/01-PLAN.md b/.paul/phases/01-setup/01-PLAN.md new file mode 100644 index 0000000..7c34134 --- /dev/null +++ b/.paul/phases/01-setup/01-PLAN.md @@ -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 diff --git a/.paul/phases/02-school-registry/README.md b/.paul/phases/02-school-registry/README.md new file mode 100644 index 0000000..4fc85f5 --- /dev/null +++ b/.paul/phases/02-school-registry/README.md @@ -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 diff --git a/.paul/phases/03-onprem-license/README.md b/.paul/phases/03-onprem-license/README.md new file mode 100644 index 0000000..2b9dc3d --- /dev/null +++ b/.paul/phases/03-onprem-license/README.md @@ -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 diff --git a/.paul/phases/04-sms-gateway/README.md b/.paul/phases/04-sms-gateway/README.md new file mode 100644 index 0000000..dc78f6e --- /dev/null +++ b/.paul/phases/04-sms-gateway/README.md @@ -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 diff --git a/.paul/phases/05-onprem-sms-agent/README.md b/.paul/phases/05-onprem-sms-agent/README.md new file mode 100644 index 0000000..94d7ae0 --- /dev/null +++ b/.paul/phases/05-onprem-sms-agent/README.md @@ -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 diff --git a/.paul/phases/06-super-admin-dashboard/README.md b/.paul/phases/06-super-admin-dashboard/README.md new file mode 100644 index 0000000..af0a277 --- /dev/null +++ b/.paul/phases/06-super-admin-dashboard/README.md @@ -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 diff --git a/.paul/phases/07-school-portal/README.md b/.paul/phases/07-school-portal/README.md new file mode 100644 index 0000000..efd1d7b --- /dev/null +++ b/.paul/phases/07-school-portal/README.md @@ -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 diff --git a/.paul/phases/08-billing-engine/README.md b/.paul/phases/08-billing-engine/README.md new file mode 100644 index 0000000..e69de29 diff --git a/.paul/phases/09-email-dispatcher/README.md b/.paul/phases/09-email-dispatcher/README.md new file mode 100644 index 0000000..e69de29 diff --git a/.paul/phases/10-support-tickets/README.md b/.paul/phases/10-support-tickets/README.md new file mode 100644 index 0000000..d4205c0 --- /dev/null +++ b/.paul/phases/10-support-tickets/README.md @@ -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 diff --git a/.paul/phases/11-monthly-reports/README.md b/.paul/phases/11-monthly-reports/README.md new file mode 100644 index 0000000..18306db --- /dev/null +++ b/.paul/phases/11-monthly-reports/README.md @@ -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 diff --git a/.paul/phases/12-onprem-report-pull/README.md b/.paul/phases/12-onprem-report-pull/README.md new file mode 100644 index 0000000..415a19a --- /dev/null +++ b/.paul/phases/12-onprem-report-pull/README.md @@ -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 diff --git a/.paul/phases/13-feature-flags/README.md b/.paul/phases/13-feature-flags/README.md new file mode 100644 index 0000000..687ed9b --- /dev/null +++ b/.paul/phases/13-feature-flags/README.md @@ -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 diff --git a/.paul/phases/14-onboarding-wizard/README.md b/.paul/phases/14-onboarding-wizard/README.md new file mode 100644 index 0000000..8396f5f --- /dev/null +++ b/.paul/phases/14-onboarding-wizard/README.md @@ -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 diff --git a/.paul/phases/15-ux-polish/README.md b/.paul/phases/15-ux-polish/README.md new file mode 100644 index 0000000..0c00616 --- /dev/null +++ b/.paul/phases/15-ux-polish/README.md @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..468edb9 --- /dev/null +++ b/backend/Dockerfile @@ -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", "-"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..64cc422 --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth/dependencies.py b/backend/app/auth/dependencies.py new file mode 100644 index 0000000..e8eb19a --- /dev/null +++ b/backend/app/auth/dependencies.py @@ -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 diff --git a/backend/app/auth/jwt.py b/backend/app/auth/jwt.py new file mode 100644 index 0000000..f6e5433 --- /dev/null +++ b/backend/app/auth/jwt.py @@ -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]) diff --git a/backend/app/auth/password.py b/backend/app/auth/password.py new file mode 100644 index 0000000..2577467 --- /dev/null +++ b/backend/app/auth/password.py @@ -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) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..10c33b4 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..3afc272 --- /dev/null +++ b/backend/app/database.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..8230751 --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/announcement.py b/backend/app/models/announcement.py new file mode 100644 index 0000000..e069c4c --- /dev/null +++ b/backend/app/models/announcement.py @@ -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) diff --git a/backend/app/models/audit.py b/backend/app/models/audit.py new file mode 100644 index 0000000..ee08894 --- /dev/null +++ b/backend/app/models/audit.py @@ -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") diff --git a/backend/app/models/billing.py b/backend/app/models/billing.py new file mode 100644 index 0000000..9d936a3 --- /dev/null +++ b/backend/app/models/billing.py @@ -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)) diff --git a/backend/app/models/license.py b/backend/app/models/license.py new file mode 100644 index 0000000..04c8940 --- /dev/null +++ b/backend/app/models/license.py @@ -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") diff --git a/backend/app/models/school.py b/backend/app/models/school.py new file mode 100644 index 0000000..586becf --- /dev/null +++ b/backend/app/models/school.py @@ -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") diff --git a/backend/app/models/sms.py b/backend/app/models/sms.py new file mode 100644 index 0000000..ab55b41 --- /dev/null +++ b/backend/app/models/sms.py @@ -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 diff --git a/backend/app/models/ticket.py b/backend/app/models/ticket.py new file mode 100644 index 0000000..3ff7430 --- /dev/null +++ b/backend/app/models/ticket.py @@ -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") diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..a2fbcf8 --- /dev/null +++ b/backend/app/models/user.py @@ -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") diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/announcements.py b/backend/app/routers/announcements.py new file mode 100644 index 0000000..4548e02 --- /dev/null +++ b/backend/app/routers/announcements.py @@ -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() diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..14c5740 --- /dev/null +++ b/backend/app/routers/auth.py @@ -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() diff --git a/backend/app/routers/billing.py b/backend/app/routers/billing.py new file mode 100644 index 0000000..25795af --- /dev/null +++ b/backend/app/routers/billing.py @@ -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} diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py new file mode 100644 index 0000000..a5f6c10 --- /dev/null +++ b/backend/app/routers/dashboard.py @@ -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}, + } diff --git a/backend/app/routers/licenses.py b/backend/app/routers/licenses.py new file mode 100644 index 0000000..6da5086 --- /dev/null +++ b/backend/app/routers/licenses.py @@ -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 diff --git a/backend/app/routers/school_portal.py b/backend/app/routers/school_portal.py new file mode 100644 index 0000000..6c1bc83 --- /dev/null +++ b/backend/app/routers/school_portal.py @@ -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, + } diff --git a/backend/app/routers/schools.py b/backend/app/routers/schools.py new file mode 100644 index 0000000..c590e5a --- /dev/null +++ b/backend/app/routers/schools.py @@ -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} diff --git a/backend/app/routers/sms.py b/backend/app/routers/sms.py new file mode 100644 index 0000000..fbab802 --- /dev/null +++ b/backend/app/routers/sms.py @@ -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, + } diff --git a/backend/app/routers/sync.py b/backend/app/routers/sync.py new file mode 100644 index 0000000..f394b95 --- /dev/null +++ b/backend/app/routers/sync.py @@ -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, + }, + } diff --git a/backend/app/routers/tickets.py b/backend/app/routers/tickets.py new file mode 100644 index 0000000..ee275cd --- /dev/null +++ b/backend/app/routers/tickets.py @@ -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()} diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..68017de --- /dev/null +++ b/backend/app/routers/users.py @@ -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} diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/email.py b/backend/app/services/email.py new file mode 100644 index 0000000..42c4c1c --- /dev/null +++ b/backend/app/services/email.py @@ -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 diff --git a/backend/app/tasks/__init__.py b/backend/app/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/tasks/billing.py b/backend/app/tasks/billing.py new file mode 100644 index 0000000..432817b --- /dev/null +++ b/backend/app/tasks/billing.py @@ -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() diff --git a/backend/app/tasks/license.py b/backend/app/tasks/license.py new file mode 100644 index 0000000..6f5eee7 --- /dev/null +++ b/backend/app/tasks/license.py @@ -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() diff --git a/backend/app/tasks/reports.py b/backend/app/tasks/reports.py new file mode 100644 index 0000000..7b09fe4 --- /dev/null +++ b/backend/app/tasks/reports.py @@ -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() diff --git a/backend/app/tasks/sms.py b/backend/app/tasks/sms.py new file mode 100644 index 0000000..1d11864 --- /dev/null +++ b/backend/app/tasks/sms.py @@ -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() diff --git a/backend/app/worker.py b/backend/app/worker.py new file mode 100644 index 0000000..0afbcea --- /dev/null +++ b/backend/app/worker.py @@ -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 diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..49f564b --- /dev/null +++ b/backend/migrations/env.py @@ -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() diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/migrations/script.py.mako @@ -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"} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..c075061 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/seed.py b/backend/seed.py new file mode 100644 index 0000000..7d6aa63 --- /dev/null +++ b/backend/seed.py @@ -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()) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..30f17dd --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..f973c69 --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..806a75b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + TapTrack Hub + + + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..306f56b --- /dev/null +++ b/frontend/nginx.conf @@ -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"; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6abc101 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..be56e0e --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,3 @@ +export default { + plugins: { tailwindcss: {}, autoprefixer: {} }, +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..d30b089 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,26 @@ + + + diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css new file mode 100644 index 0000000..9935772 --- /dev/null +++ b/frontend/src/assets/main.css @@ -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; } diff --git a/frontend/src/components/sidebar/AppSidebar.vue b/frontend/src/components/sidebar/AppSidebar.vue new file mode 100644 index 0000000..bcfcdc5 --- /dev/null +++ b/frontend/src/components/sidebar/AppSidebar.vue @@ -0,0 +1,37 @@ + + + diff --git a/frontend/src/components/sidebar/PortalSidebar.vue b/frontend/src/components/sidebar/PortalSidebar.vue new file mode 100644 index 0000000..ba11b78 --- /dev/null +++ b/frontend/src/components/sidebar/PortalSidebar.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/components/sidebar/SidebarItem.vue b/frontend/src/components/sidebar/SidebarItem.vue new file mode 100644 index 0000000..b47a2ad --- /dev/null +++ b/frontend/src/components/sidebar/SidebarItem.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/ui/KpiCard.vue b/frontend/src/components/ui/KpiCard.vue new file mode 100644 index 0000000..1b1e554 --- /dev/null +++ b/frontend/src/components/ui/KpiCard.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/components/ui/StatusBadge.vue b/frontend/src/components/ui/StatusBadge.vue new file mode 100644 index 0000000..c52990d --- /dev/null +++ b/frontend/src/components/ui/StatusBadge.vue @@ -0,0 +1,26 @@ + + + diff --git a/frontend/src/components/ui/ToastStack.vue b/frontend/src/components/ui/ToastStack.vue new file mode 100644 index 0000000..11acab7 --- /dev/null +++ b/frontend/src/components/ui/ToastStack.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/frontend/src/composables/useToast.ts b/frontend/src/composables/useToast.ts new file mode 100644 index 0000000..1bf2bdf --- /dev/null +++ b/frontend/src/composables/useToast.ts @@ -0,0 +1,22 @@ +import { ref } from 'vue' + +interface Toast { id: number; type: 'success' | 'error' | 'info' | 'warning'; message: string } + +const toasts = ref([]) +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 } diff --git a/frontend/src/layouts/AppLayout.vue b/frontend/src/layouts/AppLayout.vue new file mode 100644 index 0000000..22f1fd4 --- /dev/null +++ b/frontend/src/layouts/AppLayout.vue @@ -0,0 +1,50 @@ + + + diff --git a/frontend/src/layouts/AuthLayout.vue b/frontend/src/layouts/AuthLayout.vue new file mode 100644 index 0000000..e1417ae --- /dev/null +++ b/frontend/src/layouts/AuthLayout.vue @@ -0,0 +1,5 @@ + diff --git a/frontend/src/layouts/PortalLayout.vue b/frontend/src/layouts/PortalLayout.vue new file mode 100644 index 0000000..4e6b82f --- /dev/null +++ b/frontend/src/layouts/PortalLayout.vue @@ -0,0 +1,43 @@ + + + diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..7156c0b --- /dev/null +++ b/frontend/src/lib/api.ts @@ -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) diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..77b04e9 --- /dev/null +++ b/frontend/src/main.ts @@ -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') diff --git a/frontend/src/pages/AnnouncementsPage.vue b/frontend/src/pages/AnnouncementsPage.vue new file mode 100644 index 0000000..a24010f --- /dev/null +++ b/frontend/src/pages/AnnouncementsPage.vue @@ -0,0 +1,46 @@ + + + diff --git a/frontend/src/pages/BillingPage.vue b/frontend/src/pages/BillingPage.vue new file mode 100644 index 0000000..4ed628a --- /dev/null +++ b/frontend/src/pages/BillingPage.vue @@ -0,0 +1,78 @@ + + + diff --git a/frontend/src/pages/DashboardPage.vue b/frontend/src/pages/DashboardPage.vue new file mode 100644 index 0000000..050a4a0 --- /dev/null +++ b/frontend/src/pages/DashboardPage.vue @@ -0,0 +1,57 @@ + + + diff --git a/frontend/src/pages/LicensesPage.vue b/frontend/src/pages/LicensesPage.vue new file mode 100644 index 0000000..3389b4d --- /dev/null +++ b/frontend/src/pages/LicensesPage.vue @@ -0,0 +1,41 @@ + + + diff --git a/frontend/src/pages/LoginPage.vue b/frontend/src/pages/LoginPage.vue new file mode 100644 index 0000000..ee077bd --- /dev/null +++ b/frontend/src/pages/LoginPage.vue @@ -0,0 +1,60 @@ + + + diff --git a/frontend/src/pages/NotFoundPage.vue b/frontend/src/pages/NotFoundPage.vue new file mode 100644 index 0000000..116484b --- /dev/null +++ b/frontend/src/pages/NotFoundPage.vue @@ -0,0 +1,9 @@ + diff --git a/frontend/src/pages/SchoolDetailPage.vue b/frontend/src/pages/SchoolDetailPage.vue new file mode 100644 index 0000000..67e0876 --- /dev/null +++ b/frontend/src/pages/SchoolDetailPage.vue @@ -0,0 +1,76 @@ + + + diff --git a/frontend/src/pages/SchoolsPage.vue b/frontend/src/pages/SchoolsPage.vue new file mode 100644 index 0000000..c55b425 --- /dev/null +++ b/frontend/src/pages/SchoolsPage.vue @@ -0,0 +1,120 @@ + + + diff --git a/frontend/src/pages/SmsPage.vue b/frontend/src/pages/SmsPage.vue new file mode 100644 index 0000000..c8647d4 --- /dev/null +++ b/frontend/src/pages/SmsPage.vue @@ -0,0 +1,56 @@ + + + diff --git a/frontend/src/pages/TicketDetailPage.vue b/frontend/src/pages/TicketDetailPage.vue new file mode 100644 index 0000000..3e652ed --- /dev/null +++ b/frontend/src/pages/TicketDetailPage.vue @@ -0,0 +1,88 @@ + + + diff --git a/frontend/src/pages/TicketsPage.vue b/frontend/src/pages/TicketsPage.vue new file mode 100644 index 0000000..8bdde0d --- /dev/null +++ b/frontend/src/pages/TicketsPage.vue @@ -0,0 +1,74 @@ + + + diff --git a/frontend/src/pages/UsersPage.vue b/frontend/src/pages/UsersPage.vue new file mode 100644 index 0000000..79227c9 --- /dev/null +++ b/frontend/src/pages/UsersPage.vue @@ -0,0 +1,55 @@ + + + diff --git a/frontend/src/pages/portal/.gitkeep b/frontend/src/pages/portal/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/pages/portal/PortalBillingPage.vue b/frontend/src/pages/portal/PortalBillingPage.vue new file mode 100644 index 0000000..854c2b6 --- /dev/null +++ b/frontend/src/pages/portal/PortalBillingPage.vue @@ -0,0 +1,38 @@ + + + diff --git a/frontend/src/pages/portal/PortalOverviewPage.vue b/frontend/src/pages/portal/PortalOverviewPage.vue new file mode 100644 index 0000000..b0a05c3 --- /dev/null +++ b/frontend/src/pages/portal/PortalOverviewPage.vue @@ -0,0 +1,49 @@ + + + diff --git a/frontend/src/pages/portal/PortalProfilePage.vue b/frontend/src/pages/portal/PortalProfilePage.vue new file mode 100644 index 0000000..8d77d52 --- /dev/null +++ b/frontend/src/pages/portal/PortalProfilePage.vue @@ -0,0 +1,64 @@ + + + diff --git a/frontend/src/pages/portal/PortalSmsPage.vue b/frontend/src/pages/portal/PortalSmsPage.vue new file mode 100644 index 0000000..c946c78 --- /dev/null +++ b/frontend/src/pages/portal/PortalSmsPage.vue @@ -0,0 +1,38 @@ + + + diff --git a/frontend/src/pages/portal/PortalTicketsPage.vue b/frontend/src/pages/portal/PortalTicketsPage.vue new file mode 100644 index 0000000..c829c1b --- /dev/null +++ b/frontend/src/pages/portal/PortalTicketsPage.vue @@ -0,0 +1,102 @@ + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..4c1894a --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,125 @@ +import { createRouter, createWebHistory } from 'vue-router' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', redirect: '/dashboard' }, + { + path: '/login', + name: 'login', + component: () => import('@/pages/LoginPage.vue'), + meta: { requiresAuth: false }, + }, + // Super Admin routes + { + path: '/dashboard', + name: 'dashboard', + component: () => import('@/pages/DashboardPage.vue'), + meta: { requiresAuth: true, layout: 'app', role: 'super_admin' }, + }, + { + path: '/schools', + name: 'schools', + component: () => import('@/pages/SchoolsPage.vue'), + meta: { requiresAuth: true, layout: 'app', role: 'super_admin' }, + }, + { + path: '/schools/:id', + name: 'school-detail', + component: () => import('@/pages/SchoolDetailPage.vue'), + meta: { requiresAuth: true, layout: 'app', role: 'super_admin' }, + }, + { + path: '/licenses', + name: 'licenses', + component: () => import('@/pages/LicensesPage.vue'), + meta: { requiresAuth: true, layout: 'app', role: 'super_admin' }, + }, + { + path: '/sms', + name: 'sms', + component: () => import('@/pages/SmsPage.vue'), + meta: { requiresAuth: true, layout: 'app', role: 'super_admin' }, + }, + { + path: '/billing', + name: 'billing', + component: () => import('@/pages/BillingPage.vue'), + meta: { requiresAuth: true, layout: 'app', role: 'super_admin' }, + }, + { + path: '/tickets', + name: 'tickets', + component: () => import('@/pages/TicketsPage.vue'), + meta: { requiresAuth: true, layout: 'app' }, + }, + { + path: '/tickets/:id', + name: 'ticket-detail', + component: () => import('@/pages/TicketDetailPage.vue'), + meta: { requiresAuth: true, layout: 'app' }, + }, + { + path: '/users', + name: 'users', + component: () => import('@/pages/UsersPage.vue'), + meta: { requiresAuth: true, layout: 'app', role: 'super_admin' }, + }, + { + path: '/announcements', + name: 'announcements', + component: () => import('@/pages/AnnouncementsPage.vue'), + meta: { requiresAuth: true, layout: 'app', role: 'super_admin' }, + }, + // School Portal routes + { + path: '/portal', + name: 'portal', + component: () => import('@/pages/portal/PortalOverviewPage.vue'), + meta: { requiresAuth: true, layout: 'portal' }, + }, + { + path: '/portal/billing', + name: 'portal-billing', + component: () => import('@/pages/portal/PortalBillingPage.vue'), + meta: { requiresAuth: true, layout: 'portal' }, + }, + { + path: '/portal/sms', + name: 'portal-sms', + component: () => import('@/pages/portal/PortalSmsPage.vue'), + meta: { requiresAuth: true, layout: 'portal' }, + }, + { + path: '/portal/tickets', + name: 'portal-tickets', + component: () => import('@/pages/portal/PortalTicketsPage.vue'), + meta: { requiresAuth: true, layout: 'portal' }, + }, + { + path: '/portal/profile', + name: 'portal-profile', + component: () => import('@/pages/portal/PortalProfilePage.vue'), + meta: { requiresAuth: true, layout: 'portal' }, + }, + { + path: '/:pathMatch(.*)*', + name: 'not-found', + component: () => import('@/pages/NotFoundPage.vue'), + }, + ], +}) + +router.beforeEach((to, _from, next) => { + const token = localStorage.getItem('hub_token') + const role = localStorage.getItem('hub_role') + const requiresAuth = to.meta.requiresAuth !== false + + if (requiresAuth && !token) return next('/login') + if (to.name === 'login' && token) { + return next(role === 'super_admin' ? '/dashboard' : '/portal') + } + next() +}) + +export default router diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..1308006 --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,50 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +export const useAuthStore = defineStore('auth', () => { + const token = ref(null) + const role = ref(null) + const userId = ref(null) + const fullName = ref(null) + const schoolId = ref(null) + + const isAuthenticated = computed(() => !!token.value) + const isSuperAdmin = computed(() => role.value === 'super_admin') + const isSchoolAdmin = computed(() => role.value === 'school_admin') + + function loadFromStorage() { + token.value = localStorage.getItem('hub_token') + role.value = localStorage.getItem('hub_role') + userId.value = localStorage.getItem('hub_user_id') + fullName.value = localStorage.getItem('hub_full_name') + schoolId.value = localStorage.getItem('hub_school_id') + } + + function setAuth(data: { access_token: string; role: string; user_id: string; full_name: string; school_id: string | null }) { + token.value = data.access_token + role.value = data.role + userId.value = data.user_id + fullName.value = data.full_name + schoolId.value = data.school_id + localStorage.setItem('hub_token', data.access_token) + localStorage.setItem('hub_role', data.role) + localStorage.setItem('hub_user_id', data.user_id) + localStorage.setItem('hub_full_name', data.full_name) + if (data.school_id) localStorage.setItem('hub_school_id', data.school_id) + } + + function logout() { + token.value = null + role.value = null + userId.value = null + fullName.value = null + schoolId.value = null + localStorage.removeItem('hub_token') + localStorage.removeItem('hub_role') + localStorage.removeItem('hub_user_id') + localStorage.removeItem('hub_full_name') + localStorage.removeItem('hub_school_id') + } + + return { token, role, userId, fullName, schoolId, isAuthenticated, isSuperAdmin, isSchoolAdmin, loadFromStorage, setAuth, logout } +}) diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..76c14a2 --- /dev/null +++ b/frontend/tailwind.config.ts @@ -0,0 +1,15 @@ +import type { Config } from 'tailwindcss' + +export default { + content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'], + theme: { + extend: { + fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] }, + colors: { + sidebar: '#0F172A', + primary: '#3B82F6', + }, + }, + }, + plugins: [], +} satisfies Config diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..f2f2d75 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "paths": { "@/*": ["./src/*"] } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..f0df074 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + server: { + proxy: { + '/api': { target: 'http://localhost:8000', changeOrigin: true }, + }, + }, +}) diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..22edd86 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,34 @@ +events { worker_connections 1024; } + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + sendfile on; + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml; + + upstream backend { server backend:8000; } + upstream frontend { server frontend:80; } + + server { + listen 80; + server_name _; + client_max_body_size 20M; + + # API + location /api/ { + proxy_pass http://backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 120s; + } + + # Frontend SPA + location / { + proxy_pass http://frontend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + } +}