# USSD Gateway (Sprint 2) Africa Alert's USSD gateway lets Econet/NetOne subscribers pull grades, fees, and attendance from their handset — no smartphone, no internet. ## Wire format Econet and NetOne USSD gateways POST the same shape; we accept either JSON or form-encoded bodies. The response is always plain text (`Content-Type: text/plain; charset=utf-8`). ### Request | Field | Type | Notes | |---|---|---| | `sessionId` | string | Gateway-assigned conversation id. Same id reused across the whole dialogue. | | `phoneNumber` | string | MSISDN. Stored on the session for audit; not validated against the user's registered phone. | | `serviceCode` | string | e.g. `*123#`. Not used by the flow but logged. | | `text` | string | Cumulative text the user has typed during the session, separated by `*`. The state machine reads the **last segment** only. | ### Response Plain text. Two response types: - `CON ` — continue the session; await the user's next input. - `END ` — terminate the session; the gateway closes the dialogue. ## State transitions ``` (initial) ┌──────────────────────────────────────────────────┐ │ ▼ [start] ── dial ──▶ [awaitingStudentId] ── valid id ──▶ [awaitingPin] ▲ │ │ │ │ invalid id │ correct PIN │ ▼ ▼ └────── END "Invalid Student ID." [authenticated] ── 4 ──▶ END "Goodbye." │ ├─ 1 ──▶ END ├─ 2 ──▶ END ├─ 3 ──▶ END │ └─ other ──▶ CON "Invalid option." + main menu ``` Within `[authenticated]`, every menu choice ends the session (terminal END). This keeps each query to one round-trip on the wire. ## PIN security - 4 digits exactly (validated by `/^\d{4}$/`). - Stored as a bcrypt hash (`users.ussd_pin`). - After 3 consecutive wrong PIN attempts, `users.ussd_locked_until` is set to `now() + 30 minutes`. Subsequent attempts during the lock window return `END Account is locked. Try again later.`. - Counters reset on a successful PIN. Demo seed: every existing student with `role='student'` and `is_deleted=0` gets the default PIN `1234` (bcrypt-hashed) from migration `2026072900000000_ussd_pin.js`. ## Session management In-memory `Map` in `server/src/services/ussdSessionStore.js`. Sessions auto-expire after `USSD_SESSION_TIMEOUT_MS` (default 180000 ms = 3 min) of inactivity, swept by a 60-second `setInterval().unref()`. Session state survives across requests within the timeout. After END, the session is destroyed and the next request with the same sessionId starts a fresh session. Sessions are process-local. Server restarts drop in-flight sessions — the user just dials again. This is acceptable because USSD sessions are short-lived and per-conversation. ## Authentication If `USSD_GATEWAY_SECRET` env var is set, the controller rejects any request whose `X-Gateway-Secret` header doesn't match. Empty value = open endpoint (dev only). ## Performance - All handlers return in **3–10 ms** in local dev (well under the 800 ms budget). - One DB query per state transition; no N+1. - bcrypt verify is the slow part (~30 ms) — still well inside the budget; only triggered on PIN attempts. ## Example conversation ``` [Student dials *123#] CON Welcome to NextGen Learning. Enter Student ID: [Types: 6] CON Hello Nyasha. Enter your 4-digit PIN: [Types: 1234] CON Main Menu: 1. Grades 2. Fees 3. Attendance 4. Exit [Types: 1] END Latest grades: Mathematics: 98% (test) Mathematics: 95% (test) Mathematics: 92% (test) [Types: 2 — new session] END Fees balance: $0.00 0 overdue [Types: 3 — new session] END Last 30 days: 19/25 present (76%) [Types: 4 — new session] END Goodbye. ``` ## Postman / curl recipes The `text` field is the cumulative input the user has typed. The state machine parses the last segment, so for a 3-step flow you send three separate POSTs with the same `sessionId` and accumulating `text`. ### Initial session ```bash curl -X POST http://localhost:3001/api/ussd/callback \ -H "Content-Type: application/json" \ -d '{"sessionId":"abc123","phoneNumber":"+263771111111","serviceCode":"*123#","text":""}' # → CON Welcome to NextGen Learning.\nEnter Student ID: ``` ### Student ID ```bash curl -X POST http://localhost:3001/api/ussd/callback \ -H "Content-Type: application/json" \ -d '{"sessionId":"abc123","phoneNumber":"+263771111111","serviceCode":"*123#","text":"6"}' # → CON Hello Nyasha.\nEnter your 4-digit PIN: ``` ### PIN ```bash curl -X POST http://localhost:3001/api/ussd/callback \ -H "Content-Type: application/json" \ -d '{"sessionId":"abc123","phoneNumber":"+263771111111","serviceCode":"*123#","text":"6*1234"}' # → CON Main Menu:\n 1. Grades\n 2. Fees\n 3. Attendance\n 4. Exit ``` ### Grades ```bash curl -X POST http://localhost:3001/api/ussd/callback \ -H "Content-Type: application/json" \ -d '{"sessionId":"abc123","phoneNumber":"+263771111111","serviceCode":"*123#","text":"6*1234*1"}' # → END Latest grades:\n Mathematics: 98% (test)\n ... ``` ### Fees ```bash curl -X POST http://localhost:3001/api/ussd/callback \ -H "Content-Type: application/json" \ -d '{"sessionId":"def456","phoneNumber":"+263772222222","serviceCode":"*123#","text":"6*1234*2"}' # → END Fees balance: $0.00\n 0 overdue ``` ### Attendance ```bash curl -X POST http://localhost:3001/api/ussd/callback \ -H "Content-Type: application/json" \ -d '{"sessionId":"ghi789","phoneNumber":"+263773333333","serviceCode":"*123#","text":"6*1234*3"}' # → END Last 30 days:\n 19/25 present (76%) ``` ### Wrong PIN (1st of 3) ```bash curl -X POST http://localhost:3001/api/ussd/callback \ -H "Content-Type: application/json" \ -d '{"sessionId":"lock1","phoneNumber":"+26377","serviceCode":"*123#","text":"6*0000"}' # → CON Wrong PIN. 2 attempt(s) left.\nEnter your 4-digit PIN: ``` ### Locked account After 3 wrong PIN attempts in a row, the student's `ussd_locked_until` is set to `now() + 30 minutes`. Subsequent attempts return: ```bash curl -X POST http://localhost:3001/api/ussd/callback \ -H "Content-Type: application/json" \ -d '{"sessionId":"lock4","phoneNumber":"+26377","serviceCode":"*123#","text":"6*1234"}' # → END Account is locked. Try again later. ``` ### Invalid menu option ```bash curl -X POST http://localhost:3001/api/ussd/callback \ -H "Content-Type: application/json" \ -d '{"sessionId":"badmenu","phoneNumber":"+26377","serviceCode":"*123#","text":"6*1234*5"}' # → CON Invalid option.\n 1. Grades\n 2. Fees\n 3. Attendance\n 4. Exit ``` ### Gateway-secret check (when USSD_GATEWAY_SECRET is set) ```bash # Missing header → 401 curl -i -X POST http://localhost:3001/api/ussd/callback \ -H "Content-Type: application/json" \ -d '{"sessionId":"x","text":""}' # → HTTP/1.1 401 Unauthorized # Wrong header → 401 curl -i -X POST http://localhost:3001/api/ussd/callback \ -H "Content-Type: application/json" -H "X-Gateway-Secret: wrong" \ -d '{"sessionId":"x","text":""}' # → HTTP/1.1 401 Unauthorized # Correct header → 200 with USSD body curl -i -X POST http://localhost:3001/api/ussd/callback \ -H "Content-Type: application/json" -H "X-Gateway-Secret: $USSD_GATEWAY_SECRET" \ -d '{"sessionId":"x","text":""}' # → HTTP/1.1 200 OK, body: CON Welcome to NextGen Learning.\nEnter Student ID: ``` ## Ops endpoints `GET /api/ussd/_stats` — returns `{ active_sessions, timeout_ms, auth_required }`. Useful for dashboards; not security-sensitive. ## File map | File | Purpose | |---|---| | `server/src/database/migrations/knex/2026072900000000_ussd_pin.js` | Schema: `users.ussd_pin`, `_failed_attempts`, `_locked_until`, `_pin_set_at`. Backfills `1234` for every demo student. | | `server/src/services/ussdSessionStore.js` | In-memory session map + 60s sweep. | | `server/src/services/ussdStateMachine.js` | Pure functional state machine; lookup-table dispatch. | | `server/src/services/ussd.service.js` | DB façade — student lookup, PIN verify, grades/fees/attendance summaries. | | `server/src/controllers/ussd.controller.js` | POST `/api/ussd/callback` + GET `/api/ussd/_stats`. | | `server/src/index.js` | Mounts the controller at `/api/ussd`. | ## Env vars | Var | Default | Purpose | |---|---|---| | `USSD_SESSION_TIMEOUT_MS` | `180000` | Session inactivity timeout (ms). | | `USSD_GATEWAY_SECRET` | empty | If set, requires matching `X-Gateway-Secret` header. | ## Assumptions - **Student ID = `users.id`** (numeric). We chose numeric over roll-number / UID for uniqueness. Each school would need a printed card with the student's id, or a parent-friendly mapping (id-by-class). - **One round-trip per query.** Grades/fees/attendance are terminal ENDs. Pagination (top-N) is the response-length guard. - **No phone-number verification.** The session's `phoneNumber` is captured for audit but not matched against `users.phone`. Adding that would require parents to dial from the registered phone. - **Process-local sessions.** Restarting the server drops in-flight sessions; the user just dials again. Persistent session storage would add DB latency for no product value. - **No student self-service PIN reset.** Admins reset PINs via direct SQL or a future admin endpoint. For dev, the seed migration sets `1234` for every student. - **English only.** No localization yet. - **Locked = 30 minutes.** Hardcoded; not configurable in this version.