# PRD — doh_sims

Evidence date: 2026-10-16.

Source: `git@bitbucket.org:overdrive/doh_sims.git`, `main` at `5747dca4` (2026-10-07, "Merged in rachel_work (pull request #45)").

There is no prior PRD in Silicon Ark. The root `README.md` is the upstream Gibbon README plus a short MFA section. Requirements below are recovered from that README, `CHANGELOG.txt`, `version.php`, module READMEs, manifests, and the progression service. Provenance is labelled.

No passwords, tokens, or connection strings are copied here.

## Product purpose

**Documented (upstream).** Gibbon is "a flexible, open source school management platform" for teachers, students, parents, and schools (`README.md`, `composer.json`).

**Documented (Overdrive).** DOHCustomFields "extends the Gibbon School Management System with specialized custom fields and validation for the Department of Health (DOH) requirements", specifically South African ID numbers, student numbers, and date of birth (`modules/DOHCustomFields/README.md`).

**Implemented.** The tree is Gibbon core **29.0.02** plus Overdrive modules for registration and progression, campuses (houses), CHE/HEMIS extracts, invoices, public payment lookup, Moodle sync, and operational reports.

**Inferred.** The running product is a multi-campus health-education student information system on top of Gibbon, not a stock school install. The inference comes from DOH naming, SA ID rules, year groups described as programme levels (the Registrations README example is "First Year Nursing"), WIL hours, diploma eligibility, and CHE files. The institution's legal name and the live campus list are **Unknown / Requires Confirmation**.

## Users and roles

### Gibbon role categories

Action rows in module manifests use Gibbon's standard permission columns: Admin, Teacher, Student, Parent, Support, plus category flags for Staff, Student, Parent, and Other. Those columns are **Implemented** as the permission model. The seeded role names in a given database depend on install data in `gibbon.sql` / `gibbon_demo.sql` and later admin edits. This pass did not inventory every `gibbonRole` row.

| Actor | What the code lets them do | Provenance |
|---|---|---|
| Administrator | Gibbon admin actions. System Admin **Impersonate User** when the action is permitted and the current user's primary role matches the session role (`modules/System Admin/impersonateUser.php`) | **Implemented** |
| Teacher (Gibbon category) | Default **N** on Advanced Reports actions. Other stock modules follow their own defaults | **Implemented** in those manifests |
| Student | Registration and **My Work Integrated Learning Hours** when the Registrations actions are granted. README says Student and Parent need the WIL action; CHANGEDB v1.5.21 is documented as the grant | **Documented** |
| Parent | Same registration and WIL surfaces for linked children | **Documented** |
| Support | Advanced Reports default permission **Y** on every report action in `manifest.php` | **Implemented** in the manifest |
| Logged-out visitor | Welcome page. Payment Status card when `enablePaymentStatus` is `Y`. Public registration and password reset exist as root scripts (`publicRegistration.php`, `passwordReset.php`) | **Implemented** routes. Welcome Twig card text is **Documented** in the Payment Status README |
| Applicant | Admissions account email is the key for payment-status lookup. Public MFA challenge is documented for admissions continue-application and payment proof | **Documented** in root README (v29.0.01) and Payment Status README |

### Campus roles (DOH)

**Implemented** as insert rows in `modules/MultiCampus/manifest.php` into `gibbonCampusRole`. These are campus assignments, not replacements for `gibbonRole`.

| Code | Name | Flags seeded (view students, edit students, view staff, edit staff, view reports, run reports) |
|---|---|---|
| PRIN | Principal | Y Y Y Y Y Y |
| DPRIN | Deputy Principal | Y Y Y Y Y Y |
| HOSU | Head Office - Super User (Adding Staff) | Y Y Y Y Y Y |
| OM | Office Manager | Y Y Y N Y Y |
| REG | Registrar | Y Y Y N Y Y |
| CPM | Course Programme Manager | Y Y Y N Y Y |
| ASSU | Academic Support - Super User | Y Y Y N Y Y |
| LASU | Lecturer/Admin - Super User | Y Y Y N Y Y |
| DC | Diploma Coordinator | Y Y Y N Y N |
| CC | Course Coordinator | Y Y Y N Y N |
| AC | Admin Clerk | Y Y N N Y N |
| RL | Rep/Lecturer | Y N N N Y N |
| ASGA | Academic Staff Generic Acc | Y N N N Y N |

