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

9.9 KiB
Raw Blame History

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

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

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

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

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

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

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)

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:

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

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)

# 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.