# PRD — BuildingTomorrow

Evidence date: 2026-10-19.

Source: `git@bitbucket.org:overdrive/buildingtomorrow.git`, `master` at `0c7d5df` (2024-10-23, merge of qa pull request #122).

There is no prior PRD in Silicon Ark. `README.md` documents the Admin Overdrive starter, not the learner programme. Requirements below are recovered from routes, controllers, models, views, helpers, migrations, seeds, and deployment files. Provenance is labelled. Secret values are omitted.

## Product purpose

**Documented.** `README.md` describes an "Admin Overdrive Starter" with authentication, authorisation, user registration, and menu management. The layout `<title>` is "Building Tomorrow". The meta author is "silicon Overdrive".

**Implemented.** A CodeIgniter 4 admin application in which staff manage households, learners, lessons, and audio, and in which a handset completes enrolment and lessons through Africa's Talking USSD and voice. Progress is bulk-upserted to Salesforce custom objects whose external id is `Ewaka_External_Id__c`.

**Discrepancy.** The repository name, the UI title, the Composer description ("Admin Overdrive"), and the Salesforce "Ewaka" field names are four labels for what the code treats as one system. **Unknown / Requires Confirmation:** the audience, the country of operation, and which name is the product name.

## Users and roles

Seeded in `app/Database/Seeds/UserRole.php`:

| Role id | Name | Seeded menu access |
|---|---|---|
| 1 | Developer | `UserAccess` inserts grants only for `role_id` 1 |

No other role is inserted by the seed. The user-role screen can create further roles at runtime (`Users::createRole`). Those roles are data, not code.

`Welcome::registration` inserts a new account with `inputRole` 1, so a self-registered user becomes a Developer. Provenance: **Implemented**. Whether that route is reachable on the public internet is **Unknown**.

`app/Database/Seeds/Users.php` inserts a default user when `php spark db:seed users` is run (`README.md`). The password hash source is in that seeder. The literal is not copied here. **Requires Confirmation:** rotate it before any shared environment.

## Actors

| Actor | Evidence | Status |
|---|---|---|
| Staff user | Session `isLoggedIn`. Home at `home` | **Implemented** |
| Developer role | Only role in the role seed; only role with seeded menu rows | **Implemented** |
| Anonymous visitor | Login, register, forgot-password, reset, set-password | **Implemented** as filter exceptions |
| Household handset | `GET /ussd` reads `$_POST` session fields and returns `CON` / `END` text | **Implemented**. Auth filter excludes `ussd` |
| Voice callback | `GET /assessments` and `GET /assessments_events_handler` return Africa's Talking XML | **Implemented**. Auth filter excludes both paths |
| Scheduler | `GET /cronjob/runScheduledJobs` and `php spark custom:cronjobcommand` | **Implemented**. The HTTP path is excluded from both auth filters |
| Organisation API client | `POST /organisations/login` then bearer token on the resource | **Implemented**. Filter excludes `organisations` and `organisations/*` |
| Pipeline | Bitbucket Pipelines on `master` and `qa` | **Implemented** |

## Systems the product talks to

1. MySQL schema `buildingtomorrow` on localhost, user `root`, empty password in the committed `env`. Provenance: **Documented** in `env`. Whether production uses other credentials is **Unknown**. `CI_ENVIRONMENT` in that file is `production`.
2. Africa's Talking USSD. The callback posts `sessionId`, `serviceCode`, `phoneNumber`, and `text`. Provenance: **Implemented** in `app/Views/ussd/ussd.php`.
3. Africa's Talking Voice. `USSDModel::initiate_africas_talking_call` constructs the SDK with username `siliAdmin` and the API key from setting `africas_talking_api_key`, and calls `from` = setting `africas_talking_ugandan_number`. Provenance: **Implemented**. The live numbers are settings, not code.
4. Africa's Talking voice callbacks at `/assessments` and `/assessments_events_handler`. The playback view reads `sessionId`, `isActive`, `callerNumber`, `destinationNumber`. On an inactive call it stores duration, currency, amount, and status. Provenance: **Implemented**.
5. Salesforce REST. `authenticateWithSalesforce()` uses the client-credentials grant. Tokens are stored in the `settings` table (`SALESFORCE_ACCESS_TOKEN`, expiry one hour). Bulk jobs upsert the objects listed in the project plan. Provenance: **Implemented**.
6. AWS SSM and S3. Salesforce credentials and the media bucket / CloudFront host are parameters. Audio upload and delete use `S3Client`. Provenance: **Implemented** in `app/Helpers/aws_helper.php` and `app/Models/MediaModel.php`.
7. AWS CodeDeploy. `appspec.yml` copies the tree to `/var/www/html` as `www-data`. Provenance: **Documented** in `appspec.yml` and **Implemented** as the pipeline's deploy script.
8. SMTP is used by the password-reset path in `Welcome` through `EmailModel`. The transport that is configured in a deployment is **Unknown**.
9. OpenAI. The package `orhanerday/open-ai` is required. The controller that would call it has no helper and no view. Provenance: **Not implemented** as a working feature.
10. SonarQube. A project key is committed. The pipeline step is commented out. Provenance: **Planned** in comments, not active in the pipeline file.

A committed Salesforce Enterprise WSDL (`buildingtomorrow.wsdl`, API 57.0, generated 2023-04-07) lists Nonprofit Success Pack and related packages. Application code does not load it. The live integration is the REST helper.

## Major workflows

### WF-1 — Staff sign-in

Trigger: `GET /` or `POST GetLogin`.

Inputs: `inputEmail`, `inputPassword`.

Behaviour: if `isLoggedIn` is already set, redirect to `home`. Otherwise require `inputEmail`. Look up the user, `password_verify` the password, set session `username`, `role`, and `isLoggedIn`, redirect to `home`. A miss shows "Your Email or Password is Wrong".

Output: a session cookie stored by the database session handler (`sessions` table, cookie `ci_session`).

Validation: email required. Password is not in the validator array on the login action. Provenance: **Implemented** in `Welcome::index`.

### WF-2 — Self-registration

Trigger: `GET /register`, `POST /register`.

Inputs: full name, email, password, password confirmation.

Behaviour: email must be unique in `users.username`. Passwords must match. The new user is stored with role 1. Success returns the login view.

Error: validation messages are flashed and the register view is shown again.

Provenance: **Implemented**. **Requires Confirmation:** whether public registration is intended. It grants the Developer role.

### WF-3 — Password reset

Trigger: `GET forgot_password`, `POST reset_password`, `GET set_password`, `POST update_password`.

Behaviour is implemented on `Welcome`. The mail transport depends on `EmailModel` and deployment SMTP settings. The exact token lifetime was not fully traced in this pass. Provenance: **Implemented** as screens and controller methods. Token expiry is **Unknown / Requires Confirmation** if it is not obvious from `Welcome::reset_password` on a later reading; the methods exist.

### WF-4 — USSD session start

Trigger: Africa's Talking posts to `/ussd` with an empty `text` (first dial).

Behaviour (`app/Views/ussd/ussd.php`):

1. Load the latest `africas_talking_log` row for the phone number.
2. If that row exists and its menu does not contain `END`, resume the stored action: `enrolment`, `assessment`, `initialassessment`, `numberrecognition`, `lesson`, or an account-management action.
3. If the previous row had `assessment_initiated` set, mark it redialled (`updateAfricasTalkingRedialledCall`) and mark the session processed.
4. Otherwise build a new menu from settings:
   - Unregistered phone: `unregistered_household_menu`.
   - Registered household with no students: `registered_household_no_students_menu`.
   - Registered household with at least one student: `registered_household_registered_students_menu`.
5. Log the session and echo `CON` plus the menu text.

A comment in that file describes the registered-student options as enrol, start/continue assessment, and start/continue lessons. The fourth action used in the resume switch is account management. The comment also names the short code `*134*433*33#`. The served text comes from settings, so the comment is not the live menu. Provenance: comment is **Documented** in source; menu text is **Implemented** as settings.

### WF-5 — Enrol a learner

Trigger: the handset chooses the enrol option, or a new enrolment is started.

Behaviour (`USSDModel::ussd_enrol_user`):

- Walk an enrolment question flow (`getDefaultEnrolmentQuestion`, then `correct_answer_question_id`).
- If the household already exists, skip household questions and start at the student-only enrolment question.
- If the previous question maps to `student.first_name` and `isUnique` is 1, reject a first name that already exists on that household and repeat the prompt.
- When the next question id is 0 and the user enters `1`, treat that as confirmation. Replay the session, map answers onto tables through `getQuestionDatabaseMapping`, and insert the household (phone number taken from the calling number) and student via `createNewHouseholdStudent`. New records are set active.
- Other first-menu choices in this method: `2` returns an END message that account management is under maintenance; `3` returns help text that points back at enrolment. Those branches disagree with the account-management path that the view calls for action `account_management`. See business rules.

Provenance: **Implemented**.

### WF-6 — Initial assessment, then lessons

Trigger: a registered student selects the assessment or lesson option.

Behaviour:

- `USSDModel::assessment` refuses a second initial assessment and tells the caller to dial again and choose option 3 for lessons (several END strings around the completed-assessment checks).
- `USSDModel::lesson` refuses lessons until the initial assessment is complete and tells the caller to choose option 2.
- Choosing a student lists students on the household when more than one exists (described in the comment at the top of `ussd.php`; the selection logic lives in the model).
- Starting the activity calls `initiate_africas_talking_call`, which places a voice call and writes `sending_queue_log` type `2`.

Provenance: **Implemented**.

### WF-7 — Voice question and answer

Trigger: Africa's Talking requests `/assessments` while `isActive` is 1.

Behaviour: read the open log, resolve the question audio URL for the student's language, and return XML `GetDigits` with `timeout` = the question `time_limit` and `callbackUrl` = setting `inititial_assessment_callback_url` (spelled that way in code). The `Play` URL is the question recording.

When the call is no longer active, store cost, currency, status, and duration. An SMS-on-drop block in this view is commented out.

The events handler (`assessments_events_handler.php`) then:

- Repeats the question when the digit equals setting `question_repeat_key`.
- Advances when the digit equals setting `question_no_answer_proceed_key`.
- Scores the lesson. If `lessons.type` is `assessment` and the total score is below `pass_score`, play setting `level_failed_audio` and send an SMS that includes the USSD code.
- On lesson completion, play `lesson_completed_congratulatory_audio`. On programme completion, play `lesson_program_completed_congratulatory_audio`.
- For `numberrecognition` and `initialassessment`, follow the placement branch, including `update_student_initial_assessment_placement_level_salesforce_id`.
- `reset_user_progress_on_number_recognition_fail` exists for a failed number-recognition result.

A caller number rewrite is hardcoded: if `callerNumber` is `+27214485759`, replace it with `+27721158502` and a comment says "testing". Provenance: **Implemented**. **Requires Confirmation:** remove or keep that rewrite.

### WF-8 — Account management on USSD

Trigger: resume action is one of `account_management`, `household_opt_out`, `student_opt_out`, `account_transfer_question1`, `account_transfer_question2`, `opt_in`, `student_opt_in`, `update_student_details`, and the per-field update actions.

The model comment lists five intentions: opt out the household (and its students), opt out one student, transfer an old number after two checks (old number and language), opt back in, and update student first name, last name, age, gender, or language. Menus are loaded with `getAccountManagementMenu` from named settings such as `default_account_management_menu` and `household_opt_out_menu`.

Helpers implement `opt_out_household_from_program`, `opt_out_student_from_program`, `opt_in_household_into_program`, `opt_in_student_into_program`, and `transfer_previous_account_to_current`.

Provenance: **Implemented**, with menu copy in settings. The enrolment method's "option 2 is under maintenance" string is a conflicting path. **Requires Confirmation:** which menu is live.

### WF-9 — Author content

Trigger: a signed-in user with a menu grant opens Lessons, Levels, Questions, Language, or Media.

Behaviour:

- Lessons have number, name, pass score, type, order, time limit, level, and language (`lessons` migration and the lesson form).
- Lesson types offered by the form: `numberrecognition`, `assessment`, `enrolment`, `initial assessment` (value contains a space), `lesson`.
- Questions can attach audio. Media uploads go to S3 and store a CloudFront URL.
- Flow builder routes: `logicbuilder`, `logicbuilderquestionflow`, `logicbuildercontent`, `questiontest`. `Lessons::questiontest` can walk a flow for a student without a handset.

Provenance: **Implemented**.

**Discrepancy.** Runtime comparisons use the string `initialassessment` (no space). The form option value is `initial assessment` (with a space). A lesson saved from that option will not match the `initialassessment` branches. Provenance: **Implemented** in both places. **Requires Confirmation:** which spelling production data uses.

### WF-10 — Sync to Salesforce

Trigger: a developer runs selected jobs on the cron screen (`POST cronjob/runJobs`) or something calls `GET cronjob/runScheduledJobs` or `php spark custom:cronjobcommand`.

Behaviour: each active `scheduled_cronjob` row has a `function` column. That name is invoked on the `Salesforce` controller. The methods build CSV and upsert:

| Method | Salesforce object | External id |
|---|---|---|
| `syncHouseholdsInBulk` | `Household__c` | `Ewaka_External_Id__c` |
| `syncStudentsInBulk` | `Students_Updated__c` | `Ewaka_External_Id__c` |
| `syncLevelsInBulk` | `Level__c` | `Ewaka_External_Id__c` |
| `syncLessonsInBulk` | `Lesson__c` | `Ewaka_External_Id__c` |
| `syncLevelLessonInBulk` | `Level_Lesson_Relationship__c` | `Ewaka_External_Id__c` |
| Student level / lesson relationship methods | `Student_Level_Relationship__c`, `Student_Level_Lesson_Relationship__c` | `Ewaka_External_Id__c` |

Household CSV columns include mobile number, `District__c` from setting `SALESFORCE_EWAKA_DISTRICT`, parent first name `Ewaka Household`, and parent last name = `household_name`.

On completion, Salesforce ids are written back onto the local rows. Failures are written to `salesforce_log`.

The HTTP cron URL has no login check. Provenance: **Implemented**. **Requires Confirmation:** what protects that URL in production (network restriction was not found in the app).

`salesforce_helper.php` disables TLS peer and host verification and comments "TESTING ONLY! - REMOVE WHEN DEPLOYING TO LIVE". Provenance: **Implemented**. **Requires Confirmation:** whether production still runs this file unchanged.

### WF-11 — Staff reports

| Screen | Controller | Behaviour |
|---|---|---|
| Student engagement | `Studentengagementreport` | Default range is the current calendar month. Rejects an empty range or a start after the end. |
| Student answers | `Studentanswerreport`, `Studentanswerrpt` | Date-ranged answer report. Two controllers exist. |
| Dropped calls | `Droppedcallsreport` | `ReportModel::getDroppedCalls` |
| Redialled calls | `Redialledreport` | `ReportModel::getRedialledCalls` |
| Performance tracking | `Performancetracking` | `PerformanetrackingModel::getCallsPerformanceTracking` |
| Initiated assessments | `Initiatedassessments` | List plus `initiatedassessment`. The same controller also has household create/update/delete methods |

The seeded menu has "Performance Tracking" with URL `#`. The other report controllers are not in `UserMenu` seed. They are reachable if a menu row exists or if auto-routing is used by someone who already passes the grant check. A URL with no `user_menu` row is redirected to `/` by `Authorization`. Provenance: **Implemented**.

### WF-12 — Organisations API

Trigger: `POST /organisations/login` with JSON `name` and `api_key`.

Behaviour: if an organisation matches, return a JWT (HS256, expiry `time() + 6000` seconds). Later list, read, create, update, and delete require `Authorization: Bearer`. The list payload key is `incident_type`. Success strings say "Employee created/updated/deleted". Provenance: **Implemented**. The employee wording is **Inferred** leftover from a template. **Requires Confirmation:** any real client of this API.

The JWT HMAC secret defaults to the literal `secret` in `JwtLibrary`. Provenance: **Implemented**. **Requires Confirmation:** replace it before any shared use.

## Functional requirements

### FR-001 — Authenticate staff

The system accepts an email and password and opens a session only when `password_verify` succeeds.

Evidence: `Welcome::index`, `users` table, database session config.

Implementation: Implemented.

Confidence: High.

### FR-002 — Gate staff screens

A request that is not in the auth exception list requires `isLoggedIn`. A first URL segment that matches a menu row requires a `user_access` row for the session role. A segment with no menu row redirects to `/`. A segment with a menu row and no grant redirects to `blocked`.

Evidence: `app/Config/Filters.php`, `app/Filters/Authentication.php`, `app/Filters/Authorization.php`.

Implementation: Implemented.

Confidence: High.

### FR-003 — Enrol a household and learner by USSD

An unregistered number receives the unregistered menu. Completing the enrolment question flow and confirming with `1` creates a household keyed by the calling number and a student. A duplicate student first name in the same household is rejected when the question is marked unique.

Evidence: `ussd.php`, `USSDModel::ussd_enrol_user`, `createNewHouseholdStudent`.

Implementation: Implemented.

Confidence: High.

### FR-004 — Resume an unfinished USSD session

A new dial with empty `text` continues the last log row whose menu does not contain `END`, using the stored `action`.

Evidence: `app/Views/ussd/ussd.php`.

Implementation: Implemented.

Confidence: High.

### FR-005 — Order assessment before lessons

Lessons are refused until the initial assessment is complete. A completed initial assessment is refused a second time and the caller is sent to lessons.

Evidence: END strings in `USSDModel::assessment` and `USSDModel::lesson`.

Implementation: Implemented.

Confidence: High.

### FR-006 — Play questions on a voice call

The active-call callback returns GetDigits XML that plays the language-specific recording and posts digits to `inititial_assessment_callback_url`.

Evidence: `app/Views/ussd/assessments.php`, settings key `inititial_assessment_callback_url`.

Implementation: Implemented.

Confidence: High.

### FR-007 — Score an end-of-level assessment

When the lesson type is `assessment` and the student's total score is below the lesson `pass_score`, the call plays the level-failed audio and sends an SMS.

Evidence: `assessments_events_handler.php` comparison against `pass_score`.

Implementation: Implemented.

Confidence: Medium. The pass comparison was read at the call site. Every scoring branch in the 1 300-line view was not re-derived line by line.

### FR-008 — Opt out, opt in, transfer, and edit a learner by USSD

Account management can opt a household or student out and back in, transfer a household to a new number after checks, and update student name, age, gender, and language.

Evidence: `USSDModel::account_management` and the helpers named in WF-8.

Implementation: Implemented.

Confidence: Medium. The action names and helpers are present. Each prompt's exact copy is a setting.

### FR-009 — Maintain lesson content

Staff can create, update, and delete languages, levels, lessons, questions, and audio files. Audio is stored in S3. Lessons declare a type and a pass score.

Evidence: `Language`, `Levels`, `Lessons`, `Questions`, `Media`, `MediaModel`.

Implementation: Implemented.

Confidence: High.

### FR-010 — Sync records to Salesforce

Scheduled or manual cron runs call Salesforce bulk upserts and store returned ids on the local rows. Errors are logged.

Evidence: `Salesforce.php`, `salesforce_helper.php`, `salesforce_log` migration.

Implementation: Implemented.

Confidence: High for the object list. Confidence medium for field-level mapping beyond the household header, which was read in full.

### FR-011 — Report call and learning activity

Staff can open engagement, answer, dropped-call, redialled-call, and performance-tracking reports. Engagement validates the date range.

Evidence: report controllers and `ReportModel`.

Implementation: Implemented.

Confidence: High for the existence of the screens. The SQL definitions were not copied into this PRD.

### FR-012 — Expose organisation CRUD with a token

A client that presents an organisation name and API key receives a bearer token and can then list, read, create, update, and delete organisations.

Evidence: `Organisations.php`, `JwtLibrary.php`, route comments in `Routes.php`.

Implementation: Implemented.

Confidence: High.

### FR-013 — Deploy qa and production through CodeDeploy

A commit to `qa` deploys with the `QA_*` pipeline variables. A commit to `master` deploys with the `PROD_*` variables. Application start runs `composer update --no-dev` and `php spark migrate`.

Evidence: `bitbucket-pipelines.yml`, `appspec.yml`, `scripts/start_server`.

Implementation: Implemented as pipeline definition. The live AWS targets are **Unknown**.

Confidence: High for the file. Unknown for the current AWS state.

## Important constraints

- USSD, voice, cron, and the organisations API are outside the session filter. See business rules.
- CSRF and honeypot filters are commented out in `Filters.php`.
- Auto-routing is on.
- The committed `env` contains an encryption key and sets the environment to production. The key is not repeated in this document. **Requires Confirmation:** that file must not be the production secret store.
- Salesforce TLS verification is disabled in the helper.
- `StudentExtraFields2` migration `up()` is inside a comment, while PHP still writes Salesforce id columns that this migration would have added. A database created only from current migrations may not have those columns.

## Unknown / Requires Confirmation

- Product name: Building Tomorrow versus Ewaka versus Admin Overdrive.
- Live USSD code, Africa's Talking numbers, and whether the hardcoded caller rewrite is still required.
- Whether public self-registration and the unauthenticated cron URL are accepted.
- Whether the organisations API has a consumer.
- JWT secret and the committed encryption key.
- Whether `initial assessment` and `initialassessment` are both present in production data.
- Whether mPDF, PhpSpreadsheet, or OpenAI are used outside `app/`.
- Production database credentials, SMTP, and CodeDeploy application names.
- Anything that changed in git after `0c7d5df`.