The README's access-level words (Full, Manage, Edit, View) are **Documented**. The table above is the **Implemented** seed. Admin users are documented as bypassing campus filters.

DOHCustomFields v0.0.10 and v0.0.11 also add class-enrolment roles `Registrar` and `Programme Coordinator (Campus)` on `gibbonCourseClassPerson.role`. Those are class roles, separate from the campus role table. **Documented** in that module changelog.

## Systems the product talks to

1. The person's browser.
2. MySQL or MariaDB. Database name and host come from install-time `config.php`, which is not committed.
3. Moodle, two ways:
   - **Outbound.** LMS Sync calls Moodle REST functions `local_gibbon_sync_get_students`, `local_gibbon_sync_get_grades`, and optionally `local_gibbon_sync_get_courses`. **Documented** in `modules/LMSSync/SETUP.md`. The Moodle plugin is not in this repo.
   - **Inbound reads.** Moodle and Moodle Cohorts create SQL views (`moodleUser`, `moodleCohort`, `moodleCohortMember`, `moodleCohortCourse`, `moodleCohortCourseCredits`, `moodleYearGroupCredits`) for Moodle to sync. **Documented**.
4. SMTP via PHPMailer. Invoice Customizer sends a proforma email with a PDF attachment. **Documented**. Host and credentials are settings, not in git.
5. PayPal and Stripe through Omnipay. **Implemented** as Composer requirements and upstream Gibbon finance. Which gateway a DOH site enables is **Unknown**.
6. Azure DevOps deploy to a Linux host over SSH (`azure-pipelines.yml`). **Implemented** as pipeline definition.
7. CHE. CHEReports writes Excel files. It "does not auto-submit data to an external CHE system" (`modules/CHEReports/README.md`). **Documented**.

Google API client and Microsoft Graph are Composer dependencies. Call sites were not traced. Whether SSO or calendar sync is enabled in production is **Unknown**.

## Major workflows

### WF-1 — Sign in, MFA, impersonation

**Documented** in root `README.md` and `CHANGELOG.txt` v29.0.01 / v29.0.02:

- TOTP secrets live in `gibbonMfa`, encrypted with AES-256-GCM (otphp). Optional `$mfaEncryptionKey` in `config.php` (32+ characters). Otherwise a key is derived from the installation GUID.
- Logged-in users manage MFA under **Preferences**.
- Public admissions and payment flows use `modules/Admissions/publicMfaChallenge.php` after email or access-code checks.
- Administrators can reset MFA from **User Admin → Edit User** when MFA data exists.
- Login tolerates a missing `gibbonMfa` table until the database upgrade (MFA treated as not configured).
- Lockout uses `verifyFailCount` / `lockedUntil`. Invalid proof-of-payment access codes are throttled per IP.
- v29.0.02 adds **System Admin → Impersonate User**. The page warns that the selected user can still use their own account, and the administrator must log out and back in to return. The action is refused when the session role is not the user's primary role.

Password reset scripts exist at the repository root (`passwordReset.php`, `passwordResetProcess.php`, `passwordResetMfaProcess.php`). Detailed token rules were not re-read in this pass. **Implemented** as entry points. Exact expiry is **Unknown** from this pass (upstream Gibbon behaviour, not re-verified line by line).

### WF-2 — Identify a student (SA ID and student number)

**Documented** in `modules/DOHCustomFields/README.md` and **Implemented** as module version 0.0.14.

Actor: staff on User Admin, Data Updater, student view, and student enrolment pages (script injection targets in the README). From v0.0.07, Admissions application edit is included in the AJAX uniqueness check.

SA ID Number:

- Custom field name `SA ID Number`, 13 digits, required in the README's field table.
- Luhn check digit, calendar date in digits 1–6, year window 00–49 = 2000–2049 and 50–99 = 1950–1999, citizenship digit described as 0 = SA citizen and 1 = permanent resident.
- Date of birth on the form must match the date inside the ID.
- Uniqueness check can exclude the current person, or the accepted applicant on an admissions edit (`gibbonAdmissionsApplicationID`).

