# PRD — ClickLearnerPortal

Evidence date: 2026-10-19. Source: `git@bitbucket.org:overdrive/clicklearnerportal.git`, `master` at `2cb0ae5` (2023-02-07, "Initial commit", Dirk).

Provenance labels: **Documented**, **Implemented**, **Inferred**, **Planned**, **Unknown**.

No formal PRD existed in the repository or in `silicon_ark/ClickLearnerPortal/` before this file. Requirements below are recovered from the implementation. They are not a new product decision.

UI titles call the product **Click Foundation Learner Portal**. The JWT issuer is `learn.clicklearning.org`. A separate product statement was not found. Sentences on the login page are UI copy, listed under FR-001.

## Users and roles

No role table, no claims, and no permission check beyond a shared query secret on two actions. JWT bearer authentication is registered in `Startup.cs`. Every live action is marked `[AllowAnonymous]`. The class-level `[Authorize]` on learner, programme, quiz, and TAO controllers is overridden by that attribute. No code in this repository issues a JWT. `AccountController.cs` is commented out in full.

| Actor | What the code allows | Provenance |
|---|---|---|
| Anonymous visitor | Open `/` and `/learner/login`. Submit username and password to `POST api/learner/login`. Open `/click-home-learning`. Follow the external programme links and the how-to PDF. | **Implemented** |
| Registered learner | The login page copy addresses this person. The server looks up `portal.puserbyusername` and compares the submitted password to the stored `Password` field. There is no session after success: the browser is sent to `redirectToURL`. | **Implemented** check. The word "registered" is UI copy |
| Unregistered visitor | The login page offers "Access Open Material", which navigates to programme `999`'s `loginURL`. | **Implemented** call. What programme 999 is **Unknown** |
| Mobenzi form | Comments on `MobenziController` say the two Mobenzi routes are used by a Mobenzi form. `GET api/quiz/learnerdetail` has the same comment. | Comment. Caller **Requires Confirmation** |
| iTouch | Comment on `POST api/notification/sms` says "Used by iTouch". | Comment. Caller **Requires Confirmation** |
| TAO / test runner | `/click-home-learning` reads `learner_id`, `quiz_id`, and an LTI message, then posts to `api/tao/tao-form-submission-received`. The API itself does not check a caller. | **Implemented** handler. That TAO is the sender is **Inferred** from the route name and the query shape |
| Staff / admin | No staff screen, no admin route, no role claim. | Absent |

`ClientApp/src/components/PrivateRoute.js` redirects to `/account/login` when `services/AuthenticationService` has no current user. `App.js` does not render `PrivateRoute`. The `services` module is not in the repository. Provenance: **Implemented** file, not on the live path.

## Major workflows

### Learner sign-in and redirect

1. `/` renders `HomePage`, which renders `LearnerLoginPage`. `/learner/login` renders the same page directly.
2. On mount, if the open-material URL is still `/`, the page `GET`s `api/programme/999` and stores `loginURL` (or leaves `/` when that field is empty). There is no `.catch`. A failed call leaves the button pointing at `/`.
3. The form blocks submit when username or password is empty or whitespace.
4. Submit `POST`s `api/learner/login` with `{ username, password }`.
5. The server sets `ModelState` to `0` and clears `ErrorMessage`, then:
   - empty username → `The username cannot be empty.`
   - empty password → `The password cannot be empty.`
   - lookup via `portal.puserbyusername` with programme code `null` and `par_activeonly` `true` (the repository default)
   - no row → `Username not found.`
   - password mismatch → `Incorrect password.` Comparison is `string.Compare(..., false)` (case-sensitive, current culture). It is not a hash check.
   - `ActiveStatus` false → `ModelState` `2`, `ErrorMessage` `Inactive.`
   - redirect lookup null → `ModelState` `2`, `ErrorMessage` `Error occurred, try again.`
   - programme equals `QUIZ` → `RedirectToURL` is `{URL}/{learner.ID}/{SourceQuizID}/{EndDate}`, then `LearnerStartedQuiz` runs, `ModelState` `1`
   - any other programme → `RedirectToURL` is `URL` only, `ModelState` `1`
