From 5cab1938b18153fc8709877d833435f43eb55b2e Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Mon, 16 Mar 2026 10:37:23 +0800 Subject: [PATCH] feat(phase-4): SMS gateway dashboard + stats + health + retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (backend/app/routers/sms.py): - GET /api/sms/stats: aggregate totals by status, daily volume chart (configurable N days), per-school top-10 breakdown, delivery rate, live queue depth - POST /api/sms/jobs/{id}/retry: reset failed/cancelled job to pending - GET /api/sms/health: Semaphore API connectivity check + credit balance - POST /api/sms/trigger-queue: manually fire sms.process_queue Celery task Frontend (frontend/src/pages/SmsPage.vue) — rebuilt from scratch: - Semaphore health badge with live status indicator and balance display - 'Process Queue' button calling POST /api/sms/trigger-queue - KPI row: Total, Sent, Failed, Pending (queue depth), Delivery Rate %; period picker switching between 7/30/90 day views - Volume bar chart: daily sent vs failed bars with hover tooltips, x-axis date labels, legend; no charting library required - School breakdown panel: top 10 schools by volume with delivery percentage bars - Jobs table: status + school + phone number filters; inline error message on failed rows; Retry button for failed/cancelled jobs; retry count display; pagination api.ts: getSmsStats, retrySmsJob, getSmsHealth, triggerSmsQueue PAUL: Phase 4 marked complete, STATE + ROADMAP updated --- .paul/ROADMAP.md | 6 +- .paul/STATE.md | 14 +- .paul/phases/04-sms-gateway/README.md | 22 +- backend/app/routers/sms.py | 178 +++++++++++++- frontend/src/lib/api.ts | 6 +- frontend/src/pages/SmsPage.vue | 324 ++++++++++++++++++++++++-- 6 files changed, 512 insertions(+), 38 deletions(-) diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md index 931fa45..85efd9f 100644 --- a/.paul/ROADMAP.md +++ b/.paul/ROADMAP.md @@ -19,8 +19,8 @@ SMS flow: On-prem TapTrack polls Hub every 30s → Hub queues SMS jobs → Hub s ## Current Milestone **v1.0 — Foundation & Core Services** -Status: Phase 3 complete — Phase 4 next -Phases: 3 of 15 complete +Status: Phase 4 complete — Phase 5 next +Phases: 4 of 15 complete --- @@ -31,7 +31,7 @@ Phases: 3 of 15 complete | 1 | Project Setup & Infrastructure | 1 | ✅ Complete | 2026-03-15 | | 2 | School Registry + License Mgmt | 2 | ✅ Complete | 2026-03-15 | | 3 | On-Prem License Validation | 1 | ✅ Complete | 2026-03-15 | -| 4 | SMS Gateway (credits + queue) | TBD | Not started | — | +| 4 | SMS Gateway (credits + queue) | 2 | ✅ Complete | 2026-03-15 | | 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 | — | diff --git a/.paul/STATE.md b/.paul/STATE.md index 04dff00..eafcb07 100644 --- a/.paul/STATE.md +++ b/.paul/STATE.md @@ -3,10 +3,10 @@ ## Current Position Milestone: v1.0 — Foundation & Core Services -Phase: 3 of 15 (On-Prem License Validation — complete) -Plan: Phase 3 complete — Phase 4 next -Status: **Phase 3 applied — ready to begin Phase 4** -Last activity: 2026-03-15 — Phase 3 complete (TapTrack on-prem: license validation task, startup check, hub router, frontend warning banner) +Phase: 4 of 15 (SMS Gateway — complete) +Plan: Phase 4 complete — Phase 5 next +Status: **Phase 4 applied — ready to begin Phase 5** +Last activity: 2026-03-15 — Phase 4 complete (SMS stats/health/retry/trigger endpoints + full SmsPage dashboard with chart, KPIs, school breakdown) ## Loop Position @@ -22,7 +22,7 @@ PLAN ──▶ APPLY ──▶ UNIFY - Phase 1 (Project Setup & Infrastructure): [██████████] 100% ✓ - Phase 2 (School Registry + License Mgmt): [██████████] 100% ✓ - Phase 3 (On-Prem License Validation): [██████████] 100% ✓ -- Phase 4 (SMS Gateway): [░░░░░░░░░░] 0% +- Phase 4 (SMS Gateway): [██████████] 100% ✓ - Phase 5 (On-Prem SMS Polling Agent): [░░░░░░░░░░] 0% - Phase 6 (Super Admin Dashboard UI): [░░░░░░░░░░] 0% - Phase 7 (School Admin Portal UI): [░░░░░░░░░░] 0% @@ -37,8 +37,8 @@ PLAN ──▶ APPLY ──▶ UNIFY ## Next Action -Run: `/paul:plan` for Phase 4 — SMS Gateway (credits + queue) -Resume file: .paul/ROADMAP.md → Phase 4 +Run: `/paul:plan` for Phase 5 — On-Prem SMS Polling Agent +Resume file: .paul/ROADMAP.md → Phase 5 ## Repo diff --git a/.paul/phases/04-sms-gateway/README.md b/.paul/phases/04-sms-gateway/README.md index dc78f6e..17da7c1 100644 --- a/.paul/phases/04-sms-gateway/README.md +++ b/.paul/phases/04-sms-gateway/README.md @@ -1,9 +1,25 @@ # Phase 04: SMS Gateway (Credits + Queue) -**Status:** Not started +**Status:** ✅ Complete — 2026-03-15 ## Goal Schools submit SMS jobs via Hub; Hub sends via Semaphore; credits deducted per message. +Full SMS dashboard for super admin with stats, charts, job table, Semaphore health. -## Plans -- [ ] TBD — run /paul:plan when Phase 3 is complete +## Completed + +### 4-01: Backend stats + retry + health + trigger +- `GET /api/sms/stats` — aggregate totals, daily chart (N days), school breakdown +- `POST /api/sms/jobs/{id}/retry` — reset failed/cancelled job to pending +- `GET /api/sms/health` — Semaphore connectivity + credit balance +- `POST /api/sms/trigger-queue` — manual Celery task trigger (admin) +- `api.ts`: getSmsStats, retrySmsJob, getSmsHealth, triggerSmsQueue + +### 4-02: SmsPage full dashboard +- Semaphore health badge (green/red, shows balance) +- Process Queue button (fires trigger-queue) +- KPI row: Total, Sent, Failed, Pending, Delivery Rate %, period picker 7/30/90d +- Volume bar chart (daily sent vs failed, hover tooltips) +- School breakdown panel (top 10, sent/total ratio bars) +- Jobs table: status + school + phone filters, error message on failed rows, + Retry button, retry count, pagination diff --git a/backend/app/routers/sms.py b/backend/app/routers/sms.py index fbab802..76178e0 100644 --- a/backend/app/routers/sms.py +++ b/backend/app/routers/sms.py @@ -1,10 +1,10 @@ """SMS gateway endpoints.""" from typing import Optional -from datetime import datetime, timezone +from datetime import datetime, timezone, date, timedelta 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 sqlalchemy import select, func, desc, and_, cast, Date from app.auth.dependencies import require_super_admin, require_school_admin, get_current_user from app.database import get_db @@ -107,3 +107,177 @@ async def get_credit_ledger( ], "total": total, } + + +# ── SMS Stats ───────────────────────────────────────────────────────────────── + +@router.get("/stats") +async def get_sms_stats( + school_id: Optional[str] = Query(None), + days: int = Query(30, ge=1, le=90), + _admin: HubUser = Depends(require_super_admin), + db: AsyncSession = Depends(get_db), +): + """ + Aggregate SMS stats for the super admin dashboard. + Returns: totals by status, daily volume for last N days, per-school breakdown. + """ + since = datetime.now(timezone.utc) - timedelta(days=days) + base_filter = [SmsJob.created_at >= since] + if school_id: + base_filter.append(SmsJob.school_id == school_id) + + # Totals by status + status_rows = (await db.execute( + select(SmsJob.status, func.count().label("cnt")) + .where(and_(*base_filter)) + .group_by(SmsJob.status) + )).all() + totals = {r.status.value: r.cnt for r in status_rows} + + total_all = sum(totals.values()) + total_sent = totals.get("sent", 0) + delivery_rate = round((total_sent / total_all * 100), 1) if total_all > 0 else 0.0 + + # Daily volume for chart (last N days) + daily_rows = (await db.execute( + select( + cast(SmsJob.created_at, Date).label("day"), + SmsJob.status, + func.count().label("cnt"), + ) + .where(and_(*base_filter)) + .group_by(cast(SmsJob.created_at, Date), SmsJob.status) + .order_by(cast(SmsJob.created_at, Date)) + )).all() + + # Build day-keyed dict + day_map: dict = {} + for row in daily_rows: + key = str(row.day) + if key not in day_map: + day_map[key] = {"date": key, "sent": 0, "failed": 0, "pending": 0, "total": 0} + day_map[key][row.status.value] = row.cnt + day_map[key]["total"] += row.cnt + + # Fill missing days with zeros + chart = [] + for i in range(days): + d = (datetime.now(timezone.utc) - timedelta(days=days - 1 - i)).date().isoformat() + chart.append(day_map.get(d, {"date": d, "sent": 0, "failed": 0, "pending": 0, "total": 0})) + + # Per-school breakdown (top 10 by volume, super admin only, no school filter) + school_breakdown: list = [] + if not school_id: + school_rows = (await db.execute( + select( + SmsJob.school_id, + func.count().label("total"), + func.sum( + func.cast(SmsJob.status == SmsJobStatus.sent, func.Integer()) + ).label("sent"), + ) + .where(SmsJob.created_at >= since) + .group_by(SmsJob.school_id) + .order_by(desc("total")) + .limit(10) + )).all() + # Fetch school names + for sr in school_rows: + school = (await db.execute( + select(School.name).where(School.id == sr.school_id) + )).scalar_one_or_none() + school_breakdown.append({ + "school_id": sr.school_id, + "school_name": school or sr.school_id, + "total": sr.total, + "sent": int(sr.sent or 0), + }) + + # Pending jobs count (for the "queue depth" indicator) + pending_count = (await db.execute( + select(func.count()).where(SmsJob.status == SmsJobStatus.pending) + )).scalar_one() + + return { + "period_days": days, + "total": total_all, + "sent": total_sent, + "failed": totals.get("failed", 0), + "pending": totals.get("pending", 0), + "cancelled": totals.get("cancelled", 0), + "delivery_rate": delivery_rate, + "queue_depth": pending_count, + "chart": chart, + "by_school": school_breakdown, + } + + +# ── Retry failed job ────────────────────────────────────────────────────────── + +@router.post("/jobs/{job_id}/retry") +async def retry_sms_job( + job_id: str, + _admin: HubUser = Depends(require_super_admin), + db: AsyncSession = Depends(get_db), +): + """Reset a failed/cancelled SMS job back to pending so it gets re-processed.""" + job = (await db.execute(select(SmsJob).where(SmsJob.id == job_id))).scalar_one_or_none() + if not job: + raise HTTPException(404, "Job not found") + if job.status not in (SmsJobStatus.failed, SmsJobStatus.cancelled): + raise HTTPException(400, f"Cannot retry job with status '{job.status.value}'") + job.status = SmsJobStatus.pending + job.retry_count = 0 + job.error_message = None + await db.commit() + return {"id": job.id, "status": "pending", "message": "Job queued for retry"} + + +# ── Manual queue trigger ────────────────────────────────────────────────────── + +@router.post("/trigger-queue", status_code=202) +async def trigger_sms_queue( + _admin: HubUser = Depends(require_super_admin), +): + """Manually trigger the SMS processing Celery task. Admin only.""" + try: + from app.worker import celery_app + celery_app.send_task("sms.process_queue") + return {"message": "SMS queue processing triggered", "status": "queued"} + except Exception as exc: + raise HTTPException(500, f"Failed to trigger task: {exc}") + + +# ── Semaphore health check ──────────────────────────────────────────────────── + +@router.get("/health") +async def semaphore_health( + _admin: HubUser = Depends(require_super_admin), +): + """ + Check Semaphore API connectivity and remaining message balance. + Returns: reachable (bool), balance (int | null), error (str | null). + """ + import httpx + from app.config import settings + + if not settings.SEMAPHORE_API_KEY: + return {"reachable": False, "balance": None, "error": "SEMAPHORE_API_KEY not configured"} + + try: + async with httpx.AsyncClient(timeout=8) as client: + resp = await client.get( + "https://api.semaphore.co/api/v4/account", + params={"apikey": settings.SEMAPHORE_API_KEY}, + ) + if resp.status_code == 200: + data = resp.json() + # Semaphore returns credits as a numeric field + balance = data.get("credits") or data.get("balance") or data.get("credit_balance") + return {"reachable": True, "balance": balance, "error": None, "account": data.get("name")} + return {"reachable": False, "balance": None, "error": f"HTTP {resp.status_code}"} + except httpx.TimeoutException: + return {"reachable": False, "balance": None, "error": "Timeout connecting to Semaphore API"} + except Exception as exc: + return {"reachable": False, "balance": None, "error": str(exc)} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1b95b9a..6c821b2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -57,9 +57,13 @@ export const updateLicense = (id: string, data: object) => api.put(`/licenses/${ 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 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) +export const getSmsStats = (params?: object) => api.get('/sms/stats', { params }).then(r => r.data) +export const retrySmsJob = (id: string) => api.post(`/sms/jobs/${id}/retry`).then(r => r.data) +export const getSmsHealth = () => api.get('/sms/health').then(r => r.data) +export const triggerSmsQueue = () => api.post('/sms/trigger-queue').then(r => r.data) // ── Billing ─────────────────────────────────────────────────────────────────── export const getInvoices = (params?: object) => api.get('/billing/invoices', { params }).then(r => r.data) diff --git a/frontend/src/pages/SmsPage.vue b/frontend/src/pages/SmsPage.vue index c8647d4..bace354 100644 --- a/frontend/src/pages/SmsPage.vue +++ b/frontend/src/pages/SmsPage.vue @@ -1,18 +1,183 @@