# Business rules — ClickLearnerPortal

Evidence date: 2026-10-19. Source: `git@bitbucket.org:overdrive/clicklearnerportal.git`, `master` at `2cb0ae5`.

No business-rules document exists in the source repository. Every rule below was read out of code, config, or UI copy. Rules that the code enforces are **Implemented**. Rules that follow from naming or from a comment are **Inferred**. Nothing here is a new policy.

Stored procedures are not in this repository. A rule that depends on what a procedure returns is marked **Unknown**.

## Identity and access

| ID | Rule | Provenance |
|---|---|---|
| BR-001 | A learner signs in with a username and a password. The login page does not offer Google, a staff account, or a role picker. | **Implemented** (`LearnerLoginPage.js`) |
| BR-002 | The client refuses a username or password that is empty or only whitespace. | **Implemented** |
| BR-003 | The server refuses a username or password that is null or `""`. Whitespace is not rejected on the server. | **Implemented** |
| BR-004 | The username is looked up with programme code `null` and `par_activeonly` `true`. | **Implemented** call. What `true` removes is **Unknown** (procedure not in git) |
| BR-005 | If the lookup returns no row, the error is `Username not found.` The UI shows "Username does not exist." | **Implemented** |
| BR-006 | If more than one row comes back, the first row is used. The branch that rejected duplicates is commented out. | **Implemented** (first row). Order of rows **Unknown** |
| BR-007 | The submitted password must match `LearnerUser.Password` under a case-sensitive `string.Compare` (current culture, `ignoreCase` false). No hash is computed in this repository. | **Implemented** |
| BR-008 | A mismatch returns `Incorrect password.` The UI shows "Incorrect password. Please try again." and clears the password field. | **Implemented** |
| BR-009 | When `ActiveStatus` is false, `ModelState` is `2` and the error is `Inactive.` The UI shows "Learner is not active." | **Implemented**. Reachable only if the procedure still returns inactive rows while `par_activeonly` is true. **Unknown** whether it does |
| BR-010 | There is no lockout, retry limit, captcha, or delay after a bad password. | **Implemented** absence |
| BR-011 | A successful login does not create a session, cookie, or JWT. The browser is navigated to `redirectToURL`. | **Implemented** |
| BR-012 | The password field on the response is cleared to `""` before return. | **Implemented** |
| BR-013 | The same action opens a Serilog scope whose template includes the submitted password (`Learner Login {Username} {Password}`). | **Implemented**. This is what the code does. It is not evidence that logging passwords was an approved rule |
| BR-014 | JWT bearer authentication is configured (issuer `learn.clicklearning.org`, lifetime check on, audience equal to the issuer, expiry setting `3600` seconds) but no live action demands a token and no live action issues one. | **Implemented** |
| BR-015 | `AppID` `7BB92BC7-F734-4D63-AEE4-F62DF0248F1E` is in configuration and is not read by a controller or page. | **Implemented** config, unused |
| BR-016 | Class-level `[Authorize]` does not protect the live learner, programme, quiz, or TAO actions, because each live action is `[AllowAnonymous]`. | **Implemented** |

## Redirect after login

| ID | Rule | Provenance |
|---|---|---|
| BR-017 | The redirect target comes from `portal.puserredirectto` for the learner id. If that returns no row, `ModelState` is `2` and the error is `Error occurred, try again.` The UI does not map that string, so the form does not show it. | **Implemented** |
| BR-018 | When `Programme` is `QUIZ`, the URL sent to the browser is `{URL}/{learner ID}/{SourceQuizID}/{EndDate}`. | **Implemented** |
| BR-019 | On that QUIZ path, `portal.pLearnerStartedQuiz` is called before the response is returned. A failure there fails the login request. There is no catch. | **Implemented** |
| BR-020 | When `Programme` is anything else, including null, the URL sent to the browser is `URL` alone. `URLParameters` and `Default` are not applied. | **Implemented** |
| BR-021 | The UI navigates only when `modelState` is `1`, `redirectToURL` is truthy, and `errorMessage` is empty. | **Implemented** |
| BR-022 | The comparison to `QUIZ` is `==` on the strings returned by Dapper. Case and trimming follow ordinary C# string equality. | **Implemented** |