Student number:

- README field table: 7 digits `YY#####`, required, read-only, auto-generated, random when the house text is "Emergency Care".
- v0.0.06 changelog: `gibbonPerson.studentID` allows NULL, empty strings become NULL, UNIQUE on non-NULL values. Duplicate migration keeps the number on the smallest `gibbonPersonID` and nulls the others.
- README still says the field is required and read-only. That conflicts with NULL being allowed. **Requires confirmation** which rule staff should follow.

Visibility in the README: students only (not staff, parents, other, application form, or public registration). Data Updater: yes.

### WF-3 — Admit an applicant and enrol modules

**Documented** in `modules/Registrations/README.md` and **Implemented** at the accept hook.

1. Registration creates a new admissions application (documented from v1.4.03). Programme defaults to the next year group. Year defaults to the Registrations target school year.
2. Sections: Student, Enrolment (including module checkboxes), Documents, Invoice, Final details. They reuse the application form's `gibbonFormPage` / `gibbonFormPageSection`.
3. On accept, `modules/Admissions/applications_manage_acceptProcess.php` loads `modules/Registrations/bootstrap.php` if the file exists.
4. The bootstrap registers `EnrolRegistrationModules`, which inserts `gibbonCourseClassPerson` rows with role Student for each id in `registrationSelectedClassIDs`.
5. House is set from the registration campus field `custom0207` by matching `gibbonHouse.name`. Empty or unmatched campus leaves the house unchanged.

The README also says `index.php` must require the bootstrap on every request. That line is **not** in `index.php` or `gibbon.php` in this tree. Accept-time enrolment does not depend on it. Other bootstrap work (gender form factories, gateway registration) only runs when something includes the bootstrap. **Requires confirmation** that registration pages still resolve those services without the global include.

### WF-4 — Register to return next year

**Documented** in the Registrations README. Actor: current student, parent, or staff with Manage Registration.

Preconditions documented:

- Setting **Enable Registrations**.
- Target school year, optional open and close dates.
- The student had an enrolment in the previous school year.

Steps:

1. Open Registration. Sections match the application form.
2. Form group at entry is prefilled from `gibbonFormGroup.gibbonFormGroupIDNext` and the README says the student cannot change it.
3. If the form has a programme field, form groups can be limited to programmes ticked on the form group.
4. Modules offered come from `gibbonCourseClassMap` for the next form group, otherwise courses/classes for the entry year and year group.
5. The student selects next-year modules (`registrationSelectedClassIDs`) and, when shown, repeat modules (`registrationRepeatClassIDs`). At least one is required.
6. On confirmation, a proforma invoice is generated if none exists and emailed, when Invoice Customizer `autoGenerateEnabled` is `Y` and Finance fees exist for the entry year.
7. Staff can confirm on behalf. The README says there is no staff override of eligibility blocks.

Eligibility checks that block the form are listed under business rules. The service that implements them has changed since the README's Phase 3 section. Testers should treat `src/Domain/ReEnrolment/ProgressionEligibilityService.php` as the code under test and the README as an older description.

### WF-5 — Record WIL hours

**Documented** in the Registrations README.

- Year group has optional **Minimum WIL Hours**.
- Staff: **Registrations → Manage Registration → WIL Hours**. Enter hours, proof submitted, approved, and upload proof. Files go to `uploads/Registrations/WIL_proof/`. Path stored on `gibbonStudentWILHours.proof_file_path`.
- Student or parent: **My Work Integrated Learning Hours**. Uploading a file sets proof submitted. They do not tick Approved.
- Eligibility when a minimum is set: hours met, proof submitted, and admin approval. **Documented**. The service methods `hasMetWILHoursForYear` and `hasMetWILRequirementForYear` exist. **Implemented** as methods. Whether every registration screen calls the stricter method was not re-audited line by line.

### WF-6 — Invoice and public payment status

**Documented** in Invoice Customizer `CHANGELOG.md` and Payment Status `README.md`.