6. The password property is set to `""` before the response. The Serilog scope opened at the start of the action already includes the password. See business-rules.md.
7. The page maps three server strings onto field errors: `Username not found.` → "Username does not exist."; `Incorrect password.` → "Incorrect password. Please try again."; `Inactive.` → "Learner is not active."
8. When `modelState` is `1` and `redirectToURL` is set and `errorMessage` is empty, the page assigns `window.location` to that URL.
9. Other non-empty `errorMessage` values (`Error occurred, try again.`, and the empty-field strings) do not change the form. The user sees no message.
10. A thrown error (network, or a server exception) puts the error object into the username feedback.

Provenance: **Implemented** in `LearnerLoginPage.js` and `LearnerController.cs`.

The multiple-row branch that would reject a username found more than once is commented out. `FirstOrDefault()` is used. Which row wins is decided by the procedure, which is not in this repo. **Unknown**.

Whether `par_activeonly` true already removes inactive rows, making the `Inactive.` branch unreachable, is **Unknown**. The procedure body is not here.

`URLParameters` and `Default` on `LearnerRedirectTo` are not read by the controller.

### Access open material

The "Access Open Material" control is an anchor whose `href` is the `loginURL` loaded for programme id `999`. The button is inside that anchor. Copy under the form says the portal is for registered learners and, in parentheses, "the one you use for Reading Eggs at school". Copy under the button says the portal provides content for children aged 5–14, one-on-one or parent-led.

Provenance: **Documented** as UI strings. The programme row is **Unknown**.

### Click home learning

Route `/click-home-learning` (`ClickHomeLearningPage.js`):

- A banner links to `https://www.sacoronavirus.co.za`. The collapse control toggles state field `active`. The render reads `isActive`, which is only set in the constructor (`false`). The click does not hide the banner.
- Three blocks link out, each with `target="_blank"`:
  - Reading Eggs SSO at `https://sso.readingeggs.com/login` (locale `za`, redirect host `app.readingeggs.com`). The full query string is in the source file.
  - Matific: `https://www.matific.com/za/en-za/login-page/`
  - Reflective Learning: `https://app.reflectivelearning.co.za/auth/login`
- A link opens `/images/How-to-guide.pdf`.
- Footer mailto `info@clickfoundation.co.za`.
- On mount, if `quiz_id` is present, the value is split on `?`. The first piece is the quiz id. The code then tries to read an LTI message from the second piece (`split('=')[1]`). If that throws, it uses query parameter `lti_errormsg`.
- When that message equals `Test is finished`, the page `POST`s `api/tao/tao-form-submission-received` with `{ learnerId, quizId }` taken from `learner_id` and the parsed quiz id. A failed POST sets `isQuizCompleted` back to false and does not retry (`componentDidMount` runs once).

Provenance: **Implemented**.

`silicon_ark/click-learner-portal-platform/` records a return URL of the same path and the same `learner_id` / `quiz_id` query on `learn.clicklearning.org` (Production branch) or `portaltest.clicklearning.org` (`master` of that other repo). This repository's JWT issuer is `learn.clicklearning.org`. This repository does not contain the platform's source. The match is **Inferred**.

### Mobenzi ID lookup

`GET mobenzi-lookup-parent-id?token=…&idNumber=…`

The action is anonymous. If `token` is not the static secret compiled into `MobenziController`, the response is `401`. The secret value is not repeated in this document. The same literal is compiled into `QuizController`.

On a matching token, the action calls `portal.pmobenziparentverifyidnumberandfoodparcel` and returns HTML whose script `postMessage`s `{ type: "SetValue", data: "<procedure result>" }` to `window.parent` with target `*`.

Provenance: **Implemented**. What the procedure returns is **Unknown**.

### Mobenzi form submission

`POST mobenzi-form-submission-received`

No token check. An empty body returns without writing. Otherwise the raw body is read. If it is longer than the prefix `submissionjson=`, the remainder is URL-decoded and passed to `portal.pmobenziformsubmissionreceived`. The action returns no body (`200` from an empty `Task`, unless the procedure throws).

Provenance: **Implemented**.

### Quiz learner detail

`GET api/quiz/learnerdetail?id=…&userID=…&securityToken=…`

Anonymous. A token other than the shared secret throws `InvalidOperationException`. That type is not `HttpResponseException`, so `HttpResponseExceptionFilter` does not turn it into `401`. The commented lines in the action show a `401` that is not compiled. An unknown `userID` throws `ArgumentException`. A found row returns `LearnerId`, `FirstName`, and `LastName` (those JSON names are set with `JsonPropertyName`). The `id` query parameter is not used.