The login copy says the password is the one used for Reading Eggs at school. The server does not send programme code `RE`. Whether the procedure treats a null programme code as Reading Eggs is **Unknown**. The copy is **Documented** UI text, not a server rule.

## Open material and audience copy

| ID | Rule | Provenance |
|---|---|---|
| BR-023 | "Access Open Material" goes to the `loginURL` of programme id `999`. If that field is missing, the href stays `/`. | **Implemented**. What 999 is **Unknown** |
| BR-024 | The page says the content is for children aged 5–14 and can be one-on-one or parent-led. | **Documented** UI copy only. No age check exists in code |
| BR-025 | "Not Registered?" is a heading above the open-material button. No registration form exists in this repository. | **Documented** copy. Registration **Unknown** |

## Home learning and quizzes

| ID | Rule | Provenance |
|---|---|---|
| BR-026 | The home-learning page offers three outbound logins: Reading Eggs SSO (locale `za`), Matific ZA (`/za/en-za/login-page/`), and Reflective Learning (`app.reflectivelearning.co.za`). | **Implemented** links |
| BR-027 | The how-to guide is the static file `/images/How-to-guide.pdf`. | **Implemented** |
| BR-028 | The contact address is `info@clickfoundation.co.za`. | **Implemented** mailto |
| BR-029 | A COVID-19 banner links to `https://www.sacoronavirus.co.za`. The collapse control does not hide it, because the click toggles `active` and the render reads `isActive`. | **Implemented** |
| BR-030 | A quiz is treated as finished only when the parsed LTI message equals `Test is finished`. | **Implemented** |
| BR-031 | The quiz id is the part of `quiz_id` before a `?`. The message is taken from after that `?` when the split works, otherwise from `lti_errormsg`. | **Implemented** |
| BR-032 | On that message, the page posts `{ learnerId, quizId }` from `learner_id` and the parsed quiz id. No other check (signature, timestamp, user) is made. | **Implemented** |
| BR-033 | A failed completion POST does not retry. | **Implemented** |
| BR-034 | `POST api/tao/tao-form-submission-received` stores the two ids by calling `portal.ptaoformsubmissionreceived`. The ids are placed into the SQL text. | **Implemented** |
| BR-035 | `GET api/quiz/learnerdetail` returns the requested `userID` plus first and last name when the shared secret matches and a row exists. Query parameter `id` is ignored. | **Implemented** |
| BR-036 | A wrong quiz `securityToken` throws, rather than returning `401`. The `401` lines are comments. | **Implemented** |
| BR-037 | An unknown quiz `userID` throws `ArgumentException` with message `Not found.` | **Implemented** |

## Mobenzi

| ID | Rule | Provenance |
|---|---|---|
| BR-038 | `GET mobenzi-lookup-parent-id` requires query parameter `token` to equal the static secret in `MobenziController`. Otherwise the status is `401`. | **Implemented**. The secret is in source and is not copied here |
| BR-039 | The same secret literal is the quiz `securityToken` check. | **Implemented** |
| BR-040 | A matching lookup calls `portal.pmobenziparentverifyidnumberandfoodparcel` with the `idNumber` and returns HTML that posts `{ type: "SetValue", data: <result> }` to `window.parent` for any origin (`*`). | **Implemented**. The meaning of the result string is **Unknown** |
| BR-041 | `POST mobenzi-form-submission-received` has no token check. | **Implemented** |
| BR-042 | An empty body is ignored. A body that starts with `submissionjson=` is URL-decoded after that prefix and passed to `portal.pmobenziformsubmissionreceived`. A shorter body is ignored. | **Implemented** |

Comments say these routes are used by a Mobenzi form. The comment is the only link. Caller identity **Requires Confirmation**.

## SMS

| ID | Rule | Provenance |
|---|---|---|
| BR-043 | `POST api/notification/sms` has no authentication. | **Implemented** |
| BR-044 | Header `Type: Notification` with an empty or missing `Status` is logged and not stored. | **Implemented** |
| BR-045 | A notification status is parsed as an integer and stored with the comment's field set (status description, message reference, phone, five timestamps, user reference, message text). `Received` is the server clock (`DateTime.Now`), not a field from the provider. | **Implemented** |
| BR-046 | A reply stores message reference, phone, source number, create time, message text, user reference, flags, and original text, plus `Received` = `DateTime.Now`. | **Implemented** |
| BR-047 | Timestamps must be `yyyy/MM/dd HH:mm:ss` or they are stored as null. An empty string is null. | **Implemented** |
| BR-048 | A body that is not a notification and not a reply is logged as unknown and not stored. An empty body with no `Type` header is logged and not stored. | **Implemented** |

The comment names iTouch. That name is **Inferred** as the intended sender until someone confirms the integration is still in use.

## Programme record

`GET api/programme/{id}` returns id, code, name, URL, login URL, `IsTechRelated`, login-card URL, and supplier organisation from `portal.pProgrammeGetByID`.

No rule in this app decides who may call it. It is anonymous.

If the procedure returns no row, the action throws. There is no "programme not found" message. **Implemented**.

`IsTechRelated` is returned and is not read by either React page. **Implemented** field, unused by this UI.

## Hosting rules visible in config

| ID | Rule | Provenance |
|---|---|---|
| BR-049 | Non-development errors are a generic page plus the request id. Development uses the developer exception page. | **Implemented** |
| BR-050 | The pipeline redirects HTTP to HTTPS and, outside Development, adds HSTS. | **Implemented** |
| BR-051 | CORS allows any origin, method, and header. | **Implemented** |
| BR-052 | `AllowedHosts` is `*`. | **Implemented** |
| BR-053 | Static responses in `web.config` send `Cache-Control: no-cache` and also a 30-second client cache. Both are in the file. | **Implemented** config. Which header a given IIS setup actually emits **Requires Confirmation** |
| BR-054 | Google Analytics id `UA-162415700-1` and the Intercom widget are on when the env flags are non-empty. `.env.development` sets both flags to empty, so `npm start` turns them off. `.env` turns them on, so a production webpack build includes them. | **Implemented** |
| BR-055 | The service worker is unregistered. | **Implemented** |

## Data the code expects

These shapes are what C# properties Dapper will fill. They are not a schema.

`LearnerUser`: `ID`, `PersonID`, `Programme`, `Username`, `Password`, `FirstName`, `LastName`, `ActiveStatus`.

`LearnerRedirectTo`: `UserID`, `Programme`, `URL`, `URLParameters`, `Default`, `SourceQuizID`, `EndDate`.

`Programme`: `ID`, `Code`, `Name`, `URL`, `LoginURL`, `IsTechRelated`, `SupplierOrganisation`, `LoginCardURL`.

`QuizLearnerDetail` JSON names: `LearnerId`, `FirstName`, `LastName`.

`TaoFormSubmission` JSON names: `learnerId`, `quizId`.

`Learner`, `School`, `IdentityUser`, and `Authenticated` exist as classes and are not used by a live action.

## Rules that are not established

- Who is allowed to be a learner, how a username is issued, and how a password is set or reset.
- Whether passwords in `ClickDW` are stored in plaintext. This app compares them as plaintext. The column contents were not inspected.
- What programme id `999` is, and what a null programme code means to `puserbyusername`.
- Whether `QUIZ` URLs point at `click-learner-portal-platform`.
- School, grade, class, or province rules. The search that would use them is commented out. `Models/School.cs` is unused.
- Any fee, enrolment, or food-parcel policy. The Mobenzi procedure name contains `foodparcel`. The rule inside the procedure is **Unknown**.
- Retention of SMS bodies and form JSON. This app inserts them and does not delete them.
- Whether Windows authentication from `launchSettings.json` is in force anywhere.
