geocrop-platform./apps/nextgen/docs/USSD.md

273 lines
9.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 <message>` — continue the session; await the user's next input.
- `END <message>` — 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 <grades text>
├─ 2 ──▶ END <fees text>
├─ 3 ──▶ END <attendance text>
└─ 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<sessionId, SessionState>` 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 **310 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.