1. On application submit, `CreateProformaInvoice` can create a Pending finance invoice and email it with a PDF (TCPDF) when auto-generate is on.
2. Unreleased changelog note: proforma and final invoices include only Finance fee rows whose name matches a selected module. Amounts of 0.00 are included. No synthetic lines for modules without a fee. Payment tracking is still created when the total is zero.
3. Logged-out user opens **View Payment Status**, enters the admissions-account email, and sees rows from `gibbonApplicationPaymentTracking` where amount due exceeds amount paid: application id, invoice id, total due, amount paid, outstanding, last updated, plus a total.
4. Setting `enablePaymentStatus` (scope `Payment Status`) defaults to `Y` on install. `index.php` passes `paymentStatusEnabled` into the welcome template.
5. The Payment Status README says to consider rate limiting and does not describe MFA on this page. Core v29.0.01 documents MFA for "Payment Status" and proof-of-payment upload via `publicMfaChallenge.php`. **Requires confirmation** whether the email lookup itself now requires MFA or only the proof-upload path does.

### WF-7 — CHE / HEMIS extracts

**Documented** in `modules/CHEReports/README.md` and `modules/CHEReports/docs/CHE_File_Reports.md`. Module version **Implemented** as 1.0.63.

Actor: Admin by default for exports and provider mapping. Codes screen defaults to School Admin in the README.

1. Maintain codes in `gibbonCHEReportCode` (unique code, name, optional `NN:columnKey`, description, active). Export the code list to `.xlsx`.
2. Map each campus (house) to `Provider_Code` and optional `Provider_ETQA_ID` in `gibbonCHEReportProviderByHouse`.
3. Export files 61, 63, 65, 66, 67, 68 as `.xlsx`, or one consolidated workbook. Campus filter follows MultiCampus assignments for staff who are not System Admin.
4. Nothing is submitted to CHE. Column maps in `config/che_file*_field_keys.php` must be aligned to the institution's official element dictionary. The docs say the extracts are not a certified submission until that mapping is done.

### WF-8 — Sync Moodle into the markbook

**Documented** in `modules/LMSSync/SETUP.md` and `CRON.md`. Module version 1.5.9.

1. Staff enable sync, set an interval in minutes, optionally limit course ids, and set a cron token.
2. Map Moodle courses to Gibbon courses (and optionally a class). `local_gibbon_sync_get_courses` can supply `idnumber` equal to the Gibbon course id.
3. Manual sync or HTTP `index.php?q=/modules/LMSSync/actions/syncCron.php&token=...` or a CLI path described in `CRON.md`.
4. Students match on username, email, or idnumber.
5. Grades can be limited to the class whose short name or name matches Moodle `group_name`. Otherwise the student's existing Gibbon class enrolment is used.

Failure behaviour (Moodle down, partial course failure, duplicate people) is **Unknown** beyond whatever `lib/SyncLog.php` records. The log screen exists (`actions/sync_log.php`). Exact retry rules were not read.

## Functional requirements

### FR-001 — Run as Gibbon 29.0.02

The application identifies itself as Gibbon `29.0.02` and refuses to be treated as current when the database version is behind: `index.php` compares `$versionDB` and `$versionCode` and routes an authorised user to System Admin update.

- Trigger: every authenticated page load through `index.php`.
- Output: normal shell, or the update screen when the database is older.
- Evidence: `version.php`, `index.php` version compare.
- Implementation: **Implemented**.
- Confidence: High.

### FR-002 — Enforce CSRF and nonce on process posts

POST requests whose script name contains `Process.php` and whose body has more than one field must carry a valid CSRF token and nonce. Failure redirects to the referrer with `return=error9` (CSRF) or `return=error10` (nonce).

- Evidence: `gibbon.php`.
- Implementation: **Implemented**.
- Confidence: High.
- Edge: a POST with a single field skips the check (`count($_POST) > 1`). **Inferred** from the condition. Whether that is intentional is **Unknown**.

### FR-003 — Validate South African ID numbers

Staff-facing person forms that the module injects into must reject an SA ID that is not 13 digits, fails Luhn, encodes an impossible date, or duplicates another person (excluding the person being edited). Date of birth must match the ID date.

- Evidence: `modules/DOHCustomFields/README.md`, AJAX scripts named there, changelog v0.0.07.
- Implementation: **Documented** and present as module code. Validator classes were not re-executed.
- Confidence: Medium. README is behind `version.php` (0.0.06 vs 0.0.14).

### FR-004 — Keep student numbers unique when set

Non-NULL `gibbonPerson.studentID` values are unique. Many people may have NULL. Empty strings are stored as NULL.

- Evidence: DOHCustomFields changelog v0.0.06.
- Implementation: **Documented** as a migration. Confirming the column on a live database requires a migrated environment.
- Confidence: Medium.

### FR-005 — Assign staff to campuses with seeded DOH roles

Installing MultiCampus creates `gibbonCampusRole`, `gibbonCampusPerson`, and `gibbonCampusPermission`, and inserts the 13 roles in FR table above.

- Evidence: `modules/MultiCampus/manifest.php`.
- Implementation: **Implemented** in the manifest (runs on module install).
- Confidence: High for the seed text. Whether `index.php` on a server actually loads the bootstrap is **Requires confirmation** (the line is absent in git).

### FR-006 — Filter student data by campus when enforcement is on

When `enforceCampusAccess` is `Y` (README default), staff see students whose house is one of their `gibbonCampusPerson` campuses. System Admin bypasses the filter. CHE exports and Advanced Reports share `campusFilter.php` behaviour as described in `CHE_File_Reports.md`.

- Evidence: MultiCampus README, CHE file report doc.
- Implementation: **Documented**. Hook scripts modify core module files at install time; those edits may or may not already be in git. This pass did not diff every hooked page.
- Confidence: Medium.

### FR-007 — Block ineligible re-registration

Before confirmation, registration must apply progression rules from `ProgressionEligibilityService` and the registration helper named in the README (`registration_eligibility_helper.php`).

Rules that are **Implemented** in the service class (preferred over the older README where they differ):

- Pass/fail uses the markbook course total (`activity_type = 'course'`), compared with class `pass_mark` or the scale's lowest acceptable grade.
- Earned credits come from that markbook entry (fallback: theory + clinical). Failed/carry loads use `gibbonCourse.credits`.
- Minimum credits come from `gibbonYearGroup.minimum_credits` when that column value is &gt; 0.
- `max_core_carried` is read from the year group when the column exists.
- A single failed module with **more than** 24 credits, or a combined failed total **over** 24, sets `failedModuleWith24Credits`. Exactly 24 does not.
- Second failure: the same course name and short name hard-failed in two school years. A pass does not count. A supplementary band or `SP` / `SUPP` value does not count as a hard fail. No course-total row means that class is not a hard fail.
- Diploma helper: no failed classes, and earned credits at least the year-group minimum (or the caller's override). There is no 1970 constant in this method.
- Progression blocker course codes are configurable (`getProgressionBlockerCourseCodes`). The class comment gives `CLN` as an example, not a fixed list.

Rules that remain **Documented** in the README and were not re-proven against every process file:

- The form is hidden (not merely rejected on submit) for students, parents, and staff confirm-on-behalf.
- Repeat-only registration is the exception when next-year modules are forbidden.
- WIL blocks registration when the year group has a minimum.

- Confidence: High for the service methods that were read. Medium for UI blocking on every entry page.

### FR-008 — Enrol selected modules when an application is accepted

Accepting an application includes the Registrations bootstrap and enrols `registrationSelectedClassIDs` as Student class membership for the entry year. The accept result exposes `enrolRegistrationModulesCount` and `enrolRegistrationModuleClassIds`. Zero is valid when nothing was selected.

- Evidence: Registrations README, `applications_manage_acceptProcess.php`.
- Implementation: **Implemented** call site. Enrol method body was not re-read in full.
- Confidence: Medium.

### FR-009 — Generate CHE-shaped workbooks without submitting them

An authorised user can export files 61, 63, 65, 66, 67, and 68, filtered by campus, using custom-field maps in `modules/CHEReports/config/`. Rows that would contain only `Date_Stamp` are skipped (per the file report doc).

- Evidence: `modules/CHEReports/docs/CHE_File_Reports.md`.
- Implementation: **Documented** in that file, which states what the SQL does. SQL files were not re-executed.
- Confidence: Medium.

### FR-010 — Publish a public payment balance by admissions email

A logged-out user who submits an email that exists on `gibbonAdmissionsAccount` sees outstanding `gibbonApplicationPaymentTracking` rows. Unknown email or a zero balance yields the documented "no payment records" outcome. The feature hides when `enablePaymentStatus` is not `Y`.

- Evidence: Payment Status README, `index.php` setting read.
- Implementation: **Documented** plus the setting flag in `index.php`.
- Confidence: Medium.
- Security note in the README: the page trusts the email address. It suggests rate limiting "for production" and does not show that it is implemented. Core changelog documents per-IP throttling for invalid proof-of-payment access codes, which may be a different page.

### FR-011 — Pull Moodle enrolments and grades into Gibbon

When sync is enabled, Gibbon calls the three Moodle functions, matches people, and writes class enrolment and markbook grades. Cron refuses to run again until `Sync Interval (minutes)` has elapsed. HTTP cron requires the configured token.

- Evidence: `modules/LMSSync/SETUP.md`, `CRON.md`.
- Implementation: **Documented**. `lib/MoodleAPI.php` and sync classes exist.
- Confidence: Medium.
- Dependency: Moodle plugin `local_gibbon_sync` is **not in this repository**.

### FR-012 — Offer operational reports to Admin and Support

Advanced Reports registers these sidebar actions, default Admin **Y**, Teacher **N**, Student **N**, Parent **N**, Support **Y**, staff category **Y**:

- New Enrolments Report
- Qualification List
- Qualification Enrolment Comprehensive
- Campus Student Demographic
- Students Registered for Module
- Contract / Student Type
- Completion Statistics Comprehensive
- Completion Statistics Demographic
- Module Registration Summary
- Modules Registration Details

Each has an export script beside the view script in `manifest.php`. PDF variants exist for some reports as files (qualification list, new enrolments, module student list, contract/student type) and are not all listed in the manifest excerpt that was read.

- Evidence: `modules/Advanced Reports/manifest.php`.
- Implementation: **Implemented** as action definitions. Report SQL was not executed.
- Confidence: High for the menu contract. Medium for the numbers the reports show.

### FR-013 — Deploy a release tarball to the Gibbon host

A pipeline on `main` or `releases/*` (and pull requests targeting `main`) installs production Composer dependencies, tars the tree excluding `.git`, `tests`, and `node_modules`, and copies it to `/home/deploy/_incoming`. An SSH step runs `/home/deploy/release.sh` with `APP=gibbon` and `KEEP=5`.

- Evidence: `azure-pipelines.yml`.
- Implementation: **Implemented** as YAML. The self-hosted agent, secrets, and `release.sh` are outside the repo.
- Confidence: High for the file. Unknown whether the pipeline is the one that last deployed.

## Constraints

- GPL-3.0. Module copyright lines add Silicon Overdrive / Wessels De Wet on DOHCustomFields and Silicon Overdrive on Advanced Reports and Moodle Cohorts.
- PHP platform in Composer is 7.4. System requirements also demand gettext, mbstring, curl, zip, xml, gd, intl, `max_input_vars >= 8000`, `max_file_uploads >= 20`, `allow_url_fopen = 1`, `register_globals = 0`, `session.gc_maxlifetime >= 1200`.
- Apache rewrite base in `.htaccess` is `/gibbon/`. A host mounted elsewhere must change that. **Implemented** default.
- CHE files are extracts, not a submission.
- Student number format documented as 7 digits caps a year at 99999. **Documented** limitation.
- SA ID validation only covers the South African 13-digit format. **Documented** limitation.

## Requirements that could not be confirmed

- Production URL, institution name, and which houses are real campuses.
- Whether modules are installed and active in the target database.
- Whether `index.php` on the server contains the MultiCampus and Registrations bootstrap lines.
- Which PHP version production runs.
- Whether PayPal, Stripe, Google, Microsoft Graph, or SMS are configured.
- The official CHE element dictionary the column maps must match.
- Full upstream-versus-fork diff of stock modules.
- Behaviour of the root `tests/` suite: the directory is absent, while Composer scripts expect it.
