feat(phase-1): TapTrack Hub initial scaffold
Full project scaffold for TapTrack Hub — cloud SaaS control plane for managing on-prem TapTrack school deployments. ## Infrastructure - Docker Compose: backend (gunicorn+uvicorn), Celery worker + beat, frontend (Vite build + nginx), PostgreSQL 15, Redis 7, nginx proxy - Dockerfile for backend and frontend, nginx reverse proxy config ## Backend (FastAPI + SQLAlchemy async + Celery) Database schema (10 tables): hub_users, schools, licenses, sms_jobs, sms_credit_ledger, invoices, invoice_line_items, school_subscriptions, support_tickets, ticket_replies, audit_logs, announcements Auth: JWT (python-jose) + bcrypt + role-based FastAPI dependencies (get_current_user, require_super_admin, require_school_admin) Routers (11): auth, schools, licenses, sms, billing, tickets, users, dashboard, school_portal, announcements, sync Celery tasks (6): sms.process_queue, billing.generate_monthly_invoices, billing.send_invoice_email, billing.check_overdue, license.check_expiry, reports.send_monthly_reports Services: SMTP email helper (smtplib + Jinja2) Seed script: creates super admin admin@taptrack.io ## Frontend (Vue 3 + Vite + Pinia + Tailwind CSS) Router: 14 routes across super admin + school portal layouts Stores: Pinia auth store with localStorage persistence API client: full axios client for all backend endpoints Layouts: AppLayout (super admin), PortalLayout (school), AuthLayout Components: AppSidebar, PortalSidebar, SidebarItem, KpiCard, StatusBadge, ToastStack Pages: Login, Dashboard, Schools, SchoolDetail, Licenses, SMS, Billing, Tickets, TicketDetail, Users, Announcements, 404 Portal pages: Overview, Billing, SMS Reports, Tickets, Profile ## PAUL Planning Files - .paul/ROADMAP.md: full 15-phase roadmap with detailed scope - .paul/STATE.md: current position, tech stack, architecture notes - .paul/phases/01-setup/01-PLAN.md: complete Phase 1 plan (done) - .paul/phases/02 through 15: README stubs for all future phases
This commit is contained in:
11
.env.example
Normal file
11
.env.example
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
SECRET_KEY=changeme-use-openssl-rand-hex-32
|
||||||
|
ENVIRONMENT=development
|
||||||
|
SEMAPHORE_API_KEY=your-semaphore-api-key
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=your-email@gmail.com
|
||||||
|
SMTP_PASSWORD=your-app-password
|
||||||
|
SMTP_FROM=noreply@taptrack.io
|
||||||
|
HUB_BASE_URL=https://hub.taptrack.io
|
||||||
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
*.log
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
.DS_Store
|
||||||
339
.paul/ROADMAP.md
Normal file
339
.paul/ROADMAP.md
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
# Roadmap: TapTrack Hub
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
TapTrack Hub is a cloud-hosted SaaS control plane for managing all on-prem TapTrack school deployments.
|
||||||
|
It handles school licensing, SMS gateway proxying (Semaphore PH), billing/invoicing, support tickets,
|
||||||
|
monthly automated reports, and provides a client-facing school portal.
|
||||||
|
|
||||||
|
Two user roles:
|
||||||
|
- **Super Admin** — you (the operator): full control over all schools, licenses, SMS, billing
|
||||||
|
- **School Admin** — each client school's administrator: portal access for billing, SMS, tickets
|
||||||
|
|
||||||
|
Architecture: FastAPI + PostgreSQL + Redis + Celery + Vue 3 + Nginx, deployed as Docker Compose on a cloud VPS.
|
||||||
|
|
||||||
|
SMS flow: On-prem TapTrack polls Hub every 30s → Hub queues SMS jobs → Hub sends via Semaphore → reports back status.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Milestone
|
||||||
|
|
||||||
|
**v1.0 — Foundation & Core Services**
|
||||||
|
Status: Phase 1 complete — Phase 2 next
|
||||||
|
Phases: 1 of 15 complete
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase Status
|
||||||
|
|
||||||
|
| Phase | Name | Plans | Status | Completed |
|
||||||
|
|-------|------------------------------|-------|--------------|------------|
|
||||||
|
| 1 | Project Setup & Infrastructure | 1 | ✅ Complete | 2026-03-15 |
|
||||||
|
| 2 | School Registry + License Mgmt | TBD | Not started | — |
|
||||||
|
| 3 | On-Prem License Validation | TBD | Not started | — |
|
||||||
|
| 4 | SMS Gateway (credits + queue) | TBD | Not started | — |
|
||||||
|
| 5 | On-Prem SMS Polling Agent | TBD | Not started | — |
|
||||||
|
| 6 | Super Admin Dashboard UI | TBD | Not started | — |
|
||||||
|
| 7 | School Admin Portal UI | TBD | Not started | — |
|
||||||
|
| 8 | Billing Engine + Invoice PDF | TBD | Not started | — |
|
||||||
|
| 9 | Email Dispatcher | TBD | Not started | — |
|
||||||
|
| 10 | Support Ticket System | TBD | Not started | — |
|
||||||
|
| 11 | Monthly Report Generation | TBD | Not started | — |
|
||||||
|
| 12 | On-Prem Monthly Report Pull | TBD | Not started | — |
|
||||||
|
| 13 | Feature Flags + Suspension | TBD | Not started | — |
|
||||||
|
| 14 | Onboarding Wizard + Welcome Email | TBD | Not started | — |
|
||||||
|
| 15 | UX Polish + Ops Tools | TBD | Not started | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase Details
|
||||||
|
|
||||||
|
### Phase 1: Project Setup & Infrastructure ✅
|
||||||
|
|
||||||
|
**Goal:** Runnable Docker Compose stack with FastAPI, PostgreSQL, Redis, Celery, and Vue 3 frontend skeleton.
|
||||||
|
**Completed:** 2026-03-15
|
||||||
|
|
||||||
|
What was built:
|
||||||
|
- Docker Compose: backend (gunicorn+uvicorn), celery worker, celery beat, frontend (vite build + nginx), PostgreSQL, Redis, nginx reverse proxy
|
||||||
|
- Full database schema: hub_users, schools, licenses, sms_jobs, sms_credit_ledger, invoices, invoice_line_items, school_subscriptions, support_tickets, ticket_replies, audit_logs, announcements
|
||||||
|
- Auth: JWT (python-jose), bcrypt, role-based dependencies (super_admin, school_admin)
|
||||||
|
- All FastAPI routers: auth, schools, licenses, sms, billing, tickets, users, dashboard, school_portal, announcements, sync
|
||||||
|
- All Celery tasks: sms.process_queue, billing.generate_monthly_invoices, billing.send_invoice_email, billing.check_overdue, license.check_expiry, reports.send_monthly_reports
|
||||||
|
- Email service (SMTP), seed script (super admin)
|
||||||
|
- Vue 3 frontend: router (super admin + portal routes), Pinia auth store, full api.ts client, Tailwind CSS, all page skeletons (Login, Dashboard, Schools, SchoolDetail, Licenses, SMS, Billing, Tickets, TicketDetail, Users, Announcements, NotFoundPage + all 5 portal pages)
|
||||||
|
- Shared components: AppSidebar, PortalSidebar, SidebarItem, KpiCard, StatusBadge, ToastStack
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: School Registry + License Management
|
||||||
|
|
||||||
|
**Goal:** Super admin can register schools, issue license keys, set billing plans, add SMS credits, activate/suspend.
|
||||||
|
**Depends on:** Phase 1
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
- School create/edit form (modal) in SchoolsPage
|
||||||
|
- License detail panel in SchoolDetailPage: change expiry, tier, revoke
|
||||||
|
- Subscription setup form per school (monthly fee, SMS cost per message)
|
||||||
|
- Add SMS credits form with ledger view
|
||||||
|
- Send welcome email on school creation (onboarding stub)
|
||||||
|
- School status lifecycle: pending → active → suspended/expired
|
||||||
|
|
||||||
|
**Plans:**
|
||||||
|
- [ ] 2-01: School create/edit modal + license expiry editor in SchoolDetailPage
|
||||||
|
- [ ] 2-02: Subscription setup + SMS credit top-up form + ledger table
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: On-Prem License Validation
|
||||||
|
|
||||||
|
**Goal:** TapTrack on-prem validates its license key against Hub on startup and periodically; Hub returns feature flags and school config.
|
||||||
|
**Depends on:** Phase 2
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
- `POST /api/licenses/validate` endpoint (already built) — verify, update last_seen, return features
|
||||||
|
- On-prem TapTrack changes:
|
||||||
|
- Celery beat task every 6h to call Hub license validate
|
||||||
|
- If expired/revoked: SMS disabled, warning banner in UI
|
||||||
|
- Store validated features in Redis (5min TTL) for fast feature flag checks
|
||||||
|
- Hardware fingerprint binding (optional — store server MAC on first validation)
|
||||||
|
|
||||||
|
**Plans:**
|
||||||
|
- [ ] 3-01: On-prem license validation task + feature flag Redis cache (TapTrack side)
|
||||||
|
- [ ] 3-02: UI warning banner on TapTrack when license invalid/expired
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: SMS Gateway (Credits + Job Queue)
|
||||||
|
|
||||||
|
**Goal:** Schools submit SMS jobs via Hub; Hub processes them through Semaphore using school's sender name and deducts credits.
|
||||||
|
**Depends on:** Phase 2
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
- `POST /api/sms/submit` (already built) — on-prem posts jobs
|
||||||
|
- Celery `sms.process_queue` (already built) — processes via Semaphore, deducts credits, logs ledger
|
||||||
|
- SMS job retry logic (up to 5 retries, exponential backoff)
|
||||||
|
- Low credit email alert (already built)
|
||||||
|
- Super admin SMS dashboard: jobs by status, school breakdown, Semaphore API health check
|
||||||
|
- Credit ledger view per school in SchoolDetailPage
|
||||||
|
|
||||||
|
**Plans:**
|
||||||
|
- [ ] 4-01: SMS dashboard stats + per-school credit ledger UI in SchoolDetailPage
|
||||||
|
- [ ] 4-02: SMS retry logic hardening + Semaphore health check endpoint
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: On-Prem SMS Polling Agent
|
||||||
|
|
||||||
|
**Goal:** TapTrack on-prem polls Hub every 30s for pending SMS jobs; sends them via Hub's Semaphore account; reports back completion.
|
||||||
|
**Depends on:** Phase 4
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
- Replace TapTrack's direct Semaphore calls with Hub polling
|
||||||
|
- On-prem Celery task: `POST /api/sync/poll` with license key + completed job IDs
|
||||||
|
- Hub returns jobs to send, school config (sender name, credits, status)
|
||||||
|
- On-prem sends via Hub-provided sender name (not its own settings)
|
||||||
|
- If school status = suspended: skip SMS, show banner
|
||||||
|
- Graceful degradation: if Hub unreachable, queue locally and retry
|
||||||
|
|
||||||
|
**Plans:**
|
||||||
|
- [ ] 5-01: On-prem polling task replacing direct Semaphore calls (TapTrack side)
|
||||||
|
- [ ] 5-02: Graceful degradation + local queue fallback (TapTrack side)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 6: Super Admin Dashboard UI
|
||||||
|
|
||||||
|
**Goal:** Rich super admin dashboard with KPI overview, school health list, SMS volume chart, revenue snapshot.
|
||||||
|
**Depends on:** Phase 4, Phase 2
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
- KPI row: total/active/suspended schools, licenses expiring, open tickets, SMS today, pending invoices
|
||||||
|
- School health table: name, status, credits, last seen, license expiry (sortable)
|
||||||
|
- SMS volume chart (last 30 days) — bar chart by day
|
||||||
|
- Revenue snapshot: MRR, outstanding, overdue total
|
||||||
|
- Quick action buttons: add school, trigger SMS queue, check overdue invoices
|
||||||
|
|
||||||
|
**Plans:**
|
||||||
|
- [ ] 6-01: Dashboard KPI row + school health table
|
||||||
|
- [ ] 6-02: SMS volume chart + revenue snapshot panel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 7: School Admin Portal UI
|
||||||
|
|
||||||
|
**Goal:** Complete school admin portal — overview, billing history, SMS reports with charts, support tickets, profile.
|
||||||
|
**Depends on:** Phase 5, Phase 8
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
- Portal overview: license status card, credit meter with low-credit warning, monthly SMS usage chart
|
||||||
|
- Billing tab: full invoice table, PDF download button per invoice, credit top-up request form
|
||||||
|
- SMS tab: monthly SMS log, delivery rate chart, credit burn rate chart
|
||||||
|
- Tickets tab: create ticket + reply thread view (already built as stubs)
|
||||||
|
- Profile tab: change password, view school details
|
||||||
|
|
||||||
|
**Plans:**
|
||||||
|
- [ ] 7-01: Portal overview enhancements (credit meter, charts, license countdown)
|
||||||
|
- [ ] 7-02: Billing PDF download + credit top-up request form
|
||||||
|
- [ ] 7-03: SMS usage charts + delivery rate visualization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 8: Billing Engine + Invoice PDF
|
||||||
|
|
||||||
|
**Goal:** Automated monthly invoice generation per school; PDF export; mark as paid workflow; overdue escalation.
|
||||||
|
**Depends on:** Phase 2
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
- `billing.generate_monthly_invoices` Celery task (already built) — creates draft invoices
|
||||||
|
- Invoice PDF generation using Jinja2 HTML template + WeasyPrint
|
||||||
|
- Invoice PDF stored at `/app/data/invoices/{id}.pdf`
|
||||||
|
- `GET /api/billing/invoices/{id}/pdf` — serve PDF
|
||||||
|
- Mark as paid workflow (super admin sets paid_at, payment_method, payment_reference)
|
||||||
|
- Overdue escalation: 7 days → warning email, 30 days → suspend school
|
||||||
|
- Invoice number sequencing: INV-YYYYMM-NNNN
|
||||||
|
|
||||||
|
**Plans:**
|
||||||
|
- [ ] 8-01: Invoice PDF generation (Jinja2 template + WeasyPrint) + serve endpoint
|
||||||
|
- [ ] 8-02: Mark-paid workflow + overdue escalation (suspend at 30 days)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 9: Email Dispatcher
|
||||||
|
|
||||||
|
**Goal:** All automated emails (invoices, low credits, license expiry, monthly reports) send correctly via SMTP with proper HTML templates.
|
||||||
|
**Depends on:** Phase 8
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
- Jinja2 HTML email templates: invoice, low credit alert, license expiry warning, monthly report, welcome email
|
||||||
|
- Email queue via Celery (retry on failure)
|
||||||
|
- Email delivery log table (email_logs): to, subject, type, status, sent_at, error
|
||||||
|
- `GET /api/email-logs` — super admin can view all email delivery history
|
||||||
|
- Test email endpoint: `POST /api/email/test` — send a test email to verify SMTP config
|
||||||
|
|
||||||
|
**Plans:**
|
||||||
|
- [ ] 9-01: Jinja2 HTML email templates + email_logs table + delivery logging
|
||||||
|
- [ ] 9-02: Email delivery log UI in super admin + test email endpoint
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 10: Support Ticket System
|
||||||
|
|
||||||
|
**Goal:** Full-featured support ticket system with SLA tracking, internal notes, priority management, and email notifications.
|
||||||
|
**Depends on:** Phase 9 (emails)
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
- Ticket create/reply already built — polish and harden
|
||||||
|
- Email notification on new ticket (to super admin) and on reply (to school admin)
|
||||||
|
- Internal notes (super admin only, already supported via is_internal flag)
|
||||||
|
- SLA tracking: first_response_at already stored — display SLA status in ticket list
|
||||||
|
- Priority escalation: tickets open > 48h auto-escalate to high
|
||||||
|
- Ticket assignment to specific super admin users
|
||||||
|
- Bulk actions: close all resolved, assign multiple
|
||||||
|
|
||||||
|
**Plans:**
|
||||||
|
- [ ] 10-01: Email notifications on ticket create/reply + SLA display in ticket list
|
||||||
|
- [ ] 10-02: Ticket assignment + priority auto-escalation + bulk close
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 11: Monthly Report Generation + Email
|
||||||
|
|
||||||
|
**Goal:** On the 1st of each month, automatically generate and email a comprehensive report to each active school.
|
||||||
|
**Depends on:** Phase 9, Phase 12
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
- `reports.send_monthly_reports` Celery task (already built as stub) — enhance with real data
|
||||||
|
- Report content: attendance summary, SMS usage, credit consumption, invoice for period
|
||||||
|
- HTML email template for monthly report (branded, professional)
|
||||||
|
- Super admin can manually trigger report for any school: `POST /api/reports/send/{school_id}`
|
||||||
|
- School portal: view past monthly reports (list + detail)
|
||||||
|
|
||||||
|
**Plans:**
|
||||||
|
- [ ] 11-01: Monthly report data aggregation + HTML email template
|
||||||
|
- [ ] 11-02: Manual trigger endpoint + report history in school portal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 12: On-Prem Monthly Report Pull
|
||||||
|
|
||||||
|
**Goal:** Hub pulls attendance summary data from each on-prem TapTrack instance to populate monthly reports.
|
||||||
|
**Depends on:** Phase 11
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
- New endpoint on TapTrack on-prem: `GET /api/hub/monthly-report?key={license_key}&month={YYYY-MM}`
|
||||||
|
- Returns: total_students, present_days, absent_days, late_days, avg_attendance_rate, sms_sent
|
||||||
|
- Hub Celery task: on the 1st at 5am, poll each active school's on-prem for monthly data
|
||||||
|
- Store result in `school_monthly_stats` table for report generation
|
||||||
|
- Fallback: if on-prem unreachable, report shows "data unavailable" for attendance section
|
||||||
|
|
||||||
|
**Plans:**
|
||||||
|
- [ ] 12-01: TapTrack on-prem monthly report endpoint + school_monthly_stats table in Hub
|
||||||
|
- [ ] 12-02: Hub Celery pull task + fallback handling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 13: Feature Flags + Service Suspension Logic
|
||||||
|
|
||||||
|
**Goal:** Hub controls which features each school's on-prem instance can use, based on tier and payment status.
|
||||||
|
**Depends on:** Phase 3, Phase 8
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
- Feature flags per tier (already in `_tier_features()`) — expose via `/api/licenses/validate`
|
||||||
|
- Suspension flow:
|
||||||
|
1. Invoice overdue 30 days → school.status = suspended
|
||||||
|
2. On-prem polls Hub → gets status=suspended → disables SMS, shows banner
|
||||||
|
3. On-prem attendance still works (not blocked) — only SMS and reports disabled
|
||||||
|
- Feature flag UI: per-school overrides in SchoolDetailPage (super admin can enable beta features)
|
||||||
|
- `feature_overrides` JSON column on School model for per-school feature customization
|
||||||
|
|
||||||
|
**Plans:**
|
||||||
|
- [ ] 13-01: feature_overrides column + per-school feature flag UI in SchoolDetailPage
|
||||||
|
- [ ] 13-02: Suspension propagation to on-prem via sync poll + on-prem banner
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 14: Onboarding Wizard + Welcome Email
|
||||||
|
|
||||||
|
**Goal:** When a new school is registered, automatically send a welcome email with license key and setup instructions; guide super admin through initial school setup.
|
||||||
|
**Depends on:** Phase 9
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
- Welcome email template: school name, license key, setup instructions link, Hub portal login URL
|
||||||
|
- Triggered automatically on school creation (after license is issued)
|
||||||
|
- Super admin onboarding checklist in SchoolDetailPage:
|
||||||
|
- [ ] School created
|
||||||
|
- [ ] License issued
|
||||||
|
- [ ] Billing plan set
|
||||||
|
- [ ] SMS credits added
|
||||||
|
- [ ] Welcome email sent
|
||||||
|
- [ ] School admin account created
|
||||||
|
- "Resend welcome email" button
|
||||||
|
- "Complete onboarding" button sets school.status = active
|
||||||
|
|
||||||
|
**Plans:**
|
||||||
|
- [ ] 14-01: Welcome email template + auto-send on school creation
|
||||||
|
- [ ] 14-02: Onboarding checklist UI in SchoolDetailPage + "activate school" workflow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 15: UX Polish + Super Admin Ops Tools
|
||||||
|
|
||||||
|
**Goal:** Production-ready polish: loading states, empty states, error boundaries, keyboard shortcuts, mobile responsiveness, audit log viewer, bulk operations.
|
||||||
|
**Depends on:** All previous phases
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
- Audit log viewer: `GET /api/audit-logs` — searchable table of all system events
|
||||||
|
- Bulk school actions: export CSV, bulk status change
|
||||||
|
- Global search: search schools, tickets, invoices by name/number
|
||||||
|
- Keyboard shortcuts: `/` focus search, `N` new school, `Esc` close modals
|
||||||
|
- Mobile-responsive layouts for portal (school admins may use phone)
|
||||||
|
- Error boundary component for API failures
|
||||||
|
- Dashboard refresh button with last-updated timestamp
|
||||||
|
- Empty state illustrations for zero-data pages
|
||||||
|
- Production hardening: rate limiting on auth endpoints, HTTPS redirect
|
||||||
|
|
||||||
|
**Plans:**
|
||||||
|
- [ ] 15-01: Audit log table + global search + bulk export
|
||||||
|
- [ ] 15-02: Mobile-responsive portal + error boundary + production hardening
|
||||||
|
|
||||||
|
---
|
||||||
64
.paul/STATE.md
Normal file
64
.paul/STATE.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# TapTrack Hub — PAUL State
|
||||||
|
|
||||||
|
## Current Position
|
||||||
|
|
||||||
|
Milestone: v1.0 — Foundation & Core Services
|
||||||
|
Phase: 1 of 15 (Project Setup & Infrastructure — complete)
|
||||||
|
Plan: Phase 1 complete — no active plan
|
||||||
|
Status: **Phase 1 applied — ready to begin Phase 2**
|
||||||
|
Last activity: 2026-03-15 — Phase 1 complete (full stack scaffold: Docker Compose, all backend models/routers/tasks, Vue 3 frontend skeleton with all page stubs)
|
||||||
|
|
||||||
|
## Loop Position
|
||||||
|
|
||||||
|
```
|
||||||
|
PLAN ──▶ APPLY ──▶ UNIFY
|
||||||
|
· · · [No active plan — Phase 2 planning next]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
### v1.0 Foundation
|
||||||
|
|
||||||
|
- Phase 1 (Project Setup & Infrastructure): [██████████] 100% ✓
|
||||||
|
- Phase 2 (School Registry + License Mgmt): [░░░░░░░░░░] 0%
|
||||||
|
- Phase 3 (On-Prem License Validation): [░░░░░░░░░░] 0%
|
||||||
|
- Phase 4 (SMS Gateway): [░░░░░░░░░░] 0%
|
||||||
|
- Phase 5 (On-Prem SMS Polling Agent): [░░░░░░░░░░] 0%
|
||||||
|
- Phase 6 (Super Admin Dashboard UI): [░░░░░░░░░░] 0%
|
||||||
|
- Phase 7 (School Admin Portal UI): [░░░░░░░░░░] 0%
|
||||||
|
- Phase 8 (Billing Engine + Invoice PDF): [░░░░░░░░░░] 0%
|
||||||
|
- Phase 9 (Email Dispatcher): [░░░░░░░░░░] 0%
|
||||||
|
- Phase 10 (Support Ticket System): [░░░░░░░░░░] 0%
|
||||||
|
- Phase 11 (Monthly Report Generation): [░░░░░░░░░░] 0%
|
||||||
|
- Phase 12 (On-Prem Monthly Report Pull): [░░░░░░░░░░] 0%
|
||||||
|
- Phase 13 (Feature Flags + Suspension): [░░░░░░░░░░] 0%
|
||||||
|
- Phase 14 (Onboarding Wizard): [░░░░░░░░░░] 0%
|
||||||
|
- Phase 15 (UX Polish + Ops Tools): [░░░░░░░░░░] 0%
|
||||||
|
|
||||||
|
## Next Action
|
||||||
|
|
||||||
|
Run: `/paul:plan` for Phase 2 — School Registry + License Management
|
||||||
|
Resume file: .paul/ROADMAP.md → Phase 2
|
||||||
|
|
||||||
|
## Repo
|
||||||
|
|
||||||
|
Remote: TBD (new Gitea repo)
|
||||||
|
Branch: master
|
||||||
|
Last commit: initial scaffold
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- Backend: FastAPI 0.115 + SQLAlchemy 2 async + PostgreSQL 15 + Redis 7
|
||||||
|
- Worker: Celery 5.4 + celery-beat
|
||||||
|
- Email: SMTP via smtplib + Jinja2 HTML templates
|
||||||
|
- SMS: Semaphore PH API (single account, proxied for all schools)
|
||||||
|
- Frontend: Vue 3.5 + Vite + Pinia + Vue Router + Tailwind CSS 3
|
||||||
|
- Infra: Docker Compose + Nginx reverse proxy
|
||||||
|
|
||||||
|
## Architecture Notes
|
||||||
|
|
||||||
|
- Two user roles: super_admin (operator) + school_admin (client)
|
||||||
|
- Two frontend layouts: AppLayout (super admin nav) + PortalLayout (school portal nav)
|
||||||
|
- SMS flow: on-prem polls /api/sync/poll every 30s → Hub queues/sends via Semaphore
|
||||||
|
- License flow: on-prem calls /api/licenses/validate on startup + every 6h
|
||||||
|
- No inbound firewall rules needed — all communication is on-prem → Hub outbound
|
||||||
89
.paul/phases/01-setup/01-PLAN.md
Normal file
89
.paul/phases/01-setup/01-PLAN.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
phase: 01-setup
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
autonomous: true
|
||||||
|
status: complete
|
||||||
|
completed: 2026-03-15
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Bootstrap the full TapTrack Hub project: Docker Compose stack, complete database schema,
|
||||||
|
FastAPI backend skeleton with all routers and Celery tasks, Vue 3 frontend skeleton
|
||||||
|
with all page stubs, PAUL state/roadmap files.
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
- Docker Compose: backend (gunicorn+uvicorn), celery worker, celery beat, frontend (vite+nginx),
|
||||||
|
PostgreSQL 15, Redis 7, nginx reverse proxy (port 8080)
|
||||||
|
- backend/Dockerfile, frontend/Dockerfile, nginx/nginx.conf
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- app/database.py — async SQLAlchemy engine + Base
|
||||||
|
- app/config.py — Settings (env vars: DB, Redis, SMTP, Semaphore, JWT)
|
||||||
|
- app/worker.py — Celery app + beat schedule (5 tasks)
|
||||||
|
- app/auth/ — password hashing (bcrypt), JWT encode/decode, FastAPI dependencies
|
||||||
|
(get_current_user, require_super_admin, require_school_admin)
|
||||||
|
|
||||||
|
### Database Models (8 tables)
|
||||||
|
- hub_users (id, email, full_name, hashed_password, role, school_id, is_active)
|
||||||
|
- schools (id, name, slug, address, tier, status, sms_credits, sms_sender_name, ...)
|
||||||
|
- licenses (id, school_id, key, status, tier, expires_at, last_validated_at, last_seen_ip)
|
||||||
|
- sms_jobs (id, school_id, recipient_phone, message, sender_name, status, retry_count)
|
||||||
|
- sms_credit_ledger (id, school_id, tx_type, amount, balance_after, description)
|
||||||
|
- invoices + invoice_line_items (full billing schema)
|
||||||
|
- school_subscriptions (monthly_fee, sms_cost_per_message, cycle, next_billing_date)
|
||||||
|
- support_tickets + ticket_replies (subject, body, category, status, priority, is_internal)
|
||||||
|
- audit_logs (actor, action, entity, detail, ip_address)
|
||||||
|
- announcements (title, body, is_active, expires_at)
|
||||||
|
|
||||||
|
### Routers (11)
|
||||||
|
- auth: POST /login, GET /me, PUT /me/password
|
||||||
|
- schools: CRUD + POST /{id}/credits (SMS top-up)
|
||||||
|
- licenses: list, update, revoke, POST /validate (for on-prem)
|
||||||
|
- sms: POST /submit (on-prem), GET /jobs, GET /credits/{school_id}
|
||||||
|
- billing: invoices CRUD + send-email, subscriptions upsert
|
||||||
|
- tickets: CRUD + replies
|
||||||
|
- users: CRUD (super admin)
|
||||||
|
- dashboard: GET /summary (KPIs)
|
||||||
|
- school_portal: GET /portal/overview (school-scoped)
|
||||||
|
- announcements: CRUD
|
||||||
|
- sync: POST /sync/poll (on-prem 30s poll)
|
||||||
|
|
||||||
|
### Celery Tasks (6)
|
||||||
|
- sms.process_queue — send pending jobs via Semaphore, deduct credits, log ledger
|
||||||
|
- sms.send_low_credit_alert — email school when balance < threshold
|
||||||
|
- billing.generate_monthly_invoices — create draft invoices on 1st of month
|
||||||
|
- billing.send_invoice_email — send invoice to billing contact
|
||||||
|
- billing.check_overdue — mark unpaid invoices as overdue daily
|
||||||
|
- license.check_expiry — email expiry warnings at 30/14/7 days
|
||||||
|
- reports.send_monthly_reports — email report stub to all active schools
|
||||||
|
|
||||||
|
### Services
|
||||||
|
- app/services/email.py — SMTP send_email helper
|
||||||
|
|
||||||
|
### Seed Script
|
||||||
|
- backend/seed.py — creates default super admin: admin@taptrack.io / admin123!
|
||||||
|
|
||||||
|
### Frontend (Vue 3 + Vite + Tailwind + Pinia)
|
||||||
|
- router: super admin routes (/, /dashboard, /schools, /licenses, /sms, /billing, /tickets, /users, /announcements)
|
||||||
|
+ portal routes (/portal, /portal/billing, /portal/sms, /portal/tickets, /portal/profile)
|
||||||
|
- stores/auth.ts — Pinia auth store with localStorage persistence
|
||||||
|
- lib/api.ts — full axios client for all endpoints
|
||||||
|
- composables/useToast.ts — toast notification system
|
||||||
|
- layouts: AppLayout (super admin), PortalLayout (school), AuthLayout (login)
|
||||||
|
- components: AppSidebar, PortalSidebar, SidebarItem, KpiCard, StatusBadge, ToastStack
|
||||||
|
- pages: LoginPage, DashboardPage, SchoolsPage, SchoolDetailPage, LicensesPage,
|
||||||
|
SmsPage, BillingPage, TicketsPage, TicketDetailPage, UsersPage, AnnouncementsPage,
|
||||||
|
NotFoundPage
|
||||||
|
- portal pages: PortalOverviewPage, PortalBillingPage, PortalSmsPage, PortalTicketsPage, PortalProfilePage
|
||||||
|
|
||||||
|
## Acceptance Criteria (All Met)
|
||||||
|
- [x] Docker Compose starts cleanly (backend, celery, frontend, db, redis, nginx)
|
||||||
|
- [x] GET /api/health returns {"status":"ok"}
|
||||||
|
- [x] Database tables created on startup via SQLAlchemy create_all
|
||||||
|
- [x] Super admin seed script creates admin account
|
||||||
|
- [x] Frontend builds and serves on port 8080
|
||||||
|
- [x] Login page renders; JWT auth flow works
|
||||||
|
- [x] All 15 PAUL phase directories created
|
||||||
20
.paul/phases/02-school-registry/README.md
Normal file
20
.paul/phases/02-school-registry/README.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Phase 2: School Registry + License Management
|
||||||
|
|
||||||
|
**Status:** Not started
|
||||||
|
**Depends on:** Phase 1
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Super admin can register schools, issue license keys, set billing plans, add SMS credits,
|
||||||
|
and manage the school lifecycle (pending → active → suspended/expired).
|
||||||
|
|
||||||
|
## Plans
|
||||||
|
- [ ] 2-01: School create/edit modal + license expiry editor in SchoolDetailPage
|
||||||
|
- [ ] 2-02: Subscription setup + SMS credit top-up form + ledger table
|
||||||
|
|
||||||
|
## Key Endpoints (already built, need UI)
|
||||||
|
- POST /api/schools — create school (auto-issues license)
|
||||||
|
- PUT /api/schools/{id} — update school details
|
||||||
|
- PUT /api/licenses/{id} — set expiry, tier, max_students
|
||||||
|
- POST /api/licenses/{id}/revoke — revoke license
|
||||||
|
- POST /api/schools/{id}/credits — add SMS credits
|
||||||
|
- PUT /api/billing/subscriptions/{school_id} — set monthly fee
|
||||||
9
.paul/phases/03-onprem-license/README.md
Normal file
9
.paul/phases/03-onprem-license/README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Phase 03: On-Prem License Validation
|
||||||
|
|
||||||
|
**Status:** Not started
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
TapTrack validates license key against Hub on startup; returns feature flags and school config.
|
||||||
|
|
||||||
|
## Plans
|
||||||
|
- [ ] TBD — run /paul:plan when Phase 2 is complete
|
||||||
9
.paul/phases/04-sms-gateway/README.md
Normal file
9
.paul/phases/04-sms-gateway/README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Phase 04: SMS Gateway (Credits + Queue)
|
||||||
|
|
||||||
|
**Status:** Not started
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Schools submit SMS jobs via Hub; Hub sends via Semaphore; credits deducted per message.
|
||||||
|
|
||||||
|
## Plans
|
||||||
|
- [ ] TBD — run /paul:plan when Phase 3 is complete
|
||||||
9
.paul/phases/05-onprem-sms-agent/README.md
Normal file
9
.paul/phases/05-onprem-sms-agent/README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Phase 05: On-Prem SMS Polling Agent
|
||||||
|
|
||||||
|
**Status:** Not started
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
TapTrack polls Hub every 30s for pending SMS jobs; sends them; reports back completion.
|
||||||
|
|
||||||
|
## Plans
|
||||||
|
- [ ] TBD — run /paul:plan when Phase 4 is complete
|
||||||
9
.paul/phases/06-super-admin-dashboard/README.md
Normal file
9
.paul/phases/06-super-admin-dashboard/README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Phase 06: Super Admin Dashboard UI
|
||||||
|
|
||||||
|
**Status:** Not started
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Rich dashboard with KPI cards, school health table, SMS volume chart, revenue snapshot.
|
||||||
|
|
||||||
|
## Plans
|
||||||
|
- [ ] TBD — run /paul:plan when Phase 5 is complete
|
||||||
9
.paul/phases/07-school-portal/README.md
Normal file
9
.paul/phases/07-school-portal/README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Phase 07: School Admin Portal UI
|
||||||
|
|
||||||
|
**Status:** Not started
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Complete portal with credit meter, charts, billing PDF download, SMS reports.
|
||||||
|
|
||||||
|
## Plans
|
||||||
|
- [ ] TBD — run /paul:plan when Phase 6 is complete
|
||||||
0
.paul/phases/08-billing-engine/README.md
Normal file
0
.paul/phases/08-billing-engine/README.md
Normal file
0
.paul/phases/09-email-dispatcher/README.md
Normal file
0
.paul/phases/09-email-dispatcher/README.md
Normal file
9
.paul/phases/10-support-tickets/README.md
Normal file
9
.paul/phases/10-support-tickets/README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Phase 10: Support Ticket System
|
||||||
|
|
||||||
|
**Status:** Not started
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Email notifications on tickets, SLA tracking, internal notes, priority escalation.
|
||||||
|
|
||||||
|
## Plans
|
||||||
|
- [ ] TBD — run /paul:plan when Phase 9 is complete
|
||||||
9
.paul/phases/11-monthly-reports/README.md
Normal file
9
.paul/phases/11-monthly-reports/README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Phase 11: Monthly Report Generation
|
||||||
|
|
||||||
|
**Status:** Not started
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Auto monthly report email to schools with attendance summary and SMS usage.
|
||||||
|
|
||||||
|
## Plans
|
||||||
|
- [ ] TBD — run /paul:plan when Phase 10 is complete
|
||||||
9
.paul/phases/12-onprem-report-pull/README.md
Normal file
9
.paul/phases/12-onprem-report-pull/README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Phase 12: On-Prem Monthly Report Pull
|
||||||
|
|
||||||
|
**Status:** Not started
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Hub pulls attendance data from each TapTrack instance for monthly reports.
|
||||||
|
|
||||||
|
## Plans
|
||||||
|
- [ ] TBD — run /paul:plan when Phase 11 is complete
|
||||||
9
.paul/phases/13-feature-flags/README.md
Normal file
9
.paul/phases/13-feature-flags/README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Phase 13: Feature Flags + Suspension
|
||||||
|
|
||||||
|
**Status:** Not started
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Per-school feature flag overrides; suspension propagation to on-prem.
|
||||||
|
|
||||||
|
## Plans
|
||||||
|
- [ ] TBD — run /paul:plan when Phase 12 is complete
|
||||||
9
.paul/phases/14-onboarding-wizard/README.md
Normal file
9
.paul/phases/14-onboarding-wizard/README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Phase 14: Onboarding Wizard
|
||||||
|
|
||||||
|
**Status:** Not started
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Welcome email with license key; onboarding checklist UI in SchoolDetailPage.
|
||||||
|
|
||||||
|
## Plans
|
||||||
|
- [ ] TBD — run /paul:plan when Phase 13 is complete
|
||||||
9
.paul/phases/15-ux-polish/README.md
Normal file
9
.paul/phases/15-ux-polish/README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Phase 15: UX Polish + Ops Tools
|
||||||
|
|
||||||
|
**Status:** Not started
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Audit log viewer, global search, bulk export, mobile responsive portal, production hardening.
|
||||||
|
|
||||||
|
## Plans
|
||||||
|
- [ ] TBD — run /paul:plan when Phase 14 is complete
|
||||||
19
backend/Dockerfile
Normal file
19
backend/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpq-dev gcc curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["gunicorn", "app.main:app", \
|
||||||
|
"-k", "uvicorn.workers.UvicornWorker", \
|
||||||
|
"--workers", "2", \
|
||||||
|
"--bind", "0.0.0.0:8000", \
|
||||||
|
"--timeout", "120", \
|
||||||
|
"--access-logfile", "-"]
|
||||||
38
backend/alembic.ini
Normal file
38
backend/alembic.ini
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = migrations
|
||||||
|
prepend_sys_path = .
|
||||||
|
sqlalchemy.url = postgresql://postgres:postgres@db:5432/taptrack_hub
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/auth/__init__.py
Normal file
0
backend/app/auth/__init__.py
Normal file
41
backend/app/auth/dependencies.py
Normal file
41
backend/app/auth/dependencies.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from jose import JWTError
|
||||||
|
|
||||||
|
from app.auth.jwt import decode_token
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import HubUser, UserRole
|
||||||
|
|
||||||
|
bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> HubUser:
|
||||||
|
if not credentials:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||||
|
try:
|
||||||
|
payload = decode_token(credentials.credentials)
|
||||||
|
user_id: str = payload.get("sub")
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||||||
|
|
||||||
|
result = await db.execute(select(HubUser).where(HubUser.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(status_code=401, detail="User not found or inactive")
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def require_super_admin(user: HubUser = Depends(get_current_user)) -> HubUser:
|
||||||
|
if user.role != UserRole.super_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="Super admin access required")
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def require_school_admin(user: HubUser = Depends(get_current_user)) -> HubUser:
|
||||||
|
if user.role not in (UserRole.super_admin, UserRole.school_admin):
|
||||||
|
raise HTTPException(status_code=403, detail="School admin access required")
|
||||||
|
return user
|
||||||
12
backend/app/auth/jwt.py
Normal file
12
backend/app/auth/jwt.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from jose import jwt, JWTError
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict:
|
||||||
|
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||||
9
backend/app/auth/password.py
Normal file
9
backend/app/auth/password.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
def hash_password(plain: str) -> str:
|
||||||
|
return pwd_context.hash(plain)
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
return pwd_context.verify(plain, hashed)
|
||||||
25
backend/app/config.py
Normal file
25
backend/app/config.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
class Settings:
|
||||||
|
SECRET_KEY: str = os.getenv("SECRET_KEY", "dev-secret-change-me")
|
||||||
|
ENVIRONMENT: str = os.getenv("ENVIRONMENT", "development")
|
||||||
|
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub")
|
||||||
|
REDIS_URL: str = os.getenv("REDIS_URL", "redis://redis:6379/0")
|
||||||
|
SEMAPHORE_API_KEY: str = os.getenv("SEMAPHORE_API_KEY", "")
|
||||||
|
SEMAPHORE_URL: str = "https://api.semaphore.co/api/v4/messages"
|
||||||
|
SMTP_HOST: str = os.getenv("SMTP_HOST", "")
|
||||||
|
SMTP_PORT: int = int(os.getenv("SMTP_PORT", "587"))
|
||||||
|
SMTP_USER: str = os.getenv("SMTP_USER", "")
|
||||||
|
SMTP_PASSWORD: str = os.getenv("SMTP_PASSWORD", "")
|
||||||
|
SMTP_FROM: str = os.getenv("SMTP_FROM", "noreply@taptrack.io")
|
||||||
|
HUB_BASE_URL: str = os.getenv("HUB_BASE_URL", "http://localhost:8080")
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 # 24 hours
|
||||||
|
ALGORITHM: str = "HS256"
|
||||||
|
|
||||||
|
# License
|
||||||
|
LICENSE_KEY_PREFIX: str = "TTUB"
|
||||||
|
DEFAULT_SMS_CREDITS: float = 0.0
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
20
backend/app/database.py
Normal file
20
backend/app/database.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import os
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv(
|
||||||
|
"DATABASE_URL",
|
||||||
|
"postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub",
|
||||||
|
)
|
||||||
|
|
||||||
|
engine = create_async_engine(DATABASE_URL, echo=False, pool_pre_ping=True)
|
||||||
|
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncSession:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
yield session
|
||||||
50
backend/app/main.py
Normal file
50
backend/app/main.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""TapTrack Hub — FastAPI application entry point."""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.database import engine, Base
|
||||||
|
# Import all models so Alembic/SQLAlchemy picks them up
|
||||||
|
from app.models import user, school, license, sms, billing, ticket, audit, announcement # noqa
|
||||||
|
|
||||||
|
from app.routers import auth, schools, licenses, sms as sms_router, billing as billing_router
|
||||||
|
from app.routers import tickets, users, dashboard, school_portal, announcements, sync
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# Create tables if not exists (dev convenience — use Alembic in prod)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
yield
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="TapTrack Hub",
|
||||||
|
description="Cloud control plane for TapTrack on-prem deployments",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Routers
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(schools.router)
|
||||||
|
app.include_router(licenses.router)
|
||||||
|
app.include_router(sms_router.router)
|
||||||
|
app.include_router(billing_router.router)
|
||||||
|
app.include_router(tickets.router)
|
||||||
|
app.include_router(users.router)
|
||||||
|
app.include_router(dashboard.router)
|
||||||
|
app.include_router(school_portal.router)
|
||||||
|
app.include_router(announcements.router)
|
||||||
|
app.include_router(sync.router)
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok", "service": "taptrack-hub"}
|
||||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
16
backend/app/models/announcement.py
Normal file
16
backend/app/models/announcement.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
class Announcement(Base):
|
||||||
|
__tablename__ = "announcements"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
created_by: Mapped[str | None] = mapped_column(String(36), ForeignKey("hub_users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
21
backend/app/models/audit.py
Normal file
21
backend/app/models/audit.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import String, DateTime, ForeignKey, Text, JSON
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
school_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("schools.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
actor_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
actor_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||||
|
entity_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
entity_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
detail: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True)
|
||||||
|
|
||||||
|
school: Mapped["School | None"] = relationship("School", back_populates="audit_logs")
|
||||||
68
backend/app/models/billing.py
Normal file
68
backend/app/models/billing.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import uuid
|
||||||
|
import enum
|
||||||
|
from datetime import datetime, timezone, date
|
||||||
|
from sqlalchemy import String, Boolean, DateTime, Date, Enum as SAEnum, ForeignKey, Text, Numeric, Integer
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
class InvoiceStatus(str, enum.Enum):
|
||||||
|
draft = "draft"
|
||||||
|
sent = "sent"
|
||||||
|
paid = "paid"
|
||||||
|
overdue = "overdue"
|
||||||
|
cancelled = "cancelled"
|
||||||
|
|
||||||
|
class BillingCycle(str, enum.Enum):
|
||||||
|
monthly = "monthly"
|
||||||
|
annual = "annual"
|
||||||
|
|
||||||
|
class Invoice(Base):
|
||||||
|
__tablename__ = "invoices"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
invoice_number: Mapped[str] = mapped_column(String(30), unique=True, nullable=False)
|
||||||
|
status: Mapped[InvoiceStatus] = mapped_column(SAEnum(InvoiceStatus), default=InvoiceStatus.draft, nullable=False)
|
||||||
|
billing_period_start: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
billing_period_end: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
subscription_amount: Mapped[float] = mapped_column(Numeric(12, 2), default=0.0, nullable=False)
|
||||||
|
sms_credit_amount: Mapped[float] = mapped_column(Numeric(12, 2), default=0.0, nullable=False)
|
||||||
|
other_amount: Mapped[float] = mapped_column(Numeric(12, 2), default=0.0, nullable=False)
|
||||||
|
total_amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
|
||||||
|
currency: Mapped[str] = mapped_column(String(3), default="PHP", nullable=False)
|
||||||
|
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
payment_method: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
payment_reference: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
pdf_path: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
email_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
school: Mapped["School"] = relationship("School", back_populates="invoices")
|
||||||
|
line_items: Mapped[list["InvoiceLineItem"]] = relationship("InvoiceLineItem", back_populates="invoice", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class InvoiceLineItem(Base):
|
||||||
|
__tablename__ = "invoice_line_items"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
invoice_id: Mapped[str] = mapped_column(String(36), ForeignKey("invoices.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
description: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
quantity: Mapped[float] = mapped_column(Numeric(10, 2), default=1.0, nullable=False)
|
||||||
|
unit_price: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
|
||||||
|
amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
|
||||||
|
|
||||||
|
invoice: Mapped[Invoice] = relationship("Invoice", back_populates="line_items")
|
||||||
|
|
||||||
|
class SchoolSubscription(Base):
|
||||||
|
"""Stores billing plan per school."""
|
||||||
|
__tablename__ = "school_subscriptions"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), unique=True, nullable=False)
|
||||||
|
cycle: Mapped[BillingCycle] = mapped_column(SAEnum(BillingCycle), default=BillingCycle.monthly, nullable=False)
|
||||||
|
monthly_fee: Mapped[float] = mapped_column(Numeric(12, 2), default=0.0, nullable=False)
|
||||||
|
sms_cost_per_message: Mapped[float] = mapped_column(Numeric(8, 4), default=1.0, nullable=False)
|
||||||
|
next_billing_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
40
backend/app/models/license.py
Normal file
40
backend/app/models/license.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import uuid
|
||||||
|
import enum
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
from datetime import datetime, timezone, date
|
||||||
|
from sqlalchemy import String, Boolean, DateTime, Date, Enum as SAEnum, ForeignKey, Text, Integer
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
class LicenseStatus(str, enum.Enum):
|
||||||
|
active = "active"
|
||||||
|
expired = "expired"
|
||||||
|
revoked = "revoked"
|
||||||
|
trial = "trial"
|
||||||
|
|
||||||
|
def generate_license_key() -> str:
|
||||||
|
alphabet = string.ascii_uppercase + string.digits
|
||||||
|
segments = ["TTUB"] + [
|
||||||
|
"".join(secrets.choice(alphabet) for _ in range(5))
|
||||||
|
for _ in range(3)
|
||||||
|
]
|
||||||
|
return "-".join(segments)
|
||||||
|
|
||||||
|
class License(Base):
|
||||||
|
__tablename__ = "licenses"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), unique=True, nullable=False)
|
||||||
|
key: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, default=generate_license_key, index=True)
|
||||||
|
status: Mapped[LicenseStatus] = mapped_column(SAEnum(LicenseStatus), default=LicenseStatus.trial, nullable=False)
|
||||||
|
tier: Mapped[str] = mapped_column(String(20), default="standard", nullable=False)
|
||||||
|
issued_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
expires_at: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
last_validated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_seen_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||||
|
hardware_fingerprint: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
max_students: Mapped[int] = mapped_column(Integer, default=500, nullable=False)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
school: Mapped["School"] = relationship("School", back_populates="license")
|
||||||
45
backend/app/models/school.py
Normal file
45
backend/app/models/school.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import uuid
|
||||||
|
import enum
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum, Text, Numeric, Integer
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
class SchoolStatus(str, enum.Enum):
|
||||||
|
active = "active"
|
||||||
|
suspended = "suspended"
|
||||||
|
expired = "expired"
|
||||||
|
pending = "pending"
|
||||||
|
|
||||||
|
class LicenseTier(str, enum.Enum):
|
||||||
|
basic = "basic"
|
||||||
|
standard = "standard"
|
||||||
|
premium = "premium"
|
||||||
|
|
||||||
|
class School(Base):
|
||||||
|
__tablename__ = "schools"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||||
|
address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
contact_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
contact_phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
billing_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
status: Mapped[SchoolStatus] = mapped_column(SAEnum(SchoolStatus), default=SchoolStatus.pending, nullable=False)
|
||||||
|
tier: Mapped[LicenseTier] = mapped_column(SAEnum(LicenseTier), default=LicenseTier.standard, nullable=False)
|
||||||
|
student_limit: Mapped[int] = mapped_column(Integer, default=500, nullable=False)
|
||||||
|
sms_sender_name: Mapped[str] = mapped_column(String(11), default="SCHOOL", nullable=False)
|
||||||
|
sms_credits: Mapped[float] = mapped_column(Numeric(12, 2), default=0.0, nullable=False)
|
||||||
|
sms_credit_low_threshold: Mapped[int] = mapped_column(Integer, default=50, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
admins: Mapped[list["HubUser"]] = relationship("HubUser", back_populates="school")
|
||||||
|
license: Mapped["License | None"] = relationship("License", back_populates="school", uselist=False)
|
||||||
|
sms_jobs: Mapped[list["SmsJob"]] = relationship("SmsJob", back_populates="school")
|
||||||
|
invoices: Mapped[list["Invoice"]] = relationship("Invoice", back_populates="school")
|
||||||
|
tickets: Mapped[list["SupportTicket"]] = relationship("SupportTicket", back_populates="school")
|
||||||
|
audit_logs: Mapped[list["AuditLog"]] = relationship("AuditLog", back_populates="school")
|
||||||
50
backend/app/models/sms.py
Normal file
50
backend/app/models/sms.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import uuid
|
||||||
|
import enum
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum, ForeignKey, Text, Numeric, Integer
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
class SmsJobStatus(str, enum.Enum):
|
||||||
|
pending = "pending"
|
||||||
|
processing = "processing"
|
||||||
|
sent = "sent"
|
||||||
|
failed = "failed"
|
||||||
|
cancelled = "cancelled"
|
||||||
|
|
||||||
|
class SmsCreditTx(str, enum.Enum):
|
||||||
|
topup = "topup"
|
||||||
|
deduct = "deduct"
|
||||||
|
refund = "refund"
|
||||||
|
adjustment = "adjustment"
|
||||||
|
|
||||||
|
class SmsJob(Base):
|
||||||
|
__tablename__ = "sms_jobs"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
recipient_phone: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
sender_name: Mapped[str] = mapped_column(String(11), nullable=False)
|
||||||
|
status: Mapped[SmsJobStatus] = mapped_column(SAEnum(SmsJobStatus), default=SmsJobStatus.pending, nullable=False, index=True)
|
||||||
|
trigger: Mapped[str | None] = mapped_column(String(50), nullable=True) # "absent", "late", "manual"
|
||||||
|
semaphore_message_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
retry_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
school: Mapped["School"] = relationship("School", back_populates="sms_jobs")
|
||||||
|
|
||||||
|
class SmsCreditLedger(Base):
|
||||||
|
__tablename__ = "sms_credit_ledger"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
tx_type: Mapped[SmsCreditTx] = mapped_column(SAEnum(SmsCreditTx), nullable=False)
|
||||||
|
amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
|
||||||
|
balance_after: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
reference_id: Mapped[str | None] = mapped_column(String(36), nullable=True) # invoice_id or sms_job_id
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
created_by: Mapped[str | None] = mapped_column(String(36), nullable=True) # hub_user_id
|
||||||
58
backend/app/models/ticket.py
Normal file
58
backend/app/models/ticket.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import uuid
|
||||||
|
import enum
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum, ForeignKey, Text, Integer
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
class TicketStatus(str, enum.Enum):
|
||||||
|
open = "open"
|
||||||
|
in_progress = "in_progress"
|
||||||
|
resolved = "resolved"
|
||||||
|
closed = "closed"
|
||||||
|
|
||||||
|
class TicketPriority(str, enum.Enum):
|
||||||
|
low = "low"
|
||||||
|
medium = "medium"
|
||||||
|
high = "high"
|
||||||
|
urgent = "urgent"
|
||||||
|
|
||||||
|
class TicketCategory(str, enum.Enum):
|
||||||
|
billing = "billing"
|
||||||
|
technical = "technical"
|
||||||
|
sms = "sms"
|
||||||
|
license = "license"
|
||||||
|
general = "general"
|
||||||
|
|
||||||
|
class SupportTicket(Base):
|
||||||
|
__tablename__ = "support_tickets"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
submitted_by: Mapped[str] = mapped_column(String(36), ForeignKey("hub_users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
ticket_number: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
|
||||||
|
subject: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
category: Mapped[TicketCategory] = mapped_column(SAEnum(TicketCategory), default=TicketCategory.general, nullable=False)
|
||||||
|
status: Mapped[TicketStatus] = mapped_column(SAEnum(TicketStatus), default=TicketStatus.open, nullable=False)
|
||||||
|
priority: Mapped[TicketPriority] = mapped_column(SAEnum(TicketPriority), default=TicketPriority.medium, nullable=False)
|
||||||
|
assigned_to: Mapped[str | None] = mapped_column(String(36), ForeignKey("hub_users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
first_response_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
school: Mapped["School"] = relationship("School", back_populates="tickets")
|
||||||
|
replies: Mapped[list["TicketReply"]] = relationship("TicketReply", back_populates="ticket", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class TicketReply(Base):
|
||||||
|
__tablename__ = "ticket_replies"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
ticket_id: Mapped[str] = mapped_column(String(36), ForeignKey("support_tickets.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
author_id: Mapped[str] = mapped_column(String(36), ForeignKey("hub_users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
is_internal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
ticket: Mapped[SupportTicket] = relationship("SupportTicket", back_populates="replies")
|
||||||
25
backend/app/models/user.py
Normal file
25
backend/app/models/user.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import uuid
|
||||||
|
import enum
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum, ForeignKey, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
class UserRole(str, enum.Enum):
|
||||||
|
super_admin = "super_admin"
|
||||||
|
school_admin = "school_admin"
|
||||||
|
|
||||||
|
class HubUser(Base):
|
||||||
|
__tablename__ = "hub_users"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||||
|
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
role: Mapped[UserRole] = mapped_column(SAEnum(UserRole), nullable=False, default=UserRole.school_admin)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
school_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("schools.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
school: Mapped["School | None"] = relationship("School", back_populates="admins")
|
||||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
54
backend/app/routers/announcements.py
Normal file
54
backend/app/routers/announcements.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""Announcements — super admin creates, all users read."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, desc, and_
|
||||||
|
|
||||||
|
from app.auth.dependencies import require_super_admin, get_current_user
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import HubUser
|
||||||
|
from app.models.announcement import Announcement
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/announcements", tags=["announcements"])
|
||||||
|
|
||||||
|
class AnnouncementCreate(BaseModel):
|
||||||
|
title: str
|
||||||
|
body: str
|
||||||
|
expires_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_announcements(
|
||||||
|
current_user: HubUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
stmt = select(Announcement).where(
|
||||||
|
and_(Announcement.is_active == True,
|
||||||
|
(Announcement.expires_at == None) | (Announcement.expires_at > datetime.now(timezone.utc)))
|
||||||
|
).order_by(desc(Announcement.created_at)).limit(10)
|
||||||
|
items = (await db.execute(stmt)).scalars().all()
|
||||||
|
return [{"id": a.id, "title": a.title, "body": a.body, "created_at": a.created_at.isoformat()} for a in items]
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
async def create_announcement(
|
||||||
|
body: AnnouncementCreate,
|
||||||
|
admin: HubUser = Depends(require_super_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
ann = Announcement(title=body.title, body=body.body, created_by=admin.id, expires_at=body.expires_at)
|
||||||
|
db.add(ann)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": ann.id, "title": ann.title, "created_at": ann.created_at.isoformat()}
|
||||||
|
|
||||||
|
@router.delete("/{ann_id}", status_code=204)
|
||||||
|
async def delete_announcement(
|
||||||
|
ann_id: str,
|
||||||
|
_admin: HubUser = Depends(require_super_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
ann = (await db.execute(select(Announcement).where(Announcement.id == ann_id))).scalar_one_or_none()
|
||||||
|
if not ann:
|
||||||
|
raise HTTPException(404)
|
||||||
|
ann.is_active = False
|
||||||
|
await db.commit()
|
||||||
73
backend/app/routers/auth.py
Normal file
73
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""Authentication endpoints for TapTrack Hub."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.auth.password import verify_password, hash_password
|
||||||
|
from app.auth.jwt import create_access_token
|
||||||
|
from app.auth.dependencies import get_current_user
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import HubUser, UserRole
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
role: str
|
||||||
|
user_id: str
|
||||||
|
full_name: str
|
||||||
|
school_id: str | None
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenResponse)
|
||||||
|
async def login(body: LoginRequest, request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(select(HubUser).where(HubUser.email == body.email))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user or not verify_password(body.password, user.hashed_password):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(status_code=401, detail="Account is inactive")
|
||||||
|
user.last_login_at = datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
token = create_access_token({"sub": user.id, "role": user.role.value})
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=token,
|
||||||
|
role=user.role.value,
|
||||||
|
user_id=user.id,
|
||||||
|
full_name=user.full_name,
|
||||||
|
school_id=user.school_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def get_me(current_user: HubUser = Depends(get_current_user)):
|
||||||
|
return {
|
||||||
|
"id": current_user.id,
|
||||||
|
"email": current_user.email,
|
||||||
|
"full_name": current_user.full_name,
|
||||||
|
"role": current_user.role.value,
|
||||||
|
"school_id": current_user.school_id,
|
||||||
|
"is_active": current_user.is_active,
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.put("/me/password", status_code=204)
|
||||||
|
async def change_password(
|
||||||
|
body: ChangePasswordRequest,
|
||||||
|
current_user: HubUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if len(body.new_password) < 8:
|
||||||
|
raise HTTPException(status_code=422, detail="Password must be at least 8 characters")
|
||||||
|
if not verify_password(body.current_password, current_user.hashed_password):
|
||||||
|
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||||
|
current_user.hashed_password = hash_password(body.new_password)
|
||||||
|
await db.commit()
|
||||||
181
backend/app/routers/billing.py
Normal file
181
backend/app/routers/billing.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"""Billing and invoice endpoints."""
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func, desc
|
||||||
|
|
||||||
|
from app.auth.dependencies import require_super_admin, get_current_user
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import HubUser, UserRole
|
||||||
|
from app.models.billing import Invoice, InvoiceStatus, InvoiceLineItem, SchoolSubscription, BillingCycle
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/billing", tags=["billing"])
|
||||||
|
|
||||||
|
class InvoiceCreate(BaseModel):
|
||||||
|
school_id: str
|
||||||
|
billing_period_start: date
|
||||||
|
billing_period_end: date
|
||||||
|
subscription_amount: float = 0.0
|
||||||
|
sms_credit_amount: float = 0.0
|
||||||
|
other_amount: float = 0.0
|
||||||
|
due_date: Optional[date] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
line_items: list[dict] = []
|
||||||
|
|
||||||
|
class InvoiceUpdate(BaseModel):
|
||||||
|
status: Optional[InvoiceStatus] = None
|
||||||
|
paid_at: Optional[datetime] = None
|
||||||
|
payment_method: Optional[str] = None
|
||||||
|
payment_reference: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
class SubscriptionUpsert(BaseModel):
|
||||||
|
monthly_fee: float
|
||||||
|
sms_cost_per_message: float = 1.0
|
||||||
|
cycle: BillingCycle = BillingCycle.monthly
|
||||||
|
next_billing_date: Optional[date] = None
|
||||||
|
|
||||||
|
def _inv_out(inv: Invoice) -> dict:
|
||||||
|
return {
|
||||||
|
"id": inv.id, "school_id": inv.school_id, "invoice_number": inv.invoice_number,
|
||||||
|
"status": inv.status.value,
|
||||||
|
"billing_period_start": inv.billing_period_start.isoformat(),
|
||||||
|
"billing_period_end": inv.billing_period_end.isoformat(),
|
||||||
|
"subscription_amount": float(inv.subscription_amount),
|
||||||
|
"sms_credit_amount": float(inv.sms_credit_amount),
|
||||||
|
"other_amount": float(inv.other_amount),
|
||||||
|
"total_amount": float(inv.total_amount),
|
||||||
|
"currency": inv.currency,
|
||||||
|
"due_date": inv.due_date.isoformat() if inv.due_date else None,
|
||||||
|
"paid_at": inv.paid_at.isoformat() if inv.paid_at else None,
|
||||||
|
"payment_method": inv.payment_method,
|
||||||
|
"payment_reference": inv.payment_reference,
|
||||||
|
"email_sent_at": inv.email_sent_at.isoformat() if inv.email_sent_at else None,
|
||||||
|
"created_at": inv.created_at.isoformat(),
|
||||||
|
"notes": inv.notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _next_invoice_number(existing_count: int) -> str:
|
||||||
|
from datetime import date
|
||||||
|
return f"INV-{date.today().strftime('%Y%m')}-{existing_count + 1:04d}"
|
||||||
|
|
||||||
|
@router.get("/invoices")
|
||||||
|
async def list_invoices(
|
||||||
|
school_id: Optional[str] = Query(None),
|
||||||
|
status: Optional[InvoiceStatus] = Query(None),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(25),
|
||||||
|
current_user: HubUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
stmt = select(Invoice).order_by(desc(Invoice.created_at))
|
||||||
|
if current_user.role != UserRole.super_admin:
|
||||||
|
stmt = stmt.where(Invoice.school_id == current_user.school_id)
|
||||||
|
elif school_id:
|
||||||
|
stmt = stmt.where(Invoice.school_id == school_id)
|
||||||
|
if status:
|
||||||
|
stmt = stmt.where(Invoice.status == status)
|
||||||
|
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
|
||||||
|
invoices = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all()
|
||||||
|
return {"items": [_inv_out(i) for i in invoices], "total": total, "page": page, "per_page": per_page}
|
||||||
|
|
||||||
|
@router.post("/invoices", status_code=201)
|
||||||
|
async def create_invoice(
|
||||||
|
body: InvoiceCreate,
|
||||||
|
_admin: HubUser = Depends(require_super_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
total = body.subscription_amount + body.sms_credit_amount + body.other_amount
|
||||||
|
count = (await db.execute(select(func.count()).select_from(Invoice))).scalar_one()
|
||||||
|
inv = Invoice(
|
||||||
|
school_id=body.school_id,
|
||||||
|
invoice_number=_next_invoice_number(count),
|
||||||
|
billing_period_start=body.billing_period_start,
|
||||||
|
billing_period_end=body.billing_period_end,
|
||||||
|
subscription_amount=body.subscription_amount,
|
||||||
|
sms_credit_amount=body.sms_credit_amount,
|
||||||
|
other_amount=body.other_amount,
|
||||||
|
total_amount=total,
|
||||||
|
due_date=body.due_date,
|
||||||
|
notes=body.notes,
|
||||||
|
)
|
||||||
|
db.add(inv)
|
||||||
|
await db.flush()
|
||||||
|
for item in body.line_items:
|
||||||
|
db.add(InvoiceLineItem(
|
||||||
|
invoice_id=inv.id,
|
||||||
|
description=item.get("description", ""),
|
||||||
|
quantity=item.get("quantity", 1),
|
||||||
|
unit_price=item.get("unit_price", 0),
|
||||||
|
amount=item.get("amount", 0),
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
return _inv_out(inv)
|
||||||
|
|
||||||
|
@router.put("/invoices/{invoice_id}")
|
||||||
|
async def update_invoice(
|
||||||
|
invoice_id: str,
|
||||||
|
body: InvoiceUpdate,
|
||||||
|
_admin: HubUser = Depends(require_super_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
inv = (await db.execute(select(Invoice).where(Invoice.id == invoice_id))).scalar_one_or_none()
|
||||||
|
if not inv:
|
||||||
|
raise HTTPException(404, "Invoice not found")
|
||||||
|
for field, value in body.model_dump(exclude_none=True).items():
|
||||||
|
setattr(inv, field, value)
|
||||||
|
await db.commit()
|
||||||
|
return _inv_out(inv)
|
||||||
|
|
||||||
|
@router.post("/invoices/{invoice_id}/send-email")
|
||||||
|
async def send_invoice_email(
|
||||||
|
invoice_id: str,
|
||||||
|
_admin: HubUser = Depends(require_super_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
from app.tasks.billing import send_invoice_email_task
|
||||||
|
inv = (await db.execute(select(Invoice).where(Invoice.id == invoice_id))).scalar_one_or_none()
|
||||||
|
if not inv:
|
||||||
|
raise HTTPException(404, "Invoice not found")
|
||||||
|
send_invoice_email_task.delay(invoice_id)
|
||||||
|
return {"message": "Email queued"}
|
||||||
|
|
||||||
|
@router.get("/subscriptions/{school_id}")
|
||||||
|
async def get_subscription(
|
||||||
|
school_id: str,
|
||||||
|
current_user: HubUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if current_user.role != UserRole.super_admin and current_user.school_id != school_id:
|
||||||
|
raise HTTPException(403)
|
||||||
|
sub = (await db.execute(select(SchoolSubscription).where(SchoolSubscription.school_id == school_id))).scalar_one_or_none()
|
||||||
|
if not sub:
|
||||||
|
raise HTTPException(404, "No subscription found")
|
||||||
|
return {
|
||||||
|
"id": sub.id, "school_id": sub.school_id, "cycle": sub.cycle.value,
|
||||||
|
"monthly_fee": float(sub.monthly_fee), "sms_cost_per_message": float(sub.sms_cost_per_message),
|
||||||
|
"next_billing_date": sub.next_billing_date.isoformat() if sub.next_billing_date else None,
|
||||||
|
"is_active": sub.is_active,
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.put("/subscriptions/{school_id}")
|
||||||
|
async def upsert_subscription(
|
||||||
|
school_id: str,
|
||||||
|
body: SubscriptionUpsert,
|
||||||
|
_admin: HubUser = Depends(require_super_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
sub = (await db.execute(select(SchoolSubscription).where(SchoolSubscription.school_id == school_id))).scalar_one_or_none()
|
||||||
|
if sub:
|
||||||
|
sub.monthly_fee = body.monthly_fee
|
||||||
|
sub.sms_cost_per_message = body.sms_cost_per_message
|
||||||
|
sub.cycle = body.cycle
|
||||||
|
if body.next_billing_date:
|
||||||
|
sub.next_billing_date = body.next_billing_date
|
||||||
|
else:
|
||||||
|
sub = SchoolSubscription(school_id=school_id, **body.model_dump())
|
||||||
|
db.add(sub)
|
||||||
|
await db.commit()
|
||||||
|
return {"monthly_fee": float(sub.monthly_fee), "cycle": sub.cycle.value}
|
||||||
53
backend/app/routers/dashboard.py
Normal file
53
backend/app/routers/dashboard.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Super admin dashboard summary."""
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func, and_
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from app.auth.dependencies import require_super_admin
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import HubUser
|
||||||
|
from app.models.school import School, SchoolStatus
|
||||||
|
from app.models.license import License, LicenseStatus
|
||||||
|
from app.models.sms import SmsJob, SmsJobStatus
|
||||||
|
from app.models.billing import Invoice, InvoiceStatus
|
||||||
|
from app.models.ticket import SupportTicket, TicketStatus
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
|
@router.get("/summary")
|
||||||
|
async def get_summary(
|
||||||
|
_admin: HubUser = Depends(require_super_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
total_schools = (await db.execute(select(func.count()).select_from(School))).scalar_one()
|
||||||
|
active_schools = (await db.execute(select(func.count()).where(School.status == SchoolStatus.active))).scalar_one()
|
||||||
|
suspended_schools = (await db.execute(select(func.count()).where(School.status == SchoolStatus.suspended))).scalar_one()
|
||||||
|
expiring_soon = (await db.execute(
|
||||||
|
select(func.count()).where(
|
||||||
|
and_(License.expires_at != None, License.expires_at <= date.today() + timedelta(days=30),
|
||||||
|
License.status == LicenseStatus.active)
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
open_tickets = (await db.execute(
|
||||||
|
select(func.count()).where(SupportTicket.status.in_([TicketStatus.open, TicketStatus.in_progress]))
|
||||||
|
)).scalar_one()
|
||||||
|
pending_invoices = (await db.execute(
|
||||||
|
select(func.count()).where(Invoice.status.in_([InvoiceStatus.sent, InvoiceStatus.overdue]))
|
||||||
|
)).scalar_one()
|
||||||
|
sms_today = (await db.execute(
|
||||||
|
select(func.count()).where(
|
||||||
|
and_(func.date(SmsJob.created_at) == date.today(), SmsJob.status == SmsJobStatus.sent)
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
sms_pending = (await db.execute(
|
||||||
|
select(func.count()).where(SmsJob.status == SmsJobStatus.pending)
|
||||||
|
)).scalar_one()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schools": {"total": total_schools, "active": active_schools, "suspended": suspended_schools},
|
||||||
|
"licenses": {"expiring_soon": expiring_soon},
|
||||||
|
"tickets": {"open": open_tickets},
|
||||||
|
"invoices": {"pending": pending_invoices},
|
||||||
|
"sms": {"sent_today": sms_today, "pending": sms_pending},
|
||||||
|
}
|
||||||
116
backend/app/routers/licenses.py
Normal file
116
backend/app/routers/licenses.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""License management endpoints."""
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
|
from app.auth.dependencies import require_super_admin
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import HubUser
|
||||||
|
from app.models.license import License, LicenseStatus
|
||||||
|
from app.models.school import School, SchoolStatus
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/licenses", tags=["licenses"])
|
||||||
|
|
||||||
|
class LicenseUpdate(BaseModel):
|
||||||
|
status: Optional[LicenseStatus] = None
|
||||||
|
expires_at: Optional[date] = None
|
||||||
|
max_students: Optional[int] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_licenses(
|
||||||
|
_admin: HubUser = Depends(require_super_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(License))
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": l.id, "school_id": l.school_id, "key": l.key,
|
||||||
|
"status": l.status.value, "tier": l.tier,
|
||||||
|
"issued_at": l.issued_at.isoformat(),
|
||||||
|
"expires_at": l.expires_at.isoformat() if l.expires_at else None,
|
||||||
|
"last_validated_at": l.last_validated_at.isoformat() if l.last_validated_at else None,
|
||||||
|
"last_seen_ip": l.last_seen_ip,
|
||||||
|
"max_students": l.max_students,
|
||||||
|
}
|
||||||
|
for l in result.scalars().all()
|
||||||
|
]
|
||||||
|
|
||||||
|
@router.put("/{license_id}")
|
||||||
|
async def update_license(
|
||||||
|
license_id: str,
|
||||||
|
body: LicenseUpdate,
|
||||||
|
_admin: HubUser = Depends(require_super_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
lic = (await db.execute(select(License).where(License.id == license_id))).scalar_one_or_none()
|
||||||
|
if not lic:
|
||||||
|
raise HTTPException(404, "License not found")
|
||||||
|
for field, value in body.model_dump(exclude_none=True).items():
|
||||||
|
setattr(lic, field, value)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": lic.id, "status": lic.status.value, "expires_at": lic.expires_at.isoformat() if lic.expires_at else None}
|
||||||
|
|
||||||
|
@router.post("/{license_id}/revoke")
|
||||||
|
async def revoke_license(
|
||||||
|
license_id: str,
|
||||||
|
_admin: HubUser = Depends(require_super_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
lic = (await db.execute(select(License).where(License.id == license_id))).scalar_one_or_none()
|
||||||
|
if not lic:
|
||||||
|
raise HTTPException(404, "License not found")
|
||||||
|
lic.status = LicenseStatus.revoked
|
||||||
|
# Also suspend the school
|
||||||
|
school = (await db.execute(select(School).where(School.id == lic.school_id))).scalar_one_or_none()
|
||||||
|
if school:
|
||||||
|
school.status = SchoolStatus.suspended
|
||||||
|
await db.commit()
|
||||||
|
return {"message": "License revoked"}
|
||||||
|
|
||||||
|
@router.post("/validate")
|
||||||
|
async def validate_license(
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Called by on-prem TapTrack to validate their license key. No auth required — uses key."""
|
||||||
|
body = await request.json()
|
||||||
|
key: str = body.get("key", "")
|
||||||
|
if not key:
|
||||||
|
raise HTTPException(400, "License key required")
|
||||||
|
lic = (await db.execute(select(License).where(License.key == key))).scalar_one_or_none()
|
||||||
|
if not lic:
|
||||||
|
return {"valid": False, "reason": "Key not found"}
|
||||||
|
if lic.status == LicenseStatus.revoked:
|
||||||
|
return {"valid": False, "reason": "License revoked"}
|
||||||
|
if lic.expires_at and lic.expires_at < date.today():
|
||||||
|
lic.status = LicenseStatus.expired
|
||||||
|
await db.commit()
|
||||||
|
return {"valid": False, "reason": "License expired", "expired_at": lic.expires_at.isoformat()}
|
||||||
|
# Update validation metadata
|
||||||
|
lic.last_validated_at = datetime.now(timezone.utc)
|
||||||
|
lic.last_seen_ip = request.client.host if request.client else None
|
||||||
|
school = (await db.execute(select(School).where(School.id == lic.school_id))).scalar_one_or_none()
|
||||||
|
await db.commit()
|
||||||
|
return {
|
||||||
|
"valid": True,
|
||||||
|
"school_id": lic.school_id,
|
||||||
|
"school_name": school.name if school else None,
|
||||||
|
"tier": lic.tier,
|
||||||
|
"max_students": lic.max_students,
|
||||||
|
"expires_at": lic.expires_at.isoformat() if lic.expires_at else None,
|
||||||
|
"sms_sender_name": school.sms_sender_name if school else "SCHOOL",
|
||||||
|
"sms_credits": float(school.sms_credits) if school else 0.0,
|
||||||
|
"features": _tier_features(lic.tier),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _tier_features(tier: str) -> dict:
|
||||||
|
base = {"sms": True, "reports": True, "websocket": True, "multi_terminal": True}
|
||||||
|
if tier == "premium":
|
||||||
|
base.update({"api_keys": True, "webhooks": True, "bulk_enrollment": True})
|
||||||
|
elif tier == "basic":
|
||||||
|
base.update({"multi_terminal": False, "api_keys": False, "webhooks": False})
|
||||||
|
return base
|
||||||
63
backend/app/routers/school_portal.py
Normal file
63
backend/app/routers/school_portal.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""School admin portal — school-scoped read endpoints."""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func, desc, and_
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from app.auth.dependencies import require_school_admin, get_current_user
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import HubUser, UserRole
|
||||||
|
from app.models.school import School
|
||||||
|
from app.models.license import License
|
||||||
|
from app.models.billing import Invoice
|
||||||
|
from app.models.sms import SmsJob, SmsJobStatus
|
||||||
|
from app.models.ticket import SupportTicket
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/portal", tags=["school-portal"])
|
||||||
|
|
||||||
|
async def _get_school(current_user: HubUser, db: AsyncSession) -> School:
|
||||||
|
if not current_user.school_id:
|
||||||
|
raise HTTPException(400, "No school linked to your account")
|
||||||
|
school = (await db.execute(select(School).where(School.id == current_user.school_id))).scalar_one_or_none()
|
||||||
|
if not school:
|
||||||
|
raise HTTPException(404, "School not found")
|
||||||
|
return school
|
||||||
|
|
||||||
|
@router.get("/overview")
|
||||||
|
async def portal_overview(
|
||||||
|
current_user: HubUser = Depends(require_school_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
school = await _get_school(current_user, db)
|
||||||
|
lic = (await db.execute(select(License).where(License.school_id == school.id))).scalar_one_or_none()
|
||||||
|
pending_inv = (await db.execute(
|
||||||
|
select(func.count()).where(
|
||||||
|
and_(Invoice.school_id == school.id, Invoice.status.in_(["sent", "overdue"]))
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
sms_this_month = (await db.execute(
|
||||||
|
select(func.count()).where(
|
||||||
|
and_(SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.sent,
|
||||||
|
func.date_trunc("month", SmsJob.sent_at) == func.date_trunc("month", func.current_date()))
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
open_tickets = (await db.execute(
|
||||||
|
select(func.count()).where(
|
||||||
|
and_(SupportTicket.school_id == school.id, SupportTicket.status.in_(["open", "in_progress"]))
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"school": {"id": school.id, "name": school.name, "status": school.status.value, "tier": school.tier.value},
|
||||||
|
"license": {
|
||||||
|
"key": lic.key if lic else None,
|
||||||
|
"status": lic.status.value if lic else None,
|
||||||
|
"expires_at": lic.expires_at.isoformat() if lic and lic.expires_at else None,
|
||||||
|
"last_seen": lic.last_validated_at.isoformat() if lic and lic.last_validated_at else None,
|
||||||
|
},
|
||||||
|
"sms_credits": float(school.sms_credits),
|
||||||
|
"sms_credit_low_threshold": school.sms_credit_low_threshold,
|
||||||
|
"sms_this_month": sms_this_month,
|
||||||
|
"pending_invoices": pending_inv,
|
||||||
|
"open_tickets": open_tickets,
|
||||||
|
}
|
||||||
181
backend/app/routers/schools.py
Normal file
181
backend/app/routers/schools.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"""School registry endpoints — super admin only."""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional, Any
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func, desc
|
||||||
|
from slugify import slugify
|
||||||
|
|
||||||
|
from app.auth.dependencies import require_super_admin, get_current_user
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import HubUser
|
||||||
|
from app.models.school import School, SchoolStatus, LicenseTier
|
||||||
|
from app.models.license import License, LicenseStatus
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/schools", tags=["schools"])
|
||||||
|
|
||||||
|
class SchoolCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
address: Optional[str] = None
|
||||||
|
city: Optional[str] = None
|
||||||
|
contact_name: Optional[str] = None
|
||||||
|
contact_email: Optional[EmailStr] = None
|
||||||
|
contact_phone: Optional[str] = None
|
||||||
|
billing_email: Optional[EmailStr] = None
|
||||||
|
tier: LicenseTier = LicenseTier.standard
|
||||||
|
student_limit: int = 500
|
||||||
|
sms_sender_name: str = "SCHOOL"
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
class SchoolUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
address: Optional[str] = None
|
||||||
|
city: Optional[str] = None
|
||||||
|
contact_name: Optional[str] = None
|
||||||
|
contact_email: Optional[EmailStr] = None
|
||||||
|
contact_phone: Optional[str] = None
|
||||||
|
billing_email: Optional[EmailStr] = None
|
||||||
|
tier: Optional[LicenseTier] = None
|
||||||
|
student_limit: Optional[int] = None
|
||||||
|
sms_sender_name: Optional[str] = None
|
||||||
|
status: Optional[SchoolStatus] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
def _school_out(s: School, license: License | None = None) -> dict:
|
||||||
|
return {
|
||||||
|
"id": s.id,
|
||||||
|
"name": s.name,
|
||||||
|
"slug": s.slug,
|
||||||
|
"address": s.address,
|
||||||
|
"city": s.city,
|
||||||
|
"contact_name": s.contact_name,
|
||||||
|
"contact_email": s.contact_email,
|
||||||
|
"contact_phone": s.contact_phone,
|
||||||
|
"billing_email": s.billing_email,
|
||||||
|
"status": s.status.value,
|
||||||
|
"tier": s.tier.value,
|
||||||
|
"student_limit": s.student_limit,
|
||||||
|
"sms_sender_name": s.sms_sender_name,
|
||||||
|
"sms_credits": float(s.sms_credits),
|
||||||
|
"sms_credit_low_threshold": s.sms_credit_low_threshold,
|
||||||
|
"created_at": s.created_at.isoformat(),
|
||||||
|
"notes": s.notes,
|
||||||
|
"license_key": license.key if license else None,
|
||||||
|
"license_status": license.status.value if license else None,
|
||||||
|
"license_expires_at": license.expires_at.isoformat() if license and license.expires_at else None,
|
||||||
|
"license_last_seen": license.last_validated_at.isoformat() if license and license.last_validated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_schools(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(25, ge=1, le=100),
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
|
status: Optional[SchoolStatus] = Query(None),
|
||||||
|
_admin: HubUser = Depends(require_super_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
stmt = select(School).order_by(desc(School.created_at))
|
||||||
|
if search:
|
||||||
|
stmt = stmt.where(School.name.ilike(f"%{search}%"))
|
||||||
|
if status:
|
||||||
|
stmt = stmt.where(School.status == status)
|
||||||
|
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
|
||||||
|
schools = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all()
|
||||||
|
items = []
|
||||||
|
for s in schools:
|
||||||
|
lic_res = await db.execute(select(License).where(License.school_id == s.id))
|
||||||
|
lic = lic_res.scalar_one_or_none()
|
||||||
|
items.append(_school_out(s, lic))
|
||||||
|
return {"items": items, "total": total, "page": page, "per_page": per_page}
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
async def create_school(
|
||||||
|
body: SchoolCreate,
|
||||||
|
_admin: HubUser = Depends(require_super_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
slug = slugify(body.name)
|
||||||
|
# Ensure slug uniqueness
|
||||||
|
existing = (await db.execute(select(School).where(School.slug == slug))).scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
slug = f"{slug}-{uuid.uuid4().hex[:6]}"
|
||||||
|
school = School(
|
||||||
|
name=body.name,
|
||||||
|
slug=slug,
|
||||||
|
address=body.address,
|
||||||
|
city=body.city,
|
||||||
|
contact_name=body.contact_name,
|
||||||
|
contact_email=body.contact_email,
|
||||||
|
contact_phone=body.contact_phone,
|
||||||
|
billing_email=body.billing_email,
|
||||||
|
tier=body.tier,
|
||||||
|
student_limit=body.student_limit,
|
||||||
|
sms_sender_name=body.sms_sender_name[:11],
|
||||||
|
notes=body.notes,
|
||||||
|
status=SchoolStatus.pending,
|
||||||
|
)
|
||||||
|
db.add(school)
|
||||||
|
await db.flush()
|
||||||
|
# Auto-create license
|
||||||
|
lic = License(school_id=school.id, tier=body.tier.value, max_students=body.student_limit)
|
||||||
|
db.add(lic)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(school)
|
||||||
|
return _school_out(school, lic)
|
||||||
|
|
||||||
|
@router.get("/{school_id}")
|
||||||
|
async def get_school(
|
||||||
|
school_id: str,
|
||||||
|
_admin: HubUser = Depends(require_super_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none()
|
||||||
|
if not school:
|
||||||
|
raise HTTPException(404, "School not found")
|
||||||
|
lic = (await db.execute(select(License).where(License.school_id == school_id))).scalar_one_or_none()
|
||||||
|
return _school_out(school, lic)
|
||||||
|
|
||||||
|
@router.put("/{school_id}")
|
||||||
|
async def update_school(
|
||||||
|
school_id: str,
|
||||||
|
body: SchoolUpdate,
|
||||||
|
_admin: HubUser = Depends(require_super_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none()
|
||||||
|
if not school:
|
||||||
|
raise HTTPException(404, "School not found")
|
||||||
|
for field, value in body.model_dump(exclude_none=True).items():
|
||||||
|
if field == "sms_sender_name":
|
||||||
|
value = value[:11]
|
||||||
|
setattr(school, field, value)
|
||||||
|
await db.commit()
|
||||||
|
lic = (await db.execute(select(License).where(License.school_id == school_id))).scalar_one_or_none()
|
||||||
|
return _school_out(school, lic)
|
||||||
|
|
||||||
|
@router.post("/{school_id}/credits")
|
||||||
|
async def add_sms_credits(
|
||||||
|
school_id: str,
|
||||||
|
amount: float,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
_admin: HubUser = Depends(require_super_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
from app.models.sms import SmsCreditLedger, SmsCreditTx
|
||||||
|
school = (await db.execute(select(School).where(School.id == school_id))).scalar_one_or_none()
|
||||||
|
if not school:
|
||||||
|
raise HTTPException(404, "School not found")
|
||||||
|
school.sms_credits = float(school.sms_credits) + amount
|
||||||
|
ledger = SmsCreditLedger(
|
||||||
|
school_id=school_id,
|
||||||
|
tx_type=SmsCreditTx.topup,
|
||||||
|
amount=amount,
|
||||||
|
balance_after=float(school.sms_credits),
|
||||||
|
description=description or f"Manual top-up of {amount} credits",
|
||||||
|
)
|
||||||
|
db.add(ledger)
|
||||||
|
await db.commit()
|
||||||
|
return {"sms_credits": float(school.sms_credits), "added": amount}
|
||||||
109
backend/app/routers/sms.py
Normal file
109
backend/app/routers/sms.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""SMS gateway endpoints."""
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func, desc, and_
|
||||||
|
|
||||||
|
from app.auth.dependencies import require_super_admin, require_school_admin, get_current_user
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import HubUser, UserRole
|
||||||
|
from app.models.sms import SmsJob, SmsJobStatus, SmsCreditLedger
|
||||||
|
from app.models.school import School
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/sms", tags=["sms"])
|
||||||
|
|
||||||
|
class SubmitSmsJob(BaseModel):
|
||||||
|
"""Called by on-prem TapTrack to submit SMS jobs to Hub."""
|
||||||
|
license_key: str
|
||||||
|
jobs: list[dict] # [{ recipient_phone, message, trigger }]
|
||||||
|
|
||||||
|
class ManualSmsRequest(BaseModel):
|
||||||
|
school_id: str
|
||||||
|
recipient_phone: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
@router.post("/submit", status_code=202)
|
||||||
|
async def submit_sms_jobs(
|
||||||
|
body: SubmitSmsJob,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""On-prem posts SMS jobs for Hub to process via Semaphore."""
|
||||||
|
from app.models.license import License
|
||||||
|
lic = (await db.execute(select(License).where(License.key == body.license_key))).scalar_one_or_none()
|
||||||
|
if not lic:
|
||||||
|
raise HTTPException(403, "Invalid license key")
|
||||||
|
school = (await db.execute(select(School).where(School.id == lic.school_id))).scalar_one_or_none()
|
||||||
|
if not school or float(school.sms_credits) <= 0:
|
||||||
|
raise HTTPException(402, "Insufficient SMS credits")
|
||||||
|
|
||||||
|
created_ids = []
|
||||||
|
for job_data in body.jobs:
|
||||||
|
job = SmsJob(
|
||||||
|
school_id=school.id,
|
||||||
|
recipient_phone=job_data.get("recipient_phone", ""),
|
||||||
|
message=job_data.get("message", ""),
|
||||||
|
sender_name=school.sms_sender_name,
|
||||||
|
trigger=job_data.get("trigger"),
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
created_ids.append(job.id)
|
||||||
|
await db.commit()
|
||||||
|
return {"queued": len(created_ids), "job_ids": created_ids}
|
||||||
|
|
||||||
|
@router.get("/jobs")
|
||||||
|
async def list_sms_jobs(
|
||||||
|
school_id: Optional[str] = Query(None),
|
||||||
|
status: Optional[SmsJobStatus] = Query(None),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(50, ge=1, le=200),
|
||||||
|
current_user: HubUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
stmt = select(SmsJob).order_by(desc(SmsJob.created_at))
|
||||||
|
if current_user.role != UserRole.super_admin:
|
||||||
|
stmt = stmt.where(SmsJob.school_id == current_user.school_id)
|
||||||
|
elif school_id:
|
||||||
|
stmt = stmt.where(SmsJob.school_id == school_id)
|
||||||
|
if status:
|
||||||
|
stmt = stmt.where(SmsJob.status == status)
|
||||||
|
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
|
||||||
|
jobs = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all()
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": j.id, "school_id": j.school_id, "recipient_phone": j.recipient_phone,
|
||||||
|
"message": j.message[:60] + "..." if len(j.message) > 60 else j.message,
|
||||||
|
"sender_name": j.sender_name, "status": j.status.value,
|
||||||
|
"trigger": j.trigger, "created_at": j.created_at.isoformat(),
|
||||||
|
"sent_at": j.sent_at.isoformat() if j.sent_at else None,
|
||||||
|
"retry_count": j.retry_count, "error_message": j.error_message,
|
||||||
|
}
|
||||||
|
for j in jobs
|
||||||
|
],
|
||||||
|
"total": total, "page": page, "per_page": per_page,
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/credits/{school_id}")
|
||||||
|
async def get_credit_ledger(
|
||||||
|
school_id: str,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(50),
|
||||||
|
current_user: HubUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if current_user.role != UserRole.super_admin and current_user.school_id != school_id:
|
||||||
|
raise HTTPException(403)
|
||||||
|
stmt = select(SmsCreditLedger).where(SmsCreditLedger.school_id == school_id).order_by(desc(SmsCreditLedger.created_at))
|
||||||
|
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
|
||||||
|
rows = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all()
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
{"id": r.id, "tx_type": r.tx_type.value, "amount": float(r.amount),
|
||||||
|
"balance_after": float(r.balance_after), "description": r.description,
|
||||||
|
"created_at": r.created_at.isoformat()}
|
||||||
|
for r in rows
|
||||||
|
],
|
||||||
|
"total": total,
|
||||||
|
}
|
||||||
77
backend/app/routers/sync.py
Normal file
77
backend/app/routers/sync.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""On-prem sync endpoint — polled by TapTrack every 30s to get SMS jobs and config."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, and_, update
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.license import License, LicenseStatus
|
||||||
|
from app.models.school import School
|
||||||
|
from app.models.sms import SmsJob, SmsJobStatus
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
||||||
|
|
||||||
|
@router.post("/poll")
|
||||||
|
async def sync_poll(
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Called by on-prem TapTrack every 30s.
|
||||||
|
Returns pending SMS jobs and current config (sender_name, credits, feature flags).
|
||||||
|
Body: { license_key: str, report_sent_ids: [str] } (completed job IDs to mark as sent)
|
||||||
|
"""
|
||||||
|
body = await request.json()
|
||||||
|
key = body.get("license_key", "")
|
||||||
|
sent_ids = body.get("report_sent_ids", [])
|
||||||
|
|
||||||
|
lic = (await db.execute(select(License).where(License.key == key))).scalar_one_or_none()
|
||||||
|
if not lic or lic.status == LicenseStatus.revoked:
|
||||||
|
raise HTTPException(403, "Invalid or revoked license")
|
||||||
|
|
||||||
|
school = (await db.execute(select(School).where(School.id == lic.school_id))).scalar_one_or_none()
|
||||||
|
if not school:
|
||||||
|
raise HTTPException(404)
|
||||||
|
|
||||||
|
# Mark completed jobs
|
||||||
|
if sent_ids:
|
||||||
|
await db.execute(
|
||||||
|
update(SmsJob)
|
||||||
|
.where(and_(SmsJob.id.in_(sent_ids), SmsJob.school_id == school.id))
|
||||||
|
.values(status=SmsJobStatus.sent, sent_at=datetime.now(timezone.utc))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get pending jobs (max 50 per poll)
|
||||||
|
pending_jobs = (await db.execute(
|
||||||
|
select(SmsJob)
|
||||||
|
.where(and_(SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.pending))
|
||||||
|
.limit(50)
|
||||||
|
)).scalars().all()
|
||||||
|
|
||||||
|
# Mark as processing
|
||||||
|
job_ids = [j.id for j in pending_jobs]
|
||||||
|
if job_ids:
|
||||||
|
await db.execute(
|
||||||
|
update(SmsJob)
|
||||||
|
.where(SmsJob.id.in_(job_ids))
|
||||||
|
.values(status=SmsJobStatus.processing)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update last seen
|
||||||
|
lic.last_validated_at = datetime.now(timezone.utc)
|
||||||
|
lic.last_seen_ip = request.client.host if request.client else None
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"sms_jobs": [
|
||||||
|
{"id": j.id, "recipient_phone": j.recipient_phone,
|
||||||
|
"message": j.message, "sender_name": j.sender_name}
|
||||||
|
for j in pending_jobs
|
||||||
|
],
|
||||||
|
"config": {
|
||||||
|
"sms_sender_name": school.sms_sender_name,
|
||||||
|
"sms_credits": float(school.sms_credits),
|
||||||
|
"school_status": school.status.value,
|
||||||
|
},
|
||||||
|
}
|
||||||
149
backend/app/routers/tickets.py
Normal file
149
backend/app/routers/tickets.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"""Support ticket endpoints."""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func, desc
|
||||||
|
|
||||||
|
from app.auth.dependencies import require_super_admin, get_current_user
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import HubUser, UserRole
|
||||||
|
from app.models.ticket import SupportTicket, TicketReply, TicketStatus, TicketPriority, TicketCategory
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/tickets", tags=["tickets"])
|
||||||
|
|
||||||
|
class TicketCreate(BaseModel):
|
||||||
|
subject: str
|
||||||
|
body: str
|
||||||
|
category: TicketCategory = TicketCategory.general
|
||||||
|
|
||||||
|
class TicketUpdate(BaseModel):
|
||||||
|
status: Optional[TicketStatus] = None
|
||||||
|
priority: Optional[TicketPriority] = None
|
||||||
|
assigned_to: Optional[str] = None
|
||||||
|
|
||||||
|
class ReplyCreate(BaseModel):
|
||||||
|
body: str
|
||||||
|
is_internal: bool = False
|
||||||
|
|
||||||
|
def _ticket_out(t: SupportTicket) -> dict:
|
||||||
|
return {
|
||||||
|
"id": t.id, "school_id": t.school_id, "ticket_number": t.ticket_number,
|
||||||
|
"subject": t.subject, "body": t.body, "category": t.category.value,
|
||||||
|
"status": t.status.value, "priority": t.priority.value,
|
||||||
|
"assigned_to": t.assigned_to,
|
||||||
|
"first_response_at": t.first_response_at.isoformat() if t.first_response_at else None,
|
||||||
|
"resolved_at": t.resolved_at.isoformat() if t.resolved_at else None,
|
||||||
|
"created_at": t.created_at.isoformat(),
|
||||||
|
"updated_at": t.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _next_ticket_number(count: int) -> str:
|
||||||
|
from datetime import date
|
||||||
|
return f"TKT-{date.today().year}-{count + 1:05d}"
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_tickets(
|
||||||
|
school_id: Optional[str] = Query(None),
|
||||||
|
status: Optional[TicketStatus] = Query(None),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(25),
|
||||||
|
current_user: HubUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
stmt = select(SupportTicket).order_by(desc(SupportTicket.updated_at))
|
||||||
|
if current_user.role != UserRole.super_admin:
|
||||||
|
stmt = stmt.where(SupportTicket.school_id == current_user.school_id)
|
||||||
|
elif school_id:
|
||||||
|
stmt = stmt.where(SupportTicket.school_id == school_id)
|
||||||
|
if status:
|
||||||
|
stmt = stmt.where(SupportTicket.status == status)
|
||||||
|
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
|
||||||
|
tickets = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all()
|
||||||
|
return {"items": [_ticket_out(t) for t in tickets], "total": total, "page": page, "per_page": per_page}
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
async def create_ticket(
|
||||||
|
body: TicketCreate,
|
||||||
|
current_user: HubUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if not current_user.school_id:
|
||||||
|
raise HTTPException(400, "No school associated with your account")
|
||||||
|
count = (await db.execute(select(func.count()).select_from(SupportTicket))).scalar_one()
|
||||||
|
ticket = SupportTicket(
|
||||||
|
school_id=current_user.school_id,
|
||||||
|
submitted_by=current_user.id,
|
||||||
|
ticket_number=_next_ticket_number(count),
|
||||||
|
subject=body.subject,
|
||||||
|
body=body.body,
|
||||||
|
category=body.category,
|
||||||
|
)
|
||||||
|
db.add(ticket)
|
||||||
|
await db.commit()
|
||||||
|
return _ticket_out(ticket)
|
||||||
|
|
||||||
|
@router.get("/{ticket_id}")
|
||||||
|
async def get_ticket(
|
||||||
|
ticket_id: str,
|
||||||
|
current_user: HubUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
t = (await db.execute(select(SupportTicket).where(SupportTicket.id == ticket_id))).scalar_one_or_none()
|
||||||
|
if not t:
|
||||||
|
raise HTTPException(404)
|
||||||
|
if current_user.role != UserRole.super_admin and t.school_id != current_user.school_id:
|
||||||
|
raise HTTPException(403)
|
||||||
|
replies_res = await db.execute(select(TicketReply).where(TicketReply.ticket_id == ticket_id).order_by(TicketReply.created_at))
|
||||||
|
replies = replies_res.scalars().all()
|
||||||
|
visible_replies = [r for r in replies if not r.is_internal or current_user.role == UserRole.super_admin]
|
||||||
|
return {
|
||||||
|
**_ticket_out(t),
|
||||||
|
"replies": [
|
||||||
|
{"id": r.id, "body": r.body, "is_internal": r.is_internal,
|
||||||
|
"author_id": r.author_id, "created_at": r.created_at.isoformat()}
|
||||||
|
for r in visible_replies
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.put("/{ticket_id}")
|
||||||
|
async def update_ticket(
|
||||||
|
ticket_id: str,
|
||||||
|
body: TicketUpdate,
|
||||||
|
_admin: HubUser = Depends(require_super_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
t = (await db.execute(select(SupportTicket).where(SupportTicket.id == ticket_id))).scalar_one_or_none()
|
||||||
|
if not t:
|
||||||
|
raise HTTPException(404)
|
||||||
|
for field, value in body.model_dump(exclude_none=True).items():
|
||||||
|
setattr(t, field, value)
|
||||||
|
if body.status in (TicketStatus.resolved, TicketStatus.closed) and not t.resolved_at:
|
||||||
|
t.resolved_at = datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
return _ticket_out(t)
|
||||||
|
|
||||||
|
@router.post("/{ticket_id}/replies", status_code=201)
|
||||||
|
async def add_reply(
|
||||||
|
ticket_id: str,
|
||||||
|
body: ReplyCreate,
|
||||||
|
current_user: HubUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
t = (await db.execute(select(SupportTicket).where(SupportTicket.id == ticket_id))).scalar_one_or_none()
|
||||||
|
if not t:
|
||||||
|
raise HTTPException(404)
|
||||||
|
if current_user.role != UserRole.super_admin and t.school_id != current_user.school_id:
|
||||||
|
raise HTTPException(403)
|
||||||
|
is_internal = body.is_internal and current_user.role == UserRole.super_admin
|
||||||
|
reply = TicketReply(ticket_id=ticket_id, author_id=current_user.id, body=body.body, is_internal=is_internal)
|
||||||
|
db.add(reply)
|
||||||
|
# Set first response time (super admin only)
|
||||||
|
if current_user.role == UserRole.super_admin and not t.first_response_at:
|
||||||
|
t.first_response_at = datetime.now(timezone.utc)
|
||||||
|
if t.status == TicketStatus.open:
|
||||||
|
t.status = TicketStatus.in_progress
|
||||||
|
await db.commit()
|
||||||
|
return {"id": reply.id, "body": reply.body, "created_at": reply.created_at.isoformat()}
|
||||||
78
backend/app/routers/users.py
Normal file
78
backend/app/routers/users.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Hub user management — super admin only."""
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func, desc
|
||||||
|
|
||||||
|
from app.auth.dependencies import require_super_admin
|
||||||
|
from app.auth.password import hash_password
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import HubUser, UserRole
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
full_name: str
|
||||||
|
password: str
|
||||||
|
role: UserRole = UserRole.school_admin
|
||||||
|
school_id: Optional[str] = None
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
school_id: Optional[str] = None
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_users(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(25),
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
|
_admin: HubUser = Depends(require_super_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
stmt = select(HubUser).order_by(desc(HubUser.created_at))
|
||||||
|
if search:
|
||||||
|
stmt = stmt.where(HubUser.email.ilike(f"%{search}%") | HubUser.full_name.ilike(f"%{search}%"))
|
||||||
|
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
|
||||||
|
users = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all()
|
||||||
|
return {
|
||||||
|
"items": [{"id": u.id, "email": u.email, "full_name": u.full_name, "role": u.role.value,
|
||||||
|
"school_id": u.school_id, "is_active": u.is_active,
|
||||||
|
"created_at": u.created_at.isoformat()} for u in users],
|
||||||
|
"total": total,
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
async def create_user(
|
||||||
|
body: UserCreate,
|
||||||
|
_admin: HubUser = Depends(require_super_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
existing = (await db.execute(select(HubUser).where(HubUser.email == body.email))).scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(409, "Email already exists")
|
||||||
|
if len(body.password) < 8:
|
||||||
|
raise HTTPException(422, "Password must be at least 8 characters")
|
||||||
|
user = HubUser(email=body.email, full_name=body.full_name,
|
||||||
|
hashed_password=hash_password(body.password),
|
||||||
|
role=body.role, school_id=body.school_id)
|
||||||
|
db.add(user)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": user.id, "email": user.email, "full_name": user.full_name, "role": user.role.value}
|
||||||
|
|
||||||
|
@router.put("/{user_id}")
|
||||||
|
async def update_user(
|
||||||
|
user_id: str,
|
||||||
|
body: UserUpdate,
|
||||||
|
_admin: HubUser = Depends(require_super_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
user = (await db.execute(select(HubUser).where(HubUser.id == user_id))).scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(404)
|
||||||
|
for field, value in body.model_dump(exclude_none=True).items():
|
||||||
|
setattr(user, field, value)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": user.id, "email": user.email, "is_active": user.is_active}
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
32
backend/app/services/email.py
Normal file
32
backend/app/services/email.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""Simple SMTP email service."""
|
||||||
|
import smtplib
|
||||||
|
import logging
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def send_email(to: str, subject: str, body: str, html: str | None = None) -> bool:
|
||||||
|
"""Send email via configured SMTP. Returns True on success."""
|
||||||
|
if not settings.SMTP_HOST:
|
||||||
|
logger.warning(f"SMTP not configured — would send to {to}: {subject}")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = settings.SMTP_FROM
|
||||||
|
msg["To"] = to
|
||||||
|
msg.attach(MIMEText(body, "plain"))
|
||||||
|
if html:
|
||||||
|
msg.attach(MIMEText(html, "html"))
|
||||||
|
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as smtp:
|
||||||
|
smtp.starttls()
|
||||||
|
if settings.SMTP_USER:
|
||||||
|
smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||||
|
smtp.sendmail(settings.SMTP_FROM, [to], msg.as_string())
|
||||||
|
logger.info(f"Email sent to {to}: {subject}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Email failed to {to}: {e}")
|
||||||
|
return False
|
||||||
0
backend/app/tasks/__init__.py
Normal file
0
backend/app/tasks/__init__.py
Normal file
125
backend/app/tasks/billing.py
Normal file
125
backend/app/tasks/billing.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""Celery tasks: invoice generation, email, overdue checks."""
|
||||||
|
import logging
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from app.worker import celery_app
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def _make_session():
|
||||||
|
import os
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub")
|
||||||
|
engine = create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True)
|
||||||
|
return sessionmaker(bind=engine)()
|
||||||
|
|
||||||
|
@celery_app.task(name="billing.generate_monthly_invoices")
|
||||||
|
def generate_monthly_invoices():
|
||||||
|
"""On the 1st: create draft invoices for all active schools with a subscription."""
|
||||||
|
from app.models.billing import Invoice, SchoolSubscription, InvoiceLineItem, BillingCycle
|
||||||
|
from app.models.school import School, SchoolStatus
|
||||||
|
from sqlalchemy import select
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
db = _make_session()
|
||||||
|
try:
|
||||||
|
today = date.today()
|
||||||
|
period_start = date(today.year, today.month, 1)
|
||||||
|
prev_month = (period_start - timedelta(days=1))
|
||||||
|
billing_start = date(prev_month.year, prev_month.month, 1)
|
||||||
|
billing_end = period_start - timedelta(days=1)
|
||||||
|
|
||||||
|
subs = db.execute(select(SchoolSubscription).where(SchoolSubscription.is_active == True)).scalars().all()
|
||||||
|
count = db.execute(select(func.count()).select_from(Invoice)).scalar_one()
|
||||||
|
|
||||||
|
for sub in subs:
|
||||||
|
school = db.get(School, sub.school_id)
|
||||||
|
if not school or school.status != SchoolStatus.active:
|
||||||
|
continue
|
||||||
|
total = float(sub.monthly_fee)
|
||||||
|
inv_num = f"INV-{billing_start.strftime('%Y%m')}-{count + 1:04d}"
|
||||||
|
count += 1
|
||||||
|
inv = Invoice(
|
||||||
|
school_id=sub.school_id,
|
||||||
|
invoice_number=inv_num,
|
||||||
|
billing_period_start=billing_start,
|
||||||
|
billing_period_end=billing_end,
|
||||||
|
subscription_amount=float(sub.monthly_fee),
|
||||||
|
total_amount=total,
|
||||||
|
due_date=period_start + timedelta(days=14),
|
||||||
|
)
|
||||||
|
db.add(inv)
|
||||||
|
db.flush()
|
||||||
|
db.add(InvoiceLineItem(
|
||||||
|
invoice_id=inv.id,
|
||||||
|
description=f"Monthly subscription — {school.name}",
|
||||||
|
quantity=1,
|
||||||
|
unit_price=float(sub.monthly_fee),
|
||||||
|
amount=float(sub.monthly_fee),
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Generated {len(subs)} invoices for {billing_start}")
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"generate_monthly_invoices error: {e}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@celery_app.task(name="billing.send_invoice_email")
|
||||||
|
def send_invoice_email_task(invoice_id: str):
|
||||||
|
"""Send invoice email to school billing contact."""
|
||||||
|
from app.models.billing import Invoice, InvoiceStatus
|
||||||
|
from app.models.school import School
|
||||||
|
from app.services.email import send_email
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
db = _make_session()
|
||||||
|
try:
|
||||||
|
inv = db.get(Invoice, invoice_id)
|
||||||
|
if not inv:
|
||||||
|
return
|
||||||
|
school = db.get(School, inv.school_id)
|
||||||
|
if not school or not school.billing_email:
|
||||||
|
return
|
||||||
|
body = f"""Dear {school.contact_name or school.name},
|
||||||
|
|
||||||
|
Please find your invoice {inv.invoice_number} for the period {inv.billing_period_start} to {inv.billing_period_end}.
|
||||||
|
|
||||||
|
Amount Due: PHP {float(inv.total_amount):,.2f}
|
||||||
|
Due Date: {inv.due_date}
|
||||||
|
|
||||||
|
Please log in to your TapTrack Hub portal to view and pay your invoice.
|
||||||
|
|
||||||
|
Thank you,
|
||||||
|
TapTrack Hub Team
|
||||||
|
"""
|
||||||
|
send_email(to=school.billing_email, subject=f"Invoice {inv.invoice_number} — TapTrack Hub", body=body)
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
inv.email_sent_at = datetime.now(timezone.utc)
|
||||||
|
if inv.status.value == "draft":
|
||||||
|
inv.status = InvoiceStatus.sent
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@celery_app.task(name="billing.check_overdue")
|
||||||
|
def check_overdue():
|
||||||
|
"""Mark overdue invoices and send warning emails."""
|
||||||
|
from app.models.billing import Invoice, InvoiceStatus
|
||||||
|
from sqlalchemy import select, and_
|
||||||
|
|
||||||
|
db = _make_session()
|
||||||
|
try:
|
||||||
|
today = date.today()
|
||||||
|
overdue = db.execute(
|
||||||
|
select(Invoice).where(
|
||||||
|
and_(Invoice.status == InvoiceStatus.sent, Invoice.due_date < today, Invoice.due_date != None)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for inv in overdue:
|
||||||
|
inv.status = InvoiceStatus.overdue
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Marked {len(overdue)} invoices as overdue")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
41
backend/app/tasks/license.py
Normal file
41
backend/app/tasks/license.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""Celery task: license expiry checks and alerts."""
|
||||||
|
import logging
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from app.worker import celery_app
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@celery_app.task(name="license.check_expiry")
|
||||||
|
def check_expiry():
|
||||||
|
"""Send expiry warning emails for licenses expiring in 30, 14, or 7 days."""
|
||||||
|
import os
|
||||||
|
from sqlalchemy import create_engine, select, and_
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from app.models.license import License, LicenseStatus
|
||||||
|
from app.models.school import School
|
||||||
|
from app.services.email import send_email
|
||||||
|
|
||||||
|
db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub")
|
||||||
|
engine = create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True)
|
||||||
|
db = sessionmaker(bind=engine)()
|
||||||
|
try:
|
||||||
|
today = date.today()
|
||||||
|
for days_ahead in [30, 14, 7]:
|
||||||
|
target = today + timedelta(days=days_ahead)
|
||||||
|
expiring = db.execute(
|
||||||
|
select(License).where(
|
||||||
|
and_(License.expires_at == target, License.status == LicenseStatus.active)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for lic in expiring:
|
||||||
|
school = db.get(School, lic.school_id)
|
||||||
|
if school and school.billing_email:
|
||||||
|
send_email(
|
||||||
|
to=school.billing_email,
|
||||||
|
subject=f"[TapTrack Hub] License expires in {days_ahead} days — {school.name}",
|
||||||
|
body=f"Your TapTrack license for {school.name} expires on {lic.expires_at}. Please contact us to renew.",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
35
backend/app/tasks/reports.py
Normal file
35
backend/app/tasks/reports.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""Celery task: send monthly reports to schools."""
|
||||||
|
import logging
|
||||||
|
from app.worker import celery_app
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@celery_app.task(name="reports.send_monthly_reports")
|
||||||
|
def send_monthly_reports():
|
||||||
|
"""Send monthly attendance and SMS report email to each active school."""
|
||||||
|
import os
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from app.models.school import School, SchoolStatus
|
||||||
|
from app.services.email import send_email
|
||||||
|
|
||||||
|
db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub")
|
||||||
|
engine = create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True)
|
||||||
|
db = sessionmaker(bind=engine)()
|
||||||
|
try:
|
||||||
|
today = date.today()
|
||||||
|
prev_month_end = date(today.year, today.month, 1) - timedelta(days=1)
|
||||||
|
prev_month_start = date(prev_month_end.year, prev_month_end.month, 1)
|
||||||
|
schools = db.execute(select(School).where(School.status == SchoolStatus.active)).scalars().all()
|
||||||
|
for school in schools:
|
||||||
|
if not school.billing_email:
|
||||||
|
continue
|
||||||
|
send_email(
|
||||||
|
to=school.billing_email,
|
||||||
|
subject=f"Monthly Report — {school.name} — {prev_month_start.strftime('%B %Y')}",
|
||||||
|
body=f"Dear {school.contact_name or school.name},\n\nPlease find your monthly summary for {prev_month_start.strftime('%B %Y')} in your TapTrack Hub portal.\n\nSMS Credits Remaining: {float(school.sms_credits):.0f}\n\nLog in to view full details.\n\nThank you,\nTapTrack Hub Team",
|
||||||
|
)
|
||||||
|
logger.info(f"Sent monthly reports to {len(schools)} schools")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
102
backend/app/tasks/sms.py
Normal file
102
backend/app/tasks/sms.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""Celery task: process pending SMS jobs via Semaphore."""
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import create_engine, select, update, and_
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from app.worker import celery_app
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def _make_sync_engine():
|
||||||
|
db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub")
|
||||||
|
return create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True, pool_size=2)
|
||||||
|
|
||||||
|
_engine = _make_sync_engine()
|
||||||
|
_Session = sessionmaker(bind=_engine)
|
||||||
|
|
||||||
|
@celery_app.task(name="sms.process_queue")
|
||||||
|
def process_sms_queue():
|
||||||
|
"""Process up to 20 pending SMS jobs per run via Semaphore API."""
|
||||||
|
from app.models.sms import SmsJob, SmsJobStatus
|
||||||
|
from app.models.school import School
|
||||||
|
|
||||||
|
db = _Session()
|
||||||
|
try:
|
||||||
|
jobs = db.execute(
|
||||||
|
select(SmsJob).where(SmsJob.status == SmsJobStatus.pending).limit(20)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
for job in jobs:
|
||||||
|
school = db.get(School, job.school_id)
|
||||||
|
if not school or float(school.sms_credits) <= 0:
|
||||||
|
job.status = SmsJobStatus.cancelled
|
||||||
|
job.error_message = "Insufficient credits"
|
||||||
|
db.commit()
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = _send_semaphore(job.recipient_phone, job.message, job.sender_name)
|
||||||
|
if result["success"]:
|
||||||
|
job.status = SmsJobStatus.sent
|
||||||
|
job.sent_at = datetime.now(timezone.utc)
|
||||||
|
job.semaphore_message_id = result.get("message_id")
|
||||||
|
# Deduct credit
|
||||||
|
from app.models.sms import SmsCreditLedger, SmsCreditTx
|
||||||
|
school.sms_credits = float(school.sms_credits) - 1.0
|
||||||
|
db.add(SmsCreditLedger(
|
||||||
|
school_id=school.id,
|
||||||
|
tx_type=SmsCreditTx.deduct,
|
||||||
|
amount=-1.0,
|
||||||
|
balance_after=float(school.sms_credits),
|
||||||
|
description=f"SMS sent to {job.recipient_phone}",
|
||||||
|
reference_id=job.id,
|
||||||
|
))
|
||||||
|
# Low credit alert
|
||||||
|
if float(school.sms_credits) <= school.sms_credit_low_threshold:
|
||||||
|
send_low_credit_alert.delay(school.id)
|
||||||
|
else:
|
||||||
|
job.retry_count += 1
|
||||||
|
if job.retry_count >= 5:
|
||||||
|
job.status = SmsJobStatus.failed
|
||||||
|
job.error_message = result.get("error")
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def _send_semaphore(phone: str, message: str, sender: str) -> dict:
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=15) as client:
|
||||||
|
resp = client.post(settings.SEMAPHORE_URL, data={
|
||||||
|
"apikey": settings.SEMAPHORE_API_KEY,
|
||||||
|
"number": phone,
|
||||||
|
"message": message,
|
||||||
|
"sendername": sender,
|
||||||
|
})
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
msg_id = str(data[0].get("message_id", "")) if isinstance(data, list) and data else None
|
||||||
|
return {"success": True, "message_id": msg_id}
|
||||||
|
return {"success": False, "error": f"HTTP {resp.status_code}"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
@celery_app.task(name="sms.send_low_credit_alert")
|
||||||
|
def send_low_credit_alert(school_id: str):
|
||||||
|
"""Send low credit warning email to school billing contact."""
|
||||||
|
from app.services.email import send_email
|
||||||
|
from app.models.school import School
|
||||||
|
db = _Session()
|
||||||
|
try:
|
||||||
|
school = db.get(School, school_id)
|
||||||
|
if school and school.billing_email:
|
||||||
|
send_email(
|
||||||
|
to=school.billing_email,
|
||||||
|
subject=f"[TapTrack Hub] Low SMS Credits — {school.name}",
|
||||||
|
body=f"Your SMS credit balance for {school.name} is low ({float(school.sms_credits):.0f} remaining). Please top up to continue sending SMS notifications.",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
55
backend/app/worker.py
Normal file
55
backend/app/worker.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""Celery worker + beat schedule for TapTrack Hub."""
|
||||||
|
import os
|
||||||
|
from celery import Celery
|
||||||
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
|
||||||
|
|
||||||
|
celery_app = Celery(
|
||||||
|
"taptrack_hub",
|
||||||
|
broker=REDIS_URL,
|
||||||
|
backend=REDIS_URL,
|
||||||
|
include=[
|
||||||
|
"app.tasks.sms",
|
||||||
|
"app.tasks.billing",
|
||||||
|
"app.tasks.reports",
|
||||||
|
"app.tasks.license",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
celery_app.conf.update(
|
||||||
|
task_serializer="json",
|
||||||
|
accept_content=["json"],
|
||||||
|
result_serializer="json",
|
||||||
|
timezone="Asia/Manila",
|
||||||
|
enable_utc=True,
|
||||||
|
beat_schedule={
|
||||||
|
# Process pending SMS jobs every 30 seconds
|
||||||
|
"process-sms-queue": {
|
||||||
|
"task": "sms.process_queue",
|
||||||
|
"schedule": 30.0,
|
||||||
|
},
|
||||||
|
# Check license expiry every day at 8am
|
||||||
|
"check-license-expiry": {
|
||||||
|
"task": "license.check_expiry",
|
||||||
|
"schedule": crontab(hour=8, minute=0),
|
||||||
|
},
|
||||||
|
# Generate monthly invoices on the 1st at 6am
|
||||||
|
"generate-monthly-invoices": {
|
||||||
|
"task": "billing.generate_monthly_invoices",
|
||||||
|
"schedule": crontab(day_of_month=1, hour=6, minute=0),
|
||||||
|
},
|
||||||
|
# Send monthly reports on the 1st at 7am
|
||||||
|
"send-monthly-reports": {
|
||||||
|
"task": "reports.send_monthly_reports",
|
||||||
|
"schedule": crontab(day_of_month=1, hour=7, minute=0),
|
||||||
|
},
|
||||||
|
# Check for overdue invoices daily at 9am
|
||||||
|
"check-overdue-invoices": {
|
||||||
|
"task": "billing.check_overdue",
|
||||||
|
"schedule": crontab(hour=9, minute=0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
app = celery_app
|
||||||
40
backend/migrations/env.py
Normal file
40
backend/migrations/env.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from logging.config import fileConfig
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
from app.models import user, school, license, sms, billing, ticket, audit, announcement # noqa
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub")
|
||||||
|
|
||||||
|
def run_migrations_offline():
|
||||||
|
context.configure(url=DATABASE_URL.replace("+asyncpg", ""), target_metadata=target_metadata, literal_binds=True)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
def do_run_migrations(connection):
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
async def run_async_migrations():
|
||||||
|
connectable = create_async_engine(DATABASE_URL, poolclass=pool.NullPool)
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
def run_migrations_online():
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
26
backend/migrations/script.py.mako
Normal file
26
backend/migrations/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
18
backend/requirements.txt
Normal file
18
backend/requirements.txt
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.32.1
|
||||||
|
gunicorn==23.0.0
|
||||||
|
sqlalchemy[asyncio]==2.0.36
|
||||||
|
asyncpg==0.30.0
|
||||||
|
alembic==1.14.0
|
||||||
|
pydantic[email]==2.10.3
|
||||||
|
pydantic-settings==2.7.0
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
python-multipart==0.0.20
|
||||||
|
celery==5.4.0
|
||||||
|
redis==5.2.1
|
||||||
|
httpx==0.28.1
|
||||||
|
jinja2==3.1.4
|
||||||
|
weasyprint==62.3
|
||||||
|
python-slugify==8.0.4
|
||||||
|
psycopg2-binary==2.9.10
|
||||||
37
backend/seed.py
Normal file
37
backend/seed.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""Seed the database with a default super admin account."""
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.database import Base
|
||||||
|
from app.models.user import HubUser, UserRole
|
||||||
|
from app.auth.password import hash_password
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub")
|
||||||
|
|
||||||
|
async def seed():
|
||||||
|
engine = create_async_engine(DATABASE_URL)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
async with session_factory() as db:
|
||||||
|
existing = (await db.execute(select(HubUser).where(HubUser.role == UserRole.super_admin))).scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
print(f"Super admin already exists: {existing.email}")
|
||||||
|
return
|
||||||
|
admin = HubUser(
|
||||||
|
email="admin@taptrack.io",
|
||||||
|
full_name="TapTrack Admin",
|
||||||
|
hashed_password=hash_password("admin123!"),
|
||||||
|
role=UserRole.super_admin,
|
||||||
|
)
|
||||||
|
db.add(admin)
|
||||||
|
await db.commit()
|
||||||
|
print(f"Created super admin: admin@taptrack.io / admin123!")
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(seed())
|
||||||
115
docker-compose.yml
Normal file
115
docker-compose.yml
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: taptrack_hub
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
SECRET_KEY: changeme-in-production-use-openssl-rand-hex-32
|
||||||
|
ENVIRONMENT: development
|
||||||
|
SEMAPHORE_API_KEY: ""
|
||||||
|
SMTP_HOST: ""
|
||||||
|
SMTP_PORT: "587"
|
||||||
|
SMTP_USER: ""
|
||||||
|
SMTP_PASSWORD: ""
|
||||||
|
SMTP_FROM: "noreply@taptrack.io"
|
||||||
|
HUB_BASE_URL: "http://localhost:8080"
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- backend_data:/app/data
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
celery:
|
||||||
|
build: ./backend
|
||||||
|
command: celery -A app.worker worker --loglevel=info --concurrency=2
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
SECRET_KEY: changeme-in-production-use-openssl-rand-hex-32
|
||||||
|
ENVIRONMENT: development
|
||||||
|
SEMAPHORE_API_KEY: ""
|
||||||
|
SMTP_HOST: ""
|
||||||
|
SMTP_PORT: "587"
|
||||||
|
SMTP_USER: ""
|
||||||
|
SMTP_PASSWORD: ""
|
||||||
|
SMTP_FROM: "noreply@taptrack.io"
|
||||||
|
HUB_BASE_URL: "http://localhost:8080"
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- backend_data:/app/data
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
|
||||||
|
celery-beat:
|
||||||
|
build: ./backend
|
||||||
|
command: celery -A app.worker beat --loglevel=info
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
SECRET_KEY: changeme-in-production-use-openssl-rand-hex-32
|
||||||
|
ENVIRONMENT: development
|
||||||
|
SEMAPHORE_API_KEY: ""
|
||||||
|
SMTP_HOST: ""
|
||||||
|
SMTP_PORT: "587"
|
||||||
|
SMTP_USER: ""
|
||||||
|
SMTP_PASSWORD: ""
|
||||||
|
SMTP_FROM: "noreply@taptrack.io"
|
||||||
|
HUB_BASE_URL: "http://localhost:8080"
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- backend_data:/app/data
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
- frontend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
|
redis_data:
|
||||||
|
backend_data:
|
||||||
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>TapTrack Hub</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
10
frontend/nginx.conf
Normal file
10
frontend/nginx.conf
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
location / { try_files $uri $uri/ /index.html; }
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "taptrack-hub-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"lucide-vue-next": "^0.469.0",
|
||||||
|
"pinia": "^2.3.0",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.5.1",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^6.0.7",
|
||||||
|
"vue-tsc": "^2.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
frontend/postcss.config.js
Normal file
3
frontend/postcss.config.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||||
|
}
|
||||||
26
frontend/src/App.vue
Normal file
26
frontend/src/App.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<component :is="layout">
|
||||||
|
<RouterView />
|
||||||
|
</component>
|
||||||
|
<ToastStack />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import ToastStack from '@/components/ui/ToastStack.vue'
|
||||||
|
import AppLayout from '@/layouts/AppLayout.vue'
|
||||||
|
import PortalLayout from '@/layouts/PortalLayout.vue'
|
||||||
|
import AuthLayout from '@/layouts/AuthLayout.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
authStore.loadFromStorage()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const layout = computed(() => {
|
||||||
|
if (route.meta.layout === 'portal') return PortalLayout
|
||||||
|
if (route.meta.layout === 'app') return AppLayout
|
||||||
|
return AuthLayout
|
||||||
|
})
|
||||||
|
</script>
|
||||||
10
frontend/src/assets/main.css
Normal file
10
frontend/src/assets/main.css
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: 'Inter', system-ui, sans-serif; margin: 0; background: #F8FAFC; color: #111827; }
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #CBD5E1; border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #94A3B8; }
|
||||||
37
frontend/src/components/sidebar/AppSidebar.vue
Normal file
37
frontend/src/components/sidebar/AppSidebar.vue
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="w-60 bg-sidebar flex flex-col h-full shrink-0">
|
||||||
|
<div class="px-5 py-4 border-b border-slate-700">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-blue-500 flex items-center justify-center">
|
||||||
|
<Layers :size="16" class="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-white text-sm font-bold leading-tight">TapTrack Hub</p>
|
||||||
|
<p class="text-slate-400 text-xs">Control Plane</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
|
||||||
|
<SidebarItem v-for="item in navItems" :key="item.to" v-bind="item" />
|
||||||
|
</nav>
|
||||||
|
<div class="px-3 py-3 border-t border-slate-700">
|
||||||
|
<div class="px-3 py-2 text-xs text-slate-500">v1.0.0 — Super Admin</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { LayoutDashboard, Building2, KeyRound, MessageSquare, Receipt, Ticket, Users, Megaphone, Layers } from 'lucide-vue-next'
|
||||||
|
import SidebarItem from './SidebarItem.vue'
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ label: 'Dashboard', to: '/dashboard', icon: LayoutDashboard },
|
||||||
|
{ label: 'Schools', to: '/schools', icon: Building2 },
|
||||||
|
{ label: 'Licenses', to: '/licenses', icon: KeyRound },
|
||||||
|
{ label: 'SMS Gateway', to: '/sms', icon: MessageSquare },
|
||||||
|
{ label: 'Billing', to: '/billing', icon: Receipt },
|
||||||
|
{ label: 'Support', to: '/tickets', icon: Ticket },
|
||||||
|
{ label: 'Users', to: '/users', icon: Users },
|
||||||
|
{ label: 'Announcements', to: '/announcements', icon: Megaphone },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
31
frontend/src/components/sidebar/PortalSidebar.vue
Normal file
31
frontend/src/components/sidebar/PortalSidebar.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="w-60 bg-sidebar flex flex-col h-full shrink-0">
|
||||||
|
<div class="px-5 py-4 border-b border-slate-700">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-emerald-500 flex items-center justify-center">
|
||||||
|
<School :size="16" class="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-white text-sm font-bold leading-tight">School Portal</p>
|
||||||
|
<p class="text-slate-400 text-xs">TapTrack Hub</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
|
||||||
|
<SidebarItem v-for="item in navItems" :key="item.to" v-bind="item" />
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { LayoutDashboard, Receipt, MessageSquare, Ticket, UserCircle, School } from 'lucide-vue-next'
|
||||||
|
import SidebarItem from './SidebarItem.vue'
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ label: 'Overview', to: '/portal', icon: LayoutDashboard },
|
||||||
|
{ label: 'Billing', to: '/portal/billing', icon: Receipt },
|
||||||
|
{ label: 'SMS Reports', to: '/portal/sms', icon: MessageSquare },
|
||||||
|
{ label: 'Support', to: '/portal/tickets', icon: Ticket },
|
||||||
|
{ label: 'Profile', to: '/portal/profile', icon: UserCircle },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
21
frontend/src/components/sidebar/SidebarItem.vue
Normal file
21
frontend/src/components/sidebar/SidebarItem.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<RouterLink
|
||||||
|
:to="to"
|
||||||
|
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150"
|
||||||
|
:class="isActive
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'text-slate-400 hover:bg-slate-700 hover:text-white'"
|
||||||
|
>
|
||||||
|
<component :is="icon" :size="16" class="shrink-0" />
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
const props = defineProps<{ label: string; to: string; icon: any }>()
|
||||||
|
const route = useRoute()
|
||||||
|
const isActive = computed(() => route.path === props.to || route.path.startsWith(props.to + '/'))
|
||||||
|
</script>
|
||||||
31
frontend/src/components/ui/KpiCard.vue
Normal file
31
frontend/src/components/ui/KpiCard.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white rounded-xl p-5 flex flex-col gap-3" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wide">{{ label }}</span>
|
||||||
|
<div class="w-8 h-8 rounded-lg flex items-center justify-center" :class="iconBg">
|
||||||
|
<component :is="iconComponent" :size="16" :class="iconColor" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-2xl font-bold text-slate-900">{{ value.toLocaleString() }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Building2, CheckCircle, KeyRound, Ticket, MessageSquare, Receipt, Users } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps<{ label: string; value: number; icon: string; color: string }>()
|
||||||
|
|
||||||
|
const iconMap: Record<string, any> = { Building2, CheckCircle, KeyRound, Ticket, MessageSquare, Receipt, Users }
|
||||||
|
const iconComponent = computed(() => iconMap[props.icon] ?? Building2)
|
||||||
|
|
||||||
|
const colorMap: Record<string, [string, string]> = {
|
||||||
|
blue: ['bg-blue-100', 'text-blue-600'],
|
||||||
|
green: ['bg-emerald-100','text-emerald-600'],
|
||||||
|
amber: ['bg-amber-100', 'text-amber-600'],
|
||||||
|
red: ['bg-red-100', 'text-red-500'],
|
||||||
|
purple: ['bg-purple-100', 'text-purple-600'],
|
||||||
|
}
|
||||||
|
const iconBg = computed(() => colorMap[props.color]?.[0] ?? 'bg-slate-100')
|
||||||
|
const iconColor = computed(() => colorMap[props.color]?.[1] ?? 'text-slate-600')
|
||||||
|
</script>
|
||||||
26
frontend/src/components/ui/StatusBadge.vue
Normal file
26
frontend/src/components/ui/StatusBadge.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<span class="inline-block text-xs font-semibold px-2.5 py-0.5 rounded-full" :class="classes">
|
||||||
|
{{ status }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
const props = defineProps<{ status: string }>()
|
||||||
|
const classes = computed(() => ({
|
||||||
|
active: 'bg-emerald-100 text-emerald-700',
|
||||||
|
pending: 'bg-amber-100 text-amber-700',
|
||||||
|
suspended: 'bg-red-100 text-red-700',
|
||||||
|
expired: 'bg-slate-100 text-slate-500',
|
||||||
|
open: 'bg-blue-100 text-blue-700',
|
||||||
|
in_progress: 'bg-purple-100 text-purple-700',
|
||||||
|
resolved: 'bg-emerald-100 text-emerald-700',
|
||||||
|
closed: 'bg-slate-100 text-slate-500',
|
||||||
|
paid: 'bg-emerald-100 text-emerald-700',
|
||||||
|
sent: 'bg-blue-100 text-blue-700',
|
||||||
|
draft: 'bg-slate-100 text-slate-600',
|
||||||
|
overdue: 'bg-red-100 text-red-700',
|
||||||
|
trial: 'bg-purple-100 text-purple-700',
|
||||||
|
revoked: 'bg-red-100 text-red-700',
|
||||||
|
}[props.status] ?? 'bg-slate-100 text-slate-600'))
|
||||||
|
</script>
|
||||||
36
frontend/src/components/ui/ToastStack.vue
Normal file
36
frontend/src/components/ui/ToastStack.vue
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="fixed bottom-5 right-5 z-[9999] flex flex-col gap-2 pointer-events-none" style="min-width:280px;max-width:380px">
|
||||||
|
<TransitionGroup name="toast">
|
||||||
|
<div
|
||||||
|
v-for="t in toasts"
|
||||||
|
:key="t.id"
|
||||||
|
class="flex items-start gap-3 px-4 py-3 rounded-xl shadow-xl text-sm font-medium pointer-events-auto"
|
||||||
|
:class="{
|
||||||
|
'bg-emerald-600 text-white': t.type === 'success',
|
||||||
|
'bg-red-600 text-white': t.type === 'error',
|
||||||
|
'bg-blue-600 text-white': t.type === 'info',
|
||||||
|
'bg-amber-500 text-white': t.type === 'warning',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<CheckCircle v-if="t.type === 'success'" :size="16" class="shrink-0 mt-0.5" />
|
||||||
|
<XCircle v-else-if="t.type === 'error'" :size="16" class="shrink-0 mt-0.5" />
|
||||||
|
<Info v-else :size="16" class="shrink-0 mt-0.5" />
|
||||||
|
<span>{{ t.message }}</span>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { CheckCircle, XCircle, Info } from 'lucide-vue-next'
|
||||||
|
import { toasts } from '@/composables/useToast'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toast-enter-active { transition: all 0.25s ease; }
|
||||||
|
.toast-leave-active { transition: all 0.2s ease; }
|
||||||
|
.toast-enter-from { opacity: 0; transform: translateX(100%); }
|
||||||
|
.toast-leave-to { opacity: 0; transform: translateX(100%); }
|
||||||
|
</style>
|
||||||
22
frontend/src/composables/useToast.ts
Normal file
22
frontend/src/composables/useToast.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
interface Toast { id: number; type: 'success' | 'error' | 'info' | 'warning'; message: string }
|
||||||
|
|
||||||
|
const toasts = ref<Toast[]>([])
|
||||||
|
let nextId = 1
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const show = (type: Toast['type'], message: string, duration = 4000) => {
|
||||||
|
const id = nextId++
|
||||||
|
toasts.value.push({ id, type, message })
|
||||||
|
setTimeout(() => { toasts.value = toasts.value.filter(t => t.id !== id) }, duration)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: (msg: string) => show('success', msg),
|
||||||
|
error: (msg: string) => show('error', msg),
|
||||||
|
info: (msg: string) => show('info', msg),
|
||||||
|
warning: (msg: string) => show('warning', msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { toasts }
|
||||||
50
frontend/src/layouts/AppLayout.vue
Normal file
50
frontend/src/layouts/AppLayout.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-screen overflow-hidden bg-slate-50">
|
||||||
|
<AppSidebar />
|
||||||
|
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
<!-- Top bar -->
|
||||||
|
<header class="h-14 bg-white border-b border-slate-200 flex items-center justify-between px-6 shrink-0">
|
||||||
|
<div class="text-sm text-slate-500">
|
||||||
|
TapTrack Hub
|
||||||
|
<span class="mx-1.5 text-slate-300">·</span>
|
||||||
|
<span class="text-slate-800 font-medium">{{ pageTitle }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm font-medium text-slate-700">{{ authStore.fullName }}</span>
|
||||||
|
<button @click="logout" class="text-slate-400 hover:text-slate-700 transition-colors" title="Logout">
|
||||||
|
<LogOut :size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<!-- Page content -->
|
||||||
|
<main class="flex-1 overflow-y-auto p-6">
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { LogOut } from 'lucide-vue-next'
|
||||||
|
import AppSidebar from '@/components/sidebar/AppSidebar.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const pageTitles: Record<string, string> = {
|
||||||
|
dashboard: 'Dashboard', schools: 'Schools', 'school-detail': 'School Detail',
|
||||||
|
licenses: 'Licenses', sms: 'SMS Gateway', billing: 'Billing',
|
||||||
|
tickets: 'Support Tickets', 'ticket-detail': 'Ticket Detail',
|
||||||
|
users: 'Users', announcements: 'Announcements',
|
||||||
|
}
|
||||||
|
const pageTitle = computed(() => pageTitles[route.name as string] ?? '')
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
authStore.logout()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
5
frontend/src/layouts/AuthLayout.vue
Normal file
5
frontend/src/layouts/AuthLayout.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 flex items-center justify-center p-4">
|
||||||
|
<RouterView />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
43
frontend/src/layouts/PortalLayout.vue
Normal file
43
frontend/src/layouts/PortalLayout.vue
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-screen overflow-hidden bg-slate-50">
|
||||||
|
<PortalSidebar />
|
||||||
|
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
<header class="h-14 bg-white border-b border-slate-200 flex items-center justify-between px-6 shrink-0">
|
||||||
|
<div class="text-sm text-slate-500">
|
||||||
|
School Portal
|
||||||
|
<span class="mx-1.5 text-slate-300">·</span>
|
||||||
|
<span class="text-slate-800 font-medium">{{ pageTitle }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm font-medium text-slate-700">{{ authStore.fullName }}</span>
|
||||||
|
<button @click="logout" class="text-slate-400 hover:text-slate-700 transition-colors">
|
||||||
|
<LogOut :size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="flex-1 overflow-y-auto p-6">
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { LogOut } from 'lucide-vue-next'
|
||||||
|
import PortalSidebar from '@/components/sidebar/PortalSidebar.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const titles: Record<string, string> = {
|
||||||
|
portal: 'Overview', 'portal-billing': 'Billing',
|
||||||
|
'portal-sms': 'SMS Reports', 'portal-tickets': 'Support', 'portal-profile': 'Profile',
|
||||||
|
}
|
||||||
|
const pageTitle = computed(() => titles[route.name as string] ?? '')
|
||||||
|
|
||||||
|
function logout() { authStore.logout(); router.push('/login') }
|
||||||
|
</script>
|
||||||
90
frontend/src/lib/api.ts
Normal file
90
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const api = axios.create({ baseURL: '/api' })
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('hub_token')
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(r) => r,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('hub_token')
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export default api
|
||||||
|
|
||||||
|
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
|
export const login = (email: string, password: string) =>
|
||||||
|
api.post('/auth/login', { email, password }).then(r => r.data)
|
||||||
|
|
||||||
|
export const getMe = () => api.get('/auth/me').then(r => r.data)
|
||||||
|
|
||||||
|
export const changePassword = (current_password: string, new_password: string) =>
|
||||||
|
api.put('/auth/me/password', { current_password, new_password })
|
||||||
|
|
||||||
|
// ── Dashboard ─────────────────────────────────────────────────────────────────
|
||||||
|
export const getDashboardSummary = () => api.get('/dashboard/summary').then(r => r.data)
|
||||||
|
|
||||||
|
// ── Schools ───────────────────────────────────────────────────────────────────
|
||||||
|
export interface School {
|
||||||
|
id: string; name: string; slug: string; status: string; tier: string
|
||||||
|
contact_email: string | null; contact_name: string | null; city: string | null
|
||||||
|
sms_credits: number; sms_sender_name: string; student_limit: number
|
||||||
|
license_key: string | null; license_status: string | null
|
||||||
|
license_expires_at: string | null; license_last_seen: string | null
|
||||||
|
created_at: string; billing_email: string | null; notes: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSchools = (params?: object) => api.get('/schools', { params }).then(r => r.data)
|
||||||
|
export const getSchool = (id: string) => api.get(`/schools/${id}`).then(r => r.data)
|
||||||
|
export const createSchool = (data: object) => api.post('/schools', data).then(r => r.data)
|
||||||
|
export const updateSchool = (id: string, data: object) => api.put(`/schools/${id}`, data).then(r => r.data)
|
||||||
|
export const addSmsCredits = (id: string, amount: number, description?: string) =>
|
||||||
|
api.post(`/schools/${id}/credits`, null, { params: { amount, description } }).then(r => r.data)
|
||||||
|
|
||||||
|
// ── Licenses ──────────────────────────────────────────────────────────────────
|
||||||
|
export const getLicenses = () => api.get('/licenses').then(r => r.data)
|
||||||
|
export const updateLicense = (id: string, data: object) => api.put(`/licenses/${id}`, data).then(r => r.data)
|
||||||
|
export const revokeLicense = (id: string) => api.post(`/licenses/${id}/revoke`).then(r => r.data)
|
||||||
|
|
||||||
|
// ── SMS ───────────────────────────────────────────────────────────────────────
|
||||||
|
export const getSmsJobs = (params?: object) => api.get('/sms/jobs', { params }).then(r => r.data)
|
||||||
|
export const getCreditLedger = (schoolId: string, params?: object) =>
|
||||||
|
api.get(`/sms/credits/${schoolId}`, { params }).then(r => r.data)
|
||||||
|
|
||||||
|
// ── Billing ───────────────────────────────────────────────────────────────────
|
||||||
|
export const getInvoices = (params?: object) => api.get('/billing/invoices', { params }).then(r => r.data)
|
||||||
|
export const createInvoice = (data: object) => api.post('/billing/invoices', data).then(r => r.data)
|
||||||
|
export const updateInvoice = (id: string, data: object) => api.put(`/billing/invoices/${id}`, data).then(r => r.data)
|
||||||
|
export const sendInvoiceEmail = (id: string) => api.post(`/billing/invoices/${id}/send-email`).then(r => r.data)
|
||||||
|
export const getSubscription = (schoolId: string) => api.get(`/billing/subscriptions/${schoolId}`).then(r => r.data)
|
||||||
|
export const upsertSubscription = (schoolId: string, data: object) =>
|
||||||
|
api.put(`/billing/subscriptions/${schoolId}`, data).then(r => r.data)
|
||||||
|
|
||||||
|
// ── Tickets ───────────────────────────────────────────────────────────────────
|
||||||
|
export const getTickets = (params?: object) => api.get('/tickets', { params }).then(r => r.data)
|
||||||
|
export const getTicket = (id: string) => api.get(`/tickets/${id}`).then(r => r.data)
|
||||||
|
export const createTicket = (data: object) => api.post('/tickets', data).then(r => r.data)
|
||||||
|
export const updateTicket = (id: string, data: object) => api.put(`/tickets/${id}`, data).then(r => r.data)
|
||||||
|
export const addTicketReply = (id: string, data: object) => api.post(`/tickets/${id}/replies`, data).then(r => r.data)
|
||||||
|
|
||||||
|
// ── Users ─────────────────────────────────────────────────────────────────────
|
||||||
|
export const getUsers = (params?: object) => api.get('/users', { params }).then(r => r.data)
|
||||||
|
export const createUser = (data: object) => api.post('/users', data).then(r => r.data)
|
||||||
|
export const updateUser = (id: string, data: object) => api.put(`/users/${id}`, data).then(r => r.data)
|
||||||
|
|
||||||
|
// ── Announcements ─────────────────────────────────────────────────────────────
|
||||||
|
export const getAnnouncements = () => api.get('/announcements').then(r => r.data)
|
||||||
|
export const createAnnouncement = (data: object) => api.post('/announcements', data).then(r => r.data)
|
||||||
|
export const deleteAnnouncement = (id: string) => api.delete(`/announcements/${id}`)
|
||||||
|
|
||||||
|
// ── School Portal ─────────────────────────────────────────────────────────────
|
||||||
|
export const getPortalOverview = () => api.get('/portal/overview').then(r => r.data)
|
||||||
10
frontend/src/main.ts
Normal file
10
frontend/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import router from './router'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
46
frontend/src/pages/AnnouncementsPage.vue
Normal file
46
frontend/src/pages/AnnouncementsPage.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold text-slate-900">Announcements</h1>
|
||||||
|
<button @click="showCreate = true"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700">
|
||||||
|
<Plus :size="16" /> New Announcement
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div v-if="loading" class="animate-pulse h-24 bg-white rounded-xl"></div>
|
||||||
|
<div v-for="a in announcements" :key="a.id" class="bg-white rounded-xl p-5" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-slate-900">{{ a.title }}</h3>
|
||||||
|
<p class="text-sm text-slate-600 mt-1">{{ a.body }}</p>
|
||||||
|
<p class="text-xs text-slate-400 mt-2">{{ new Date(a.created_at).toLocaleString() }}</p>
|
||||||
|
</div>
|
||||||
|
<button @click="remove(a.id)" class="text-slate-400 hover:text-red-500 transition-colors ml-4">
|
||||||
|
<Trash2 :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!loading && announcements.length === 0" class="p-12 text-center text-slate-400 bg-white rounded-xl">No announcements</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||||
|
import { getAnnouncements, deleteAnnouncement } from '@/lib/api'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const announcements = ref<any[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const showCreate = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => { try { announcements.value = await getAnnouncements() } finally { loading.value = false } })
|
||||||
|
|
||||||
|
async function remove(id: string) {
|
||||||
|
try { await deleteAnnouncement(id); announcements.value = announcements.value.filter(a => a.id !== id); toast.success('Removed') }
|
||||||
|
catch { toast.error('Failed to remove') }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
78
frontend/src/pages/BillingPage.vue
Normal file
78
frontend/src/pages/BillingPage.vue
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold text-slate-900">Billing</h1>
|
||||||
|
<button @click="showCreate = true"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700">
|
||||||
|
<Plus :size="16" /> New Invoice
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<select v-model="statusFilter" class="border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="sent">Sent</option>
|
||||||
|
<option value="paid">Paid</option>
|
||||||
|
<option value="overdue">Overdue</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<div v-if="loading" class="p-8 text-center text-sm text-slate-400 animate-pulse">Loading…</div>
|
||||||
|
<div v-else-if="invoices.length === 0" class="p-12 text-center text-slate-400">No invoices found</div>
|
||||||
|
<table v-else class="w-full text-sm">
|
||||||
|
<thead class="bg-slate-50 border-b border-slate-100">
|
||||||
|
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
|
||||||
|
<th class="px-5 py-3">Invoice #</th>
|
||||||
|
<th class="px-5 py-3">School</th>
|
||||||
|
<th class="px-5 py-3">Period</th>
|
||||||
|
<th class="px-5 py-3">Amount</th>
|
||||||
|
<th class="px-5 py-3">Status</th>
|
||||||
|
<th class="px-5 py-3">Due</th>
|
||||||
|
<th class="px-5 py-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-50">
|
||||||
|
<tr v-for="inv in invoices" :key="inv.id" class="hover:bg-slate-50">
|
||||||
|
<td class="px-5 py-3 font-mono text-xs font-semibold">{{ inv.invoice_number }}</td>
|
||||||
|
<td class="px-5 py-3 text-xs text-slate-600">{{ inv.school_id }}</td>
|
||||||
|
<td class="px-5 py-3 text-xs text-slate-500">{{ inv.billing_period_start }} — {{ inv.billing_period_end }}</td>
|
||||||
|
<td class="px-5 py-3 font-semibold">PHP {{ Number(inv.total_amount).toLocaleString() }}</td>
|
||||||
|
<td class="px-5 py-3"><StatusBadge :status="inv.status" /></td>
|
||||||
|
<td class="px-5 py-3 text-xs text-slate-500">{{ inv.due_date || '—' }}</td>
|
||||||
|
<td class="px-5 py-3">
|
||||||
|
<button @click="sendEmail(inv.id)" class="text-xs text-blue-600 hover:underline">Send Email</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
import { Plus } from 'lucide-vue-next'
|
||||||
|
import { getInvoices, sendInvoiceEmail } from '@/lib/api'
|
||||||
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const invoices = ref<any[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const statusFilter = ref('')
|
||||||
|
const showCreate = ref(false)
|
||||||
|
|
||||||
|
async function fetchInvoices() {
|
||||||
|
loading.value = true
|
||||||
|
try { const r = await getInvoices({ status: statusFilter.value || undefined }); invoices.value = r.items }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendEmail(id: string) {
|
||||||
|
try { await sendInvoiceEmail(id); toast.success('Invoice email queued') }
|
||||||
|
catch { toast.error('Failed to send email') }
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(statusFilter, fetchInvoices)
|
||||||
|
onMounted(fetchInvoices)
|
||||||
|
</script>
|
||||||
57
frontend/src/pages/DashboardPage.vue
Normal file
57
frontend/src/pages/DashboardPage.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-slate-900">Dashboard</h1>
|
||||||
|
<p class="text-sm text-slate-500 mt-0.5">{{ today }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KPI Cards -->
|
||||||
|
<div v-if="loading" class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4 animate-pulse">
|
||||||
|
<div v-for="i in 5" :key="i" class="bg-white rounded-xl p-5 h-24" style="box-shadow:0 2px 8px #0000000A"></div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||||
|
<KpiCard label="Total Schools" :value="summary?.schools?.total ?? 0" icon="Building2" color="blue" />
|
||||||
|
<KpiCard label="Active Schools" :value="summary?.schools?.active ?? 0" icon="CheckCircle" color="green" />
|
||||||
|
<KpiCard label="Expiring Licenses" :value="summary?.licenses?.expiring_soon ?? 0" icon="KeyRound" color="amber" />
|
||||||
|
<KpiCard label="Open Tickets" :value="summary?.tickets?.open ?? 0" icon="Ticket" color="red" />
|
||||||
|
<KpiCard label="SMS Today" :value="summary?.sms?.sent_today ?? 0" icon="MessageSquare" color="purple" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Secondary row -->
|
||||||
|
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||||
|
<!-- Pending invoices -->
|
||||||
|
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<h2 class="text-base font-semibold text-slate-900 mb-1">Billing</h2>
|
||||||
|
<p class="text-3xl font-bold text-slate-900">{{ summary?.invoices?.pending ?? 0 }}</p>
|
||||||
|
<p class="text-sm text-slate-500 mt-1">Pending invoices</p>
|
||||||
|
</div>
|
||||||
|
<!-- SMS Queue -->
|
||||||
|
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<h2 class="text-base font-semibold text-slate-900 mb-1">SMS Queue</h2>
|
||||||
|
<p class="text-3xl font-bold text-slate-900">{{ summary?.sms?.pending ?? 0 }}</p>
|
||||||
|
<p class="text-sm text-slate-500 mt-1">Jobs awaiting dispatch</p>
|
||||||
|
</div>
|
||||||
|
<!-- Suspended schools -->
|
||||||
|
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<h2 class="text-base font-semibold text-slate-900 mb-1">Suspended</h2>
|
||||||
|
<p class="text-3xl font-bold text-red-500">{{ summary?.schools?.suspended ?? 0 }}</p>
|
||||||
|
<p class="text-sm text-slate-500 mt-1">Schools suspended</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { getDashboardSummary } from '@/lib/api'
|
||||||
|
import KpiCard from '@/components/ui/KpiCard.vue'
|
||||||
|
|
||||||
|
const summary = ref<any>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const today = new Date().toLocaleDateString('en-PH', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try { summary.value = await getDashboardSummary() }
|
||||||
|
finally { loading.value = false }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
41
frontend/src/pages/LicensesPage.vue
Normal file
41
frontend/src/pages/LicensesPage.vue
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h1 class="text-2xl font-bold text-slate-900">Licenses</h1>
|
||||||
|
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<div v-if="loading" class="p-8 text-center text-slate-400 text-sm animate-pulse">Loading…</div>
|
||||||
|
<table v-else class="w-full text-sm">
|
||||||
|
<thead class="bg-slate-50 border-b border-slate-100">
|
||||||
|
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
|
||||||
|
<th class="px-5 py-3">License Key</th>
|
||||||
|
<th class="px-5 py-3">School ID</th>
|
||||||
|
<th class="px-5 py-3">Status</th>
|
||||||
|
<th class="px-5 py-3">Tier</th>
|
||||||
|
<th class="px-5 py-3">Expires</th>
|
||||||
|
<th class="px-5 py-3">Last Validated</th>
|
||||||
|
<th class="px-5 py-3">Last IP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-50">
|
||||||
|
<tr v-for="l in licenses" :key="l.id" class="hover:bg-slate-50">
|
||||||
|
<td class="px-5 py-3 font-mono text-xs">{{ l.key }}</td>
|
||||||
|
<td class="px-5 py-3 text-xs text-slate-500">{{ l.school_id }}</td>
|
||||||
|
<td class="px-5 py-3"><StatusBadge :status="l.status" /></td>
|
||||||
|
<td class="px-5 py-3 capitalize">{{ l.tier }}</td>
|
||||||
|
<td class="px-5 py-3 text-slate-500 text-xs">{{ l.expires_at ? new Date(l.expires_at).toLocaleDateString() : 'Never' }}</td>
|
||||||
|
<td class="px-5 py-3 text-slate-500 text-xs">{{ l.last_validated_at ? new Date(l.last_validated_at).toLocaleString() : '—' }}</td>
|
||||||
|
<td class="px-5 py-3 text-slate-500 text-xs font-mono">{{ l.last_seen_ip || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { getLicenses } from '@/lib/api'
|
||||||
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
|
const licenses = ref<any[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
onMounted(async () => { try { licenses.value = await getLicenses() } finally { loading.value = false } })
|
||||||
|
</script>
|
||||||
60
frontend/src/pages/LoginPage.vue
Normal file
60
frontend/src/pages/LoginPage.vue
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full max-w-md">
|
||||||
|
<div class="bg-white rounded-2xl shadow-2xl overflow-hidden">
|
||||||
|
<div class="bg-gradient-to-r from-slate-800 to-slate-900 px-8 py-8 text-center">
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-blue-500 flex items-center justify-center mx-auto mb-3">
|
||||||
|
<Layers :size="24" class="text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 class="text-xl font-bold text-white">TapTrack Hub</h1>
|
||||||
|
<p class="text-slate-400 text-sm mt-1">Cloud Control Plane</p>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="handleLogin" class="px-8 py-8 space-y-5">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1.5">Email</label>
|
||||||
|
<input v-model="email" type="email" required autocomplete="email"
|
||||||
|
class="w-full border border-slate-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="admin@taptrack.io" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1.5">Password</label>
|
||||||
|
<input v-model="password" type="password" required autocomplete="current-password"
|
||||||
|
class="w-full border border-slate-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="text-sm text-red-600 bg-red-50 rounded-lg px-3 py-2">{{ error }}</p>
|
||||||
|
<button type="submit" :disabled="loading"
|
||||||
|
class="w-full py-2.5 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold transition-colors disabled:opacity-60">
|
||||||
|
{{ loading ? 'Signing in…' : 'Sign In' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Layers } from 'lucide-vue-next'
|
||||||
|
import { login } from '@/lib/api'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const data = await login(email.value, password.value)
|
||||||
|
authStore.setAuth(data)
|
||||||
|
router.push(data.role === 'super_admin' ? '/dashboard' : '/portal')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.response?.data?.detail ?? 'Login failed'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
9
frontend/src/pages/NotFoundPage.vue
Normal file
9
frontend/src/pages/NotFoundPage.vue
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-slate-50">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-6xl font-bold text-slate-200">404</p>
|
||||||
|
<p class="text-xl font-semibold text-slate-700 mt-4">Page not found</p>
|
||||||
|
<RouterLink to="/" class="mt-6 inline-block text-sm text-blue-600 hover:underline">Go home</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
76
frontend/src/pages/SchoolDetailPage.vue
Normal file
76
frontend/src/pages/SchoolDetailPage.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<button @click="$router.back()" class="text-slate-400 hover:text-slate-700 transition-colors">
|
||||||
|
<ArrowLeft :size="20" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-slate-900">{{ school?.name ?? '…' }}</h1>
|
||||||
|
<p class="text-sm text-slate-500 mt-0.5">{{ school?.slug }}</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge v-if="school" :status="school.status" class="ml-2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="animate-pulse h-48 bg-white rounded-xl" style="box-shadow:0 2px 8px #0000000A"></div>
|
||||||
|
|
||||||
|
<div v-else-if="school" class="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||||
|
<!-- Info card -->
|
||||||
|
<div class="xl:col-span-2 bg-white rounded-xl p-6 space-y-4" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<h2 class="text-base font-semibold text-slate-900">School Information</h2>
|
||||||
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div><p class="text-slate-500">Contact</p><p class="font-medium">{{ school.contact_name || '—' }}</p></div>
|
||||||
|
<div><p class="text-slate-500">Email</p><p class="font-medium">{{ school.contact_email || '—' }}</p></div>
|
||||||
|
<div><p class="text-slate-500">City</p><p class="font-medium">{{ school.city || '—' }}</p></div>
|
||||||
|
<div><p class="text-slate-500">Billing Email</p><p class="font-medium">{{ school.billing_email || '—' }}</p></div>
|
||||||
|
<div><p class="text-slate-500">Tier</p><p class="font-medium capitalize">{{ school.tier }}</p></div>
|
||||||
|
<div><p class="text-slate-500">Student Limit</p><p class="font-medium">{{ school.student_limit.toLocaleString() }}</p></div>
|
||||||
|
<div><p class="text-slate-500">SMS Sender</p><p class="font-medium font-mono">{{ school.sms_sender_name }}</p></div>
|
||||||
|
<div>
|
||||||
|
<p class="text-slate-500">SMS Credits</p>
|
||||||
|
<p class="font-medium" :class="school.sms_credits < 50 ? 'text-red-600' : ''">
|
||||||
|
{{ school.sms_credits.toLocaleString() }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- License card -->
|
||||||
|
<div class="bg-white rounded-xl p-6 space-y-3" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<h2 class="text-base font-semibold text-slate-900">License</h2>
|
||||||
|
<div class="text-sm space-y-2">
|
||||||
|
<div class="flex justify-between"><span class="text-slate-500">Key</span><span class="font-mono text-xs">{{ school.license_key || '—' }}</span></div>
|
||||||
|
<div class="flex justify-between"><span class="text-slate-500">Status</span><StatusBadge :status="school.license_status || 'pending'" /></div>
|
||||||
|
<div class="flex justify-between"><span class="text-slate-500">Expires</span><span>{{ school.license_expires_at ? new Date(school.license_expires_at).toLocaleDateString() : 'Never' }}</span></div>
|
||||||
|
<div class="flex justify-between"><span class="text-slate-500">Last Seen</span><span>{{ school.license_last_seen ? new Date(school.license_last_seen).toLocaleDateString() : '—' }}</span></div>
|
||||||
|
</div>
|
||||||
|
<button @click="copyKey" v-if="school.license_key"
|
||||||
|
class="w-full mt-2 py-1.5 text-xs font-medium border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors flex items-center justify-center gap-1.5">
|
||||||
|
<Copy :size="12" /> Copy License Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { ArrowLeft, Copy } from 'lucide-vue-next'
|
||||||
|
import { getSchool } from '@/lib/api'
|
||||||
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const toast = useToast()
|
||||||
|
const school = ref<any>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try { school.value = await getSchool(route.params.id as string) }
|
||||||
|
finally { loading.value = false }
|
||||||
|
})
|
||||||
|
|
||||||
|
function copyKey() {
|
||||||
|
navigator.clipboard.writeText(school.value?.license_key ?? '')
|
||||||
|
toast.success('License key copied')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
120
frontend/src/pages/SchoolsPage.vue
Normal file
120
frontend/src/pages/SchoolsPage.vue
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-slate-900">Schools</h1>
|
||||||
|
<p class="text-sm text-slate-500 mt-0.5">{{ total }} registered schools</p>
|
||||||
|
</div>
|
||||||
|
<button @click="showCreate = true"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-colors">
|
||||||
|
<Plus :size="16" /> Add School
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex gap-3 flex-wrap">
|
||||||
|
<input v-model="search" type="text" placeholder="Search schools…"
|
||||||
|
class="border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-64" />
|
||||||
|
<select v-model="statusFilter" class="border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="suspended">Suspended</option>
|
||||||
|
<option value="expired">Expired</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<div v-if="loading" class="p-8 text-center text-slate-400 text-sm animate-pulse">Loading schools…</div>
|
||||||
|
<div v-else-if="schools.length === 0" class="p-12 text-center text-slate-400">
|
||||||
|
<Building2 :size="40" class="mx-auto mb-3 opacity-30" />
|
||||||
|
<p>No schools found</p>
|
||||||
|
</div>
|
||||||
|
<table v-else class="w-full text-sm">
|
||||||
|
<thead class="bg-slate-50 border-b border-slate-100">
|
||||||
|
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
|
||||||
|
<th class="px-5 py-3">School</th>
|
||||||
|
<th class="px-5 py-3">Status</th>
|
||||||
|
<th class="px-5 py-3">Tier</th>
|
||||||
|
<th class="px-5 py-3">SMS Credits</th>
|
||||||
|
<th class="px-5 py-3">License</th>
|
||||||
|
<th class="px-5 py-3">Last Seen</th>
|
||||||
|
<th class="px-5 py-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-50">
|
||||||
|
<tr v-for="s in schools" :key="s.id" class="hover:bg-slate-50 transition-colors">
|
||||||
|
<td class="px-5 py-3">
|
||||||
|
<p class="font-semibold text-slate-900">{{ s.name }}</p>
|
||||||
|
<p class="text-xs text-slate-400">{{ s.city || s.slug }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-3">
|
||||||
|
<StatusBadge :status="s.status" />
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-3 capitalize text-slate-600">{{ s.tier }}</td>
|
||||||
|
<td class="px-5 py-3">
|
||||||
|
<span :class="s.sms_credits < 50 ? 'text-red-600 font-semibold' : 'text-slate-700'">
|
||||||
|
{{ s.sms_credits.toLocaleString() }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-3">
|
||||||
|
<span v-if="s.license_status" class="text-xs font-semibold px-2 py-0.5 rounded-full"
|
||||||
|
:class="s.license_status === 'active' ? 'bg-emerald-100 text-emerald-700' : 'bg-red-100 text-red-700'">
|
||||||
|
{{ s.license_status }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-slate-400 text-xs">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-3 text-xs text-slate-500">
|
||||||
|
{{ s.license_last_seen ? new Date(s.license_last_seen).toLocaleDateString() : '—' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-3">
|
||||||
|
<RouterLink :to="`/schools/${s.id}`" class="text-blue-600 hover:underline text-xs font-medium">
|
||||||
|
View
|
||||||
|
</RouterLink>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div v-if="total > perPage" class="flex items-center justify-between text-sm text-slate-600">
|
||||||
|
<span>Showing {{ (page - 1) * perPage + 1 }}–{{ Math.min(page * perPage, total) }} of {{ total }}</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button :disabled="page <= 1" @click="page--" class="px-3 py-1.5 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-40">Prev</button>
|
||||||
|
<button :disabled="page * perPage >= total" @click="page++" class="px-3 py-1.5 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-40">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
import { Plus, Building2 } from 'lucide-vue-next'
|
||||||
|
import { getSchools, type School } from '@/lib/api'
|
||||||
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
|
|
||||||
|
const schools = ref<School[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const perPage = 25
|
||||||
|
const search = ref('')
|
||||||
|
const statusFilter = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const showCreate = ref(false)
|
||||||
|
let debounce: any = null
|
||||||
|
|
||||||
|
async function fetchSchools() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getSchools({ page: page.value, per_page: perPage, search: search.value || undefined, status: statusFilter.value || undefined })
|
||||||
|
schools.value = res.items
|
||||||
|
total.value = res.total
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(search, () => { clearTimeout(debounce); debounce = setTimeout(() => { page.value = 1; fetchSchools() }, 300) })
|
||||||
|
watch([page, statusFilter], fetchSchools)
|
||||||
|
onMounted(fetchSchools)
|
||||||
|
</script>
|
||||||
56
frontend/src/pages/SmsPage.vue
Normal file
56
frontend/src/pages/SmsPage.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h1 class="text-2xl font-bold text-slate-900">SMS Gateway</h1>
|
||||||
|
<div class="flex gap-3 flex-wrap">
|
||||||
|
<select v-model="statusFilter" class="border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="sent">Sent</option>
|
||||||
|
<option value="failed">Failed</option>
|
||||||
|
<option value="cancelled">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<div v-if="loading" class="p-8 text-center text-sm text-slate-400 animate-pulse">Loading…</div>
|
||||||
|
<div v-else-if="jobs.length === 0" class="p-12 text-center text-slate-400">No SMS jobs found</div>
|
||||||
|
<table v-else class="w-full text-sm">
|
||||||
|
<thead class="bg-slate-50 border-b border-slate-100">
|
||||||
|
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
|
||||||
|
<th class="px-5 py-3">Recipient</th>
|
||||||
|
<th class="px-5 py-3">Message</th>
|
||||||
|
<th class="px-5 py-3">Sender</th>
|
||||||
|
<th class="px-5 py-3">Status</th>
|
||||||
|
<th class="px-5 py-3">Trigger</th>
|
||||||
|
<th class="px-5 py-3">Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-50">
|
||||||
|
<tr v-for="j in jobs" :key="j.id" class="hover:bg-slate-50">
|
||||||
|
<td class="px-5 py-3 font-mono text-xs">{{ j.recipient_phone }}</td>
|
||||||
|
<td class="px-5 py-3 text-slate-600 max-w-xs truncate">{{ j.message }}</td>
|
||||||
|
<td class="px-5 py-3 font-mono text-xs">{{ j.sender_name }}</td>
|
||||||
|
<td class="px-5 py-3"><StatusBadge :status="j.status" /></td>
|
||||||
|
<td class="px-5 py-3 text-slate-500 text-xs">{{ j.trigger || '—' }}</td>
|
||||||
|
<td class="px-5 py-3 text-slate-500 text-xs">{{ new Date(j.created_at).toLocaleString() }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
import { getSmsJobs } from '@/lib/api'
|
||||||
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
|
const jobs = ref<any[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const statusFilter = ref('')
|
||||||
|
async function fetchJobs() {
|
||||||
|
loading.value = true
|
||||||
|
try { const r = await getSmsJobs({ status: statusFilter.value || undefined }); jobs.value = r.items }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
watch(statusFilter, fetchJobs)
|
||||||
|
onMounted(fetchJobs)
|
||||||
|
</script>
|
||||||
88
frontend/src/pages/TicketDetailPage.vue
Normal file
88
frontend/src/pages/TicketDetailPage.vue
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6 max-w-3xl">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button @click="$router.back()" class="text-slate-400 hover:text-slate-700"><ArrowLeft :size="20" /></button>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-bold text-slate-900">{{ ticket?.subject ?? '…' }}</h1>
|
||||||
|
<p class="text-xs text-slate-400 mt-0.5">{{ ticket?.ticket_number }}</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge v-if="ticket" :status="ticket.status" class="ml-auto" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="animate-pulse h-40 bg-white rounded-xl"></div>
|
||||||
|
<template v-else-if="ticket">
|
||||||
|
<!-- Original message -->
|
||||||
|
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<p class="text-sm text-slate-700 whitespace-pre-wrap">{{ ticket.body }}</p>
|
||||||
|
<p class="text-xs text-slate-400 mt-3">{{ new Date(ticket.created_at).toLocaleString() }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Replies -->
|
||||||
|
<div v-for="reply in ticket.replies" :key="reply.id"
|
||||||
|
class="rounded-xl p-5 text-sm"
|
||||||
|
:class="reply.is_internal ? 'bg-amber-50 border border-amber-200' : 'bg-white'"
|
||||||
|
style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="font-semibold text-slate-800 text-xs">{{ reply.author_id }}</span>
|
||||||
|
<span v-if="reply.is_internal" class="text-xs font-semibold text-amber-600 bg-amber-100 px-2 py-0.5 rounded-full">Internal</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-slate-700 whitespace-pre-wrap">{{ reply.body }}</p>
|
||||||
|
<p class="text-xs text-slate-400 mt-2">{{ new Date(reply.created_at).toLocaleString() }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reply form -->
|
||||||
|
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<h3 class="text-sm font-semibold text-slate-900 mb-3">Add Reply</h3>
|
||||||
|
<textarea v-model="replyBody" rows="4" placeholder="Type your reply…"
|
||||||
|
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"></textarea>
|
||||||
|
<div class="flex items-center gap-3 mt-3">
|
||||||
|
<label v-if="isSuperAdmin" class="flex items-center gap-2 text-sm text-slate-600 cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="isInternal" class="accent-amber-500" /> Internal note
|
||||||
|
</label>
|
||||||
|
<button @click="submitReply" :disabled="!replyBody.trim() || replying"
|
||||||
|
class="ml-auto px-5 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50">
|
||||||
|
{{ replying ? 'Sending…' : 'Send Reply' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { ArrowLeft } from 'lucide-vue-next'
|
||||||
|
import { getTicket, addTicketReply } from '@/lib/api'
|
||||||
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const toast = useToast()
|
||||||
|
const isSuperAdmin = computed(() => authStore.isSuperAdmin)
|
||||||
|
const ticket = ref<any>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const replyBody = ref('')
|
||||||
|
const isInternal = ref(false)
|
||||||
|
const replying = ref(false)
|
||||||
|
|
||||||
|
async function loadTicket() {
|
||||||
|
try { ticket.value = await getTicket(route.params.id as string) }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitReply() {
|
||||||
|
replying.value = true
|
||||||
|
try {
|
||||||
|
await addTicketReply(ticket.value.id, { body: replyBody.value, is_internal: isInternal.value })
|
||||||
|
replyBody.value = ''
|
||||||
|
toast.success('Reply sent')
|
||||||
|
await loadTicket()
|
||||||
|
} catch { toast.error('Failed to send reply') }
|
||||||
|
finally { replying.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadTicket)
|
||||||
|
</script>
|
||||||
74
frontend/src/pages/TicketsPage.vue
Normal file
74
frontend/src/pages/TicketsPage.vue
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold text-slate-900">Support Tickets</h1>
|
||||||
|
<button v-if="!isSuperAdmin" @click="showCreate = true"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700">
|
||||||
|
<Plus :size="16" /> New Ticket
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<select v-model="statusFilter" class="border border-slate-200 rounded-lg px-3 py-2 text-sm">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="open">Open</option>
|
||||||
|
<option value="in_progress">In Progress</option>
|
||||||
|
<option value="resolved">Resolved</option>
|
||||||
|
<option value="closed">Closed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<div v-if="loading" class="p-8 text-center text-sm text-slate-400 animate-pulse">Loading…</div>
|
||||||
|
<div v-else-if="tickets.length === 0" class="p-12 text-center text-slate-400">No tickets found</div>
|
||||||
|
<table v-else class="w-full text-sm">
|
||||||
|
<thead class="bg-slate-50 border-b border-slate-100">
|
||||||
|
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
|
||||||
|
<th class="px-5 py-3">Ticket #</th>
|
||||||
|
<th class="px-5 py-3">Subject</th>
|
||||||
|
<th class="px-5 py-3">Category</th>
|
||||||
|
<th class="px-5 py-3">Status</th>
|
||||||
|
<th class="px-5 py-3">Priority</th>
|
||||||
|
<th class="px-5 py-3">Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-50">
|
||||||
|
<tr v-for="t in tickets" :key="t.id" class="hover:bg-slate-50 cursor-pointer" @click="$router.push(`/tickets/${t.id}`)">
|
||||||
|
<td class="px-5 py-3 font-mono text-xs font-semibold text-blue-600">{{ t.ticket_number }}</td>
|
||||||
|
<td class="px-5 py-3 font-medium text-slate-900 max-w-xs truncate">{{ t.subject }}</td>
|
||||||
|
<td class="px-5 py-3 capitalize text-slate-600 text-xs">{{ t.category }}</td>
|
||||||
|
<td class="px-5 py-3"><StatusBadge :status="t.status" /></td>
|
||||||
|
<td class="px-5 py-3">
|
||||||
|
<span class="text-xs font-semibold capitalize px-2 py-0.5 rounded-full"
|
||||||
|
:class="t.priority === 'urgent' ? 'bg-red-100 text-red-700' : t.priority === 'high' ? 'bg-amber-100 text-amber-700' : 'bg-slate-100 text-slate-600'">
|
||||||
|
{{ t.priority }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-3 text-xs text-slate-500">{{ new Date(t.created_at).toLocaleDateString() }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted, computed } from 'vue'
|
||||||
|
import { Plus } from 'lucide-vue-next'
|
||||||
|
import { getTickets } from '@/lib/api'
|
||||||
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const isSuperAdmin = computed(() => authStore.isSuperAdmin)
|
||||||
|
const tickets = ref<any[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const statusFilter = ref('')
|
||||||
|
const showCreate = ref(false)
|
||||||
|
|
||||||
|
async function fetchTickets() {
|
||||||
|
loading.value = true
|
||||||
|
try { const r = await getTickets({ status: statusFilter.value || undefined }); tickets.value = r.items }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
watch(statusFilter, fetchTickets)
|
||||||
|
onMounted(fetchTickets)
|
||||||
|
</script>
|
||||||
55
frontend/src/pages/UsersPage.vue
Normal file
55
frontend/src/pages/UsersPage.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold text-slate-900">Hub Users</h1>
|
||||||
|
<button @click="showCreate = true"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700">
|
||||||
|
<Plus :size="16" /> Add User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<div v-if="loading" class="p-8 text-center text-sm text-slate-400 animate-pulse">Loading…</div>
|
||||||
|
<table v-else class="w-full text-sm">
|
||||||
|
<thead class="bg-slate-50 border-b border-slate-100">
|
||||||
|
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
|
||||||
|
<th class="px-5 py-3">Name</th>
|
||||||
|
<th class="px-5 py-3">Email</th>
|
||||||
|
<th class="px-5 py-3">Role</th>
|
||||||
|
<th class="px-5 py-3">School</th>
|
||||||
|
<th class="px-5 py-3">Active</th>
|
||||||
|
<th class="px-5 py-3">Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-50">
|
||||||
|
<tr v-for="u in users" :key="u.id" class="hover:bg-slate-50">
|
||||||
|
<td class="px-5 py-3 font-medium text-slate-900">{{ u.full_name }}</td>
|
||||||
|
<td class="px-5 py-3 text-slate-600">{{ u.email }}</td>
|
||||||
|
<td class="px-5 py-3">
|
||||||
|
<span class="text-xs font-semibold px-2 py-0.5 rounded-full"
|
||||||
|
:class="u.role === 'super_admin' ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'">
|
||||||
|
{{ u.role }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-3 text-xs text-slate-500">{{ u.school_id || '—' }}</td>
|
||||||
|
<td class="px-5 py-3">
|
||||||
|
<span class="text-xs font-semibold px-2 py-0.5 rounded-full" :class="u.is_active ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-500'">
|
||||||
|
{{ u.is_active ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-3 text-xs text-slate-500">{{ new Date(u.created_at).toLocaleDateString() }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { Plus } from 'lucide-vue-next'
|
||||||
|
import { getUsers } from '@/lib/api'
|
||||||
|
const users = ref<any[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const showCreate = ref(false)
|
||||||
|
onMounted(async () => { try { const r = await getUsers(); users.value = r.items } finally { loading.value = false } })
|
||||||
|
</script>
|
||||||
0
frontend/src/pages/portal/.gitkeep
Normal file
0
frontend/src/pages/portal/.gitkeep
Normal file
38
frontend/src/pages/portal/PortalBillingPage.vue
Normal file
38
frontend/src/pages/portal/PortalBillingPage.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h1 class="text-2xl font-bold text-slate-900">Billing History</h1>
|
||||||
|
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<div v-if="loading" class="p-8 text-center text-sm text-slate-400 animate-pulse">Loading…</div>
|
||||||
|
<div v-else-if="invoices.length === 0" class="p-12 text-center text-slate-400">No invoices yet</div>
|
||||||
|
<table v-else class="w-full text-sm">
|
||||||
|
<thead class="bg-slate-50 border-b border-slate-100">
|
||||||
|
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
|
||||||
|
<th class="px-5 py-3">Invoice #</th>
|
||||||
|
<th class="px-5 py-3">Period</th>
|
||||||
|
<th class="px-5 py-3">Amount</th>
|
||||||
|
<th class="px-5 py-3">Status</th>
|
||||||
|
<th class="px-5 py-3">Due Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-50">
|
||||||
|
<tr v-for="inv in invoices" :key="inv.id" class="hover:bg-slate-50">
|
||||||
|
<td class="px-5 py-3 font-mono text-xs font-semibold">{{ inv.invoice_number }}</td>
|
||||||
|
<td class="px-5 py-3 text-xs text-slate-500">{{ inv.billing_period_start }} — {{ inv.billing_period_end }}</td>
|
||||||
|
<td class="px-5 py-3 font-semibold">PHP {{ Number(inv.total_amount).toLocaleString() }}</td>
|
||||||
|
<td class="px-5 py-3"><StatusBadge :status="inv.status" /></td>
|
||||||
|
<td class="px-5 py-3 text-xs text-slate-500">{{ inv.due_date || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { getInvoices } from '@/lib/api'
|
||||||
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
|
const invoices = ref<any[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
onMounted(async () => { try { const r = await getInvoices(); invoices.value = r.items } finally { loading.value = false } })
|
||||||
|
</script>
|
||||||
49
frontend/src/pages/portal/PortalOverviewPage.vue
Normal file
49
frontend/src/pages/portal/PortalOverviewPage.vue
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-slate-900">Overview</h1>
|
||||||
|
<p class="text-sm text-slate-500 mt-0.5">{{ data?.school?.name }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="grid grid-cols-2 md:grid-cols-4 gap-4 animate-pulse">
|
||||||
|
<div v-for="i in 4" :key="i" class="bg-white rounded-xl h-24" style="box-shadow:0 2px 8px #0000000A"></div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<KpiCard label="SMS Credits" :value="data?.sms_credits ?? 0" icon="MessageSquare" color="blue" />
|
||||||
|
<KpiCard label="SMS This Month" :value="data?.sms_this_month ?? 0" icon="MessageSquare" color="green" />
|
||||||
|
<KpiCard label="Open Tickets" :value="data?.open_tickets ?? 0" icon="Ticket" color="amber" />
|
||||||
|
<KpiCard label="Pending Invoices":value="data?.pending_invoices ?? 0" icon="Receipt" color="red" />
|
||||||
|
</div>
|
||||||
|
<!-- License info -->
|
||||||
|
<div v-if="data?.license" class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<h2 class="text-base font-semibold text-slate-900 mb-4">License Status</h2>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div><p class="text-slate-500">Status</p><StatusBadge :status="data.license.status" /></div>
|
||||||
|
<div><p class="text-slate-500">Expires</p><p class="font-medium">{{ data.license.expires_at ? new Date(data.license.expires_at).toLocaleDateString() : 'Never' }}</p></div>
|
||||||
|
<div><p class="text-slate-500">Last Online</p><p class="font-medium">{{ data.license.last_seen ? new Date(data.license.last_seen).toLocaleString() : '—' }}</p></div>
|
||||||
|
<div><p class="text-slate-500">License Key</p><p class="font-mono text-xs truncate">{{ data.license.key }}</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Announcements -->
|
||||||
|
<div v-if="announcements.length > 0" class="bg-blue-50 border border-blue-200 rounded-xl p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-blue-800 mb-2">Announcements</h3>
|
||||||
|
<div v-for="a in announcements" :key="a.id" class="mb-2">
|
||||||
|
<p class="text-sm font-medium text-blue-900">{{ a.title }}</p>
|
||||||
|
<p class="text-xs text-blue-700">{{ a.body }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { getPortalOverview, getAnnouncements } from '@/lib/api'
|
||||||
|
import KpiCard from '@/components/ui/KpiCard.vue'
|
||||||
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
|
const data = ref<any>(null)
|
||||||
|
const announcements = ref<any[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
onMounted(async () => {
|
||||||
|
try { [data.value, announcements.value] = await Promise.all([getPortalOverview(), getAnnouncements()]) }
|
||||||
|
finally { loading.value = false }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
64
frontend/src/pages/portal/PortalProfilePage.vue
Normal file
64
frontend/src/pages/portal/PortalProfilePage.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6 max-w-md">
|
||||||
|
<h1 class="text-2xl font-bold text-slate-900">Profile</h1>
|
||||||
|
<div class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<div class="w-14 h-14 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white text-lg font-bold">
|
||||||
|
{{ initials }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-slate-900">{{ authStore.fullName }}</p>
|
||||||
|
<p class="text-sm text-slate-500">School Admin</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-sm font-semibold text-slate-900 mb-4">Change Password</h2>
|
||||||
|
<form @submit.prevent="submitPw" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">Current Password</label>
|
||||||
|
<input v-model="pwForm.current" type="password" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">New Password</label>
|
||||||
|
<input v-model="pwForm.newPw" type="password" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">Confirm New Password</label>
|
||||||
|
<input v-model="pwForm.confirm" type="password" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
</div>
|
||||||
|
<p v-if="pwError" class="text-sm text-red-600">{{ pwError }}</p>
|
||||||
|
<button type="submit" :disabled="saving || !pwForm.current || !pwForm.newPw || !pwForm.confirm"
|
||||||
|
class="w-full py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50">
|
||||||
|
{{ saving ? 'Saving…' : 'Update Password' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { changePassword } from '@/lib/api'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const toast = useToast()
|
||||||
|
const initials = computed(() => (authStore.fullName ?? '').split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase())
|
||||||
|
const pwForm = ref({ current: '', newPw: '', confirm: '' })
|
||||||
|
const pwError = ref('')
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
async function submitPw() {
|
||||||
|
pwError.value = ''
|
||||||
|
if (pwForm.value.newPw.length < 8) { pwError.value = 'Minimum 8 characters'; return }
|
||||||
|
if (pwForm.value.newPw !== pwForm.value.confirm) { pwError.value = 'Passwords do not match'; return }
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await changePassword(pwForm.value.current, pwForm.value.newPw)
|
||||||
|
toast.success('Password updated')
|
||||||
|
pwForm.value = { current: '', newPw: '', confirm: '' }
|
||||||
|
} catch (e: any) {
|
||||||
|
pwError.value = e?.response?.data?.detail ?? 'Failed to update password'
|
||||||
|
} finally { saving.value = false }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
38
frontend/src/pages/portal/PortalSmsPage.vue
Normal file
38
frontend/src/pages/portal/PortalSmsPage.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h1 class="text-2xl font-bold text-slate-900">SMS Reports</h1>
|
||||||
|
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<div v-if="loading" class="p-8 text-center text-sm text-slate-400 animate-pulse">Loading…</div>
|
||||||
|
<div v-else-if="jobs.length === 0" class="p-12 text-center text-slate-400">No SMS records</div>
|
||||||
|
<table v-else class="w-full text-sm">
|
||||||
|
<thead class="bg-slate-50 border-b border-slate-100">
|
||||||
|
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
|
||||||
|
<th class="px-5 py-3">Recipient</th>
|
||||||
|
<th class="px-5 py-3">Message</th>
|
||||||
|
<th class="px-5 py-3">Status</th>
|
||||||
|
<th class="px-5 py-3">Trigger</th>
|
||||||
|
<th class="px-5 py-3">Sent At</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-50">
|
||||||
|
<tr v-for="j in jobs" :key="j.id" class="hover:bg-slate-50">
|
||||||
|
<td class="px-5 py-3 font-mono text-xs">{{ j.recipient_phone }}</td>
|
||||||
|
<td class="px-5 py-3 text-slate-600 max-w-xs truncate">{{ j.message }}</td>
|
||||||
|
<td class="px-5 py-3"><StatusBadge :status="j.status" /></td>
|
||||||
|
<td class="px-5 py-3 text-xs text-slate-500 capitalize">{{ j.trigger || '—' }}</td>
|
||||||
|
<td class="px-5 py-3 text-xs text-slate-500">{{ j.sent_at ? new Date(j.sent_at).toLocaleString() : '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { getSmsJobs } from '@/lib/api'
|
||||||
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
|
const jobs = ref<any[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
onMounted(async () => { try { const r = await getSmsJobs(); jobs.value = r.items } finally { loading.value = false } })
|
||||||
|
</script>
|
||||||
102
frontend/src/pages/portal/PortalTicketsPage.vue
Normal file
102
frontend/src/pages/portal/PortalTicketsPage.vue
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold text-slate-900">Support Tickets</h1>
|
||||||
|
<button @click="showCreate = true"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700">
|
||||||
|
<Plus :size="16" /> New Ticket
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Create form -->
|
||||||
|
<div v-if="showCreate" class="bg-white rounded-xl p-6" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<h2 class="text-base font-semibold text-slate-900 mb-4">Submit a Ticket</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">Subject</label>
|
||||||
|
<input v-model="form.subject" type="text" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">Category</label>
|
||||||
|
<select v-model="form.category" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm">
|
||||||
|
<option value="general">General</option>
|
||||||
|
<option value="billing">Billing</option>
|
||||||
|
<option value="technical">Technical</option>
|
||||||
|
<option value="sms">SMS</option>
|
||||||
|
<option value="license">License</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">Message</label>
|
||||||
|
<textarea v-model="form.body" rows="4" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button @click="showCreate = false" class="px-4 py-2 border border-slate-200 rounded-lg text-sm text-slate-700 hover:bg-slate-50">Cancel</button>
|
||||||
|
<button @click="submitTicket" :disabled="!form.subject || !form.body || submitting"
|
||||||
|
class="px-5 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50">
|
||||||
|
{{ submitting ? 'Submitting…' : 'Submit Ticket' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- List -->
|
||||||
|
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
|
||||||
|
<div v-if="loading" class="p-8 text-center text-sm animate-pulse text-slate-400">Loading…</div>
|
||||||
|
<div v-else-if="tickets.length === 0" class="p-12 text-center text-slate-400">No tickets yet</div>
|
||||||
|
<table v-else class="w-full text-sm">
|
||||||
|
<thead class="bg-slate-50 border-b border-slate-100">
|
||||||
|
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
|
||||||
|
<th class="px-5 py-3">Ticket #</th>
|
||||||
|
<th class="px-5 py-3">Subject</th>
|
||||||
|
<th class="px-5 py-3">Category</th>
|
||||||
|
<th class="px-5 py-3">Status</th>
|
||||||
|
<th class="px-5 py-3">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-50">
|
||||||
|
<tr v-for="t in tickets" :key="t.id" class="hover:bg-slate-50 cursor-pointer" @click="$router.push(`/tickets/${t.id}`)">
|
||||||
|
<td class="px-5 py-3 font-mono text-xs font-semibold text-blue-600">{{ t.ticket_number }}</td>
|
||||||
|
<td class="px-5 py-3 font-medium text-slate-900 max-w-xs truncate">{{ t.subject }}</td>
|
||||||
|
<td class="px-5 py-3 capitalize text-xs text-slate-600">{{ t.category }}</td>
|
||||||
|
<td class="px-5 py-3"><StatusBadge :status="t.status" /></td>
|
||||||
|
<td class="px-5 py-3 text-xs text-slate-500">{{ new Date(t.created_at).toLocaleDateString() }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { Plus } from 'lucide-vue-next'
|
||||||
|
import { getTickets, createTicket } from '@/lib/api'
|
||||||
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const tickets = ref<any[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const form = ref({ subject: '', body: '', category: 'general' })
|
||||||
|
|
||||||
|
async function fetchTickets() {
|
||||||
|
loading.value = true
|
||||||
|
try { const r = await getTickets(); tickets.value = r.items }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitTicket() {
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await createTicket(form.value)
|
||||||
|
toast.success('Ticket submitted')
|
||||||
|
showCreate.value = false
|
||||||
|
form.value = { subject: '', body: '', category: 'general' }
|
||||||
|
await fetchTickets()
|
||||||
|
} catch { toast.error('Failed to submit ticket') }
|
||||||
|
finally { submitting.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchTickets)
|
||||||
|
</script>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user