273 lines
9.9 KiB
Markdown
273 lines
9.9 KiB
Markdown
# 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 **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.
|