Provenance: **Implemented**.

### SMS notification and reply

`POST api/notification/sms`

Anonymous. Two shapes, both described in comments inside the method:

- Header `Type: Notification` plus header `Status`. The status value is stored as `Status=<value>` and parsed as a notification. An empty status is logged and ignored.
- No `Type` header: the body is read. A body starting `Type=Notification&` or `Type=Replies&` selects the branch and strips that prefix. Anything else is logged as an unknown type.

Notification fields written to `portal.pSMSNotificationInsert`: status (int), status description, message reference, phone number, create/scheduled/submit/confirm/final times, user reference, message text, and the server's `DateTime.Now` as `Received`.

Reply fields written to `portal.pSMSReplyInsert`: message reference, phone number, source number, create time, message text, user reference, flags, original text, and `DateTime.Now` as `Received`.

Times use `yyyy/MM/dd HH:mm:ss`, invariant culture. `""` and unparseable values become null. `int.Parse` on `Status` can throw if the header path produced a non-integer. There is no try/catch.

Provenance: **Implemented**. The sample field lists in the comments are examples, not captured traffic.

### TAO form submission API

`POST api/tao/tao-form-submission-received` with JSON `{ learnerId, quizId }`.

Anonymous. Logs the two ids at debug and calls `portal.ptaoformsubmissionreceived` through a `CALL` statement. The ids are interpolated into the SQL text. There is no authentication and no validation that the ids are safe literals.

`LearnerStartedQuiz` uses the same style of `CALL` against `portal.pLearnerStartedQuiz`. It runs during a successful `QUIZ` login, not from this action.

Provenance: **Implemented**.

### Programme read

`GET api/programme/{id}` where `{id}` is an integer.

Anonymous. Loads `portal.pProgrammeGetByID` and copies id, code, name, URL, login URL, tech flag, login-card URL, and supplier organisation onto the client model. A null row throws when the action reads `dbResult.ID` (no null check).

Provenance: **Implemented**.

## Integrations

| System | Direction | Data this repo exchanges | Auth visible here | Failure behaviour in this repo |
|---|---|---|---|---|
| `ClickDW` via `DefaultConnection` | This API → PostgreSQL | Learner lookup, redirect, programme, Mobenzi verify and form | Connection string. User id `postgres`. Password is in `appsettings.json` and is not copied here | Unhandled repository exceptions |
| `ClickSTAGING` via `StagingConnection` | This API → PostgreSQL | SMS insert, TAO form `CALL`, learner-started-quiz `CALL` | Same pattern, database name `ClickSTAGING` | Unhandled repository exceptions. The TAO calls are not parameterised |
| Reading Eggs, Matific, Reflective Learning | Browser navigation | None. The home-learning page only links | None | The other site's behaviour is outside this repo |
| Google Analytics | Browser → Google | Measurement id `UA-162415700-1` when the env value is non-empty | None | `gtag` is skipped when the id is empty |
| Intercom | Browser → Intercom | Widget app id `nszrac5k` when `REACT_APP_LEARNER_HELP_WIDGET_ENABLE` is non-empty | None in this repo | Widget script is not inserted when the flag is empty |
| Mobenzi | Inbound HTTP, if the comments are right | ID number and token in; HTML `postMessage` out. Form JSON body in | Shared secret on the lookup and on quiz detail. The form POST has no secret | Lookup: `401` on a bad token. Quiz: unhandled exception. Form: silent return on an empty or short body |
| iTouch | Inbound HTTP, if the comment is right | SMS status and reply fields | None | Warning log and empty `200` for unknown or empty payloads. `int.Parse` can `500` |
| TAO return | Inbound browser navigation, **Inferred** | Query `learner_id`, `quiz_id`, LTI message; JSON post of the two ids | None | Failed POST is logged to the console and does not retry |
| LTI platform (`click-learner-portal-platform`) | Not called by this repo | This repo's `QUIZ` redirect shape and `/click-home-learning` query match that repo's launch and return URL | None here | Relationship **Requires Confirmation** |

`appsettings.production.json` is `{}`. There is no `appsettings.Development.json`. Unless the host overrides configuration, every environment uses the connection strings in `appsettings.json`.

## Constraints

- Target framework `netcoreapp3.1` (`ClickLearnerPortal.csproj`). ASP.NET Core 3.1 is out of support. That is a fact about the target, not a decision to upgrade.
- Node is required for a Debug build when `ClientApp/node_modules` is missing, and for publish (`npm install`, then `npm run build`).
- CORS allows any origin, any method, and any header (`Startup.cs`).
- `AllowedHosts` is `*`.
- HTTPS redirection and HSTS (non-development) are in the pipeline. `web.production.config` also contains an HTTPS redirect. Nothing in git applies that transform.
- JWT `RequireHttpsMetadata` follows `envIsDevelopment`, which defaults to `true` and is only ever assigned `true`. Metadata HTTPS is therefore not required in the code path that was read.
- `launchSettings.json` sets IIS `windowsAuthentication` true and `anonymousAuthentication` false. `web.config` does not. Whether a deployed IIS site uses the launch settings is **Unknown**.
- Passwords are compared in plaintext and the login scope logs the submitted password.
- No rate limit, captcha, or account lockout is present.
- No background worker, queue, or scheduled job is present.
- `dotnet-ef` 3.1.0 is in `.config/dotnet-tools.json`. No `DbContext` and no migrations are in the tree. Data access is Dapper.

## Requirements discovered from the implementation

### FR-001 — Show the learner sign-in page at `/` and `/learner/login`

The system shows a sign-in form titled "Click Foundation Learner Portal", a show/hide control on the password field, the Reading Eggs hint, and the open-material section described above.

The password input is created with `type="text"`. The show/hide control switches it between `text` and `password`. On first paint the password is visible.

- Actor: anonymous visitor.
- Trigger: navigation to `/` or `/learner/login`.
- Output: the form. Open-material href comes from programme 999.
- Evidence: `App.js`, `HomePage.js`, `LearnerLoginPage.js`.
- Implementation: **Implemented**.
- Confidence: High.

### FR-002 — Reject an empty username or password before calling the API

The client treats `""` and whitespace-only values as invalid and shows "Please enter a username." or "Please enter a password."

The server repeats the empty check with `IsNullOrEmpty` only (whitespace would pass the server). The UI blocks whitespace, so that server branch is for non-UI callers.

- Evidence: `LearnerLoginPage.js` `validateUsername` / `validatePassword`; `LearnerController.Login`.
- Implementation: **Implemented**.
- Confidence: High.

### FR-003 — Sign a learner in against `ClickDW` and send the browser to the stored URL

Behaviour is the workflow above. Success does not create a cookie or a JWT.

- Inputs: JSON username and password.
- Outputs: the same model, with `password` cleared, `modelState` `1`, and `redirectToURL` set. Name is `FirstName + " " + LastName`.
- Validation: non-empty fields, a row, case-sensitive password match, `ActiveStatus`, a redirect row.
- Errors: field messages for the three strings the UI knows. `ModelState` `2` messages are not shown. Repository exceptions are unhandled.
- Evidence: `LearnerController.cs`, `LearnerRepository.cs`, `LearnerLoginPage.js`.
- Implementation: **Implemented**.
- Confidence: High for the code path. Confidence in the procedure's filter is low. **Requires Confirmation**.

### FR-004 — For programme `QUIZ`, append ids and record a start

When `LearnerRedirectTo.Programme` equals `QUIZ`, the redirect URL is `URL + "/" + learner.ID + "/" + SourceQuizID + "/" + EndDate`, and `portal.pLearnerStartedQuiz` is called with those two ids before the response.

`URLParameters` is not appended.

- Evidence: `LearnerController.Login`, `TAORepository.LearnerStartedQuiz`.
- Implementation: **Implemented**.
- Confidence: High for the concatenation. The destination host is whatever `URL` the procedure returns. **Unknown**.

### FR-005 — Load programme 999 for the open-material link

`GET api/programme/999` on login mount. The button uses `loginURL`.

- Evidence: `LearnerLoginPage.js`, `ProgrammeController.cs`.
- Implementation: **Implemented**.
- Confidence: High for the call. The meaning of id 999 is **Unknown**.

### FR-006 — Read any programme by integer id

`GET api/programme/{id}`.

- Actor: anonymous.
- Output: client `Programme` fields listed in the workflow.
- Errors: missing row is a null dereference, not a `404`.
- Evidence: `ProgrammeController.cs`, `ProgrammeRepository.cs`.
- Implementation: **Implemented**.
- Confidence: High.

### FR-007 — Show programme links and record a finished test

`/click-home-learning` shows the three logins, the PDF, the contact, and the COVID banner, and posts the TAO callback when the LTI message is `Test is finished`.

- Evidence: `ClickHomeLearningPage.js`, `TAOController.cs`.
- Implementation: **Implemented**.
- Confidence: High.
- Requires confirmation: that the LTI platform in the sibling Silicon Ark set is the system that opens this URL.

### FR-008 — Answer a Mobenzi ID lookup with a parent `postMessage`

`GET mobenzi-lookup-parent-id` as described above.

- Evidence: `MobenziController.MobenziLookupIDNumber`, `MobenziRepository.VerifyIDNumberAndFoodParcel`.
- Implementation: **Implemented**.
- Confidence: High for the HTTP behaviour. The procedure result's meaning is **Unknown**.

### FR-009 — Store a Mobenzi form body

`POST mobenzi-form-submission-received` as described above. No shared secret.

- Evidence: `MobenziController.SubmissionReceived`.
- Implementation: **Implemented**.
- Confidence: High.

### FR-010 — Return a learner name to a shared-secret caller

`GET api/quiz/learnerdetail`.

- Inputs: `userID` is the lookup key. `id` is ignored. `securityToken` must match the compiled secret.
- Output: `LearnerId` (the request `userID`, not a field from a different column), `FirstName`, `LastName`.
- Errors: bad token and missing user throw. They are not mapped to `401` / `404`.
- Evidence: `QuizController.cs`, `LearnerRepository.FindByUserID`.
- Implementation: **Implemented**.
- Confidence: High.

### FR-011 — Store SMS delivery notifications and replies

`POST api/notification/sms` as described above.

- Evidence: `NotificationController.cs`, `NotificationRepository.cs`.
- Implementation: **Implemented**.
- Confidence: High for parsing. Whether iTouch is the sender **Requires Confirmation**.

### FR-012 — Serve the React app from the ASP.NET Core host

Development uses `spa.UseReactDevelopmentServer("start")`. Publish runs `npm install` and `npm run build` and includes `ClientApp/build`. Static files and SPA static files are in the pipeline. The default MVC route is `{controller}/{action=Index}/{id?}`.

- Evidence: `Startup.cs`, `ClickLearnerPortal.csproj`.
- Implementation: **Implemented**.
- Confidence: High.

### FR-013 — Log requests and write a daily file

Serilog reads `appsettings.json`, enriches with thread id and log context, writes Debug and a rolling file at `c:/websites/learnerportal/logs/log.txt`. Request logging records client IP and user agent. Status above 499 and exceptions are `Error`. Requests with no endpoint are `Verbose`. Other requests are `Information`. `Microsoft` is overridden to `Warning`.

- Evidence: `Program.cs`, `Startup.cs`, `appsettings.json`.
- Implementation: **Implemented** configuration. Whether the path is writable in production is **Unknown**.

### FR-014 — Show a generic error page outside Development

Non-development uses `UseExceptionHandler("/Error")` and HSTS. The Razor page shows "An error occurred while processing your request." and the request id. It does not show the exception.

- Evidence: `Startup.cs`, `Pages/Error.cshtml`.
- Implementation: **Implemented**.
- Confidence: High.

## Planned

No planned behaviour is written down. Commented-out code (account authenticate, learner search, multiple-username rejection, `401` on the quiz action) is unfinished source, not a plan. It is listed in features.md as not on the live path.

## Discrepancies

- `silicon_ark/ClickPortal/` documents `portal.pUserByUserName` with arguments `UserName`, `ProgrammeCode`, `ActiveOnly`, called through `SqlClient`. This repo calls `portal.puserbyusername` with `par_username`, `par_programmecode`, `par_activeonly` through Npgsql. Same database names, different call shape. **Requires Confirmation** before anyone treats them as one database contract.
- `ProgrammeRepository` in this repo still calls `portal.pProgrammeGetByID` with argument `ID`, unlike the renamed learner routines.
- `web.production.config` redirects `/support` toward `/admin/...`. This app has no `/admin` and no `/support` page. ClickPortal's UI does use `/support`. The transform looks copied. Whether it is applied **Requires Confirmation**.
- The login page copy says the password is the Reading Eggs one. The server does not pass programme code `RE` (ClickPortal's login path is documented as doing that). This server passes `null`.
