# PRD — ClickPortal

Evidence date: 2026-10-18. Source: `git@bitbucket.org:overdrive/clickportal.git`, `master` at `0e2adfb` (2023-02-07, "Initial commit", Dirk).

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

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

UI titles call the product **Click Foundation Portal** and the support area **Click Foundation Support Portal**. The JWT issuer is `clicklearning.org`. A separate product statement was not found.

## Users and roles

No role or permission table exists in this repository. `[Authorize]` means "any valid JWT". Claims put the identity-user id in `sub` and `NameIdentifier`, and the username (the Google email) in `email` and `Name`. No role claim is added.

| Role | What the code allows | Provenance |
|---|---|---|
| Anonymous visitor | Open `/account/login`. Call the anonymous API actions listed under workflows. | **Implemented** |
| Signed-in staff user | Any identity user who already exists for the Google email. After sign-in, the only private screens are support home (which immediately redirects) and find-learner. | **Implemented** for the gate. The job title "support staff" is **Inferred** from the page title "Click Foundation Support Portal". |
| Learner | `POST api/learner/login` accepts an RE username and password and returns a redirect URL. No page in the router collects those fields. | **Implemented** API. Who calls it is **Unknown**. |
| External quiz caller | `GET api/quiz/learnerdetail` is anonymous and checks a shared query secret. A comment says "Used by Mobenzi". | **Implemented** check. Mobenzi as the caller is a comment, so the caller identity is **Inferred** and **Requires Confirmation**. |

School and class queries pass the JWT user id into stored procedures. What that id is allowed to see is decided in the database, which is not in this repo. Provenance of the filter rule: **Unknown**.

## Major workflows

### Staff sign-in and find a learner

1. The app loads `/`, and `HomePage` immediately pushes `/support/findlearner`.
2. That route is a `PrivateRoute`. With no `currentUser` in `localStorage`, the router sends the browser to `/account/login` and keeps the return location.
3. The login page shows a Google button. The username/password submit handler is empty (the call is commented out).
4. On Google success the client posts `{ email, tokenId }` to `api/account/authenticate-google`.
5. The server validates the Google ID token against the configured client id, loads `portal.pIdentityByUsername` with the **email inside the token** (not the email field alone), and if a row exists writes a login timestamp (`portal.pIdentityLoginUpdate`) and returns a JWT.
6. The client stores that payload in `localStorage` under `currentUser` and pushes the return path. The default return path is `REACT_APP_REDIRECT_ACCOUNT_LOGIN_TO`, which is `/support` in both env files.
7. `/support` (`SupportHomePage`) immediately pushes `/support/findlearner`.
8. The find-learner page loads `api/school/provinces` and, as the user picks filters, `api/school/list` and `api/school/classes`. Search calls `api/learner/search`.
9. Results render in a table. A copy control writes active programme logins and the password to the clipboard.

Provenance: **Implemented**.

### Staff sign-out

`/account/logout` calls `authenticationService.logout()` (removes `currentUser`) and pushes `/support`. `/support` is private, so the next render sends the user to `/account/login`.

Provenance: **Implemented**.

### Learner programme login (API only)

`POST api/learner/login` with `{ username, password }`:

- Empty username → `ErrorMessage` "The username cannot be empty." `ModelState` stays 0. HTTP 200.
- Empty password → "The password cannot be empty." `ModelState` stays 0.
- Otherwise calls `portal.pUserByUserName` with programme code `RE` and `ActiveOnly` true.
- More than one row → "Username not found."
- Password compare is case-sensitive (`string.Compare` with ignore-case false). Mismatch → "Incorrect password."
- `ActiveStatus` false → `ModelState` 2 and "Inactive."
- Otherwise loads `portal.pUserRedirectTo`. If `Programme` is `QUIZ`, the method builds a `dataURL` pointing at `api/quiz/learnerdetail` and assigns it to `RedirectToURL`, then the next line always assigns `RedirectToURL = redirectTo.URL`. The response therefore returns the procedure's URL, not the quiz `dataURL`. `ModelState` 1. The response password is cleared.

Zero rows do not hit the "Username not found" branch. The check is `learners == null || count > 1`. An empty sequence is not null and its count is not greater than 1, so the method then reads `learner.Password` on a null `FirstOrDefault()`. Provenance: **Implemented** control flow. The resulting exception type at runtime was not executed here.

No React route calls this action. Commented code in `App.js` names `/learnerlogin` and `/learner/login`. Provenance of a learner UI: not in the current router. Whether one exists elsewhere is **Unknown**.

### Quiz learner detail

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

- The action is `[AllowAnonymous]`.
- If `securityToken` does not equal a literal in `QuizController`, the action throws `InvalidOperationException("Unauthorized.")`. That exception is not `HttpResponseException`, so the MVC exception filter does not turn it into a status code. A commented block would have returned HTTP 401.
- Otherwise `portal.pUserByUserID`. Null learner throws `ArgumentException`.
- Success returns JSON `LearnerId`, `FirstName`, `LastName` (those names are fixed with `JsonPropertyName`).

Provenance: **Implemented**. The secret value is not repeated in this PRD.

## Integrations

| System | Direction | Trigger | Data | Auth | Failure | Provenance |
|---|---|---|---|---|---|---|
| Google sign-in | Browser → Google, then this API validates the ID token | Login button | Email inside the token, token id | Google client id in configuration | "Unable to verify sign in details with Google." HTTP 400. Unknown email: "{email} is not a registered user." | **Implemented** |
| Identity stored procedures | This API → `ClickDW` | Successful Google sign-in | Username, user id, login time | SQL connection string | Not handled beyond the exception bubbling | **Implemented** call. Procedure behaviour **Unknown** |
| Learner / school / programme procedures | This API → `ClickDW` | Login, search, school pickers, programme get | See data section | SQL connection string. School calls also pass JWT user id | Unhandled repository exceptions | **Implemented** call |
| `ClickSTAGING` | Registered only | None found | Connection name `StagingConnection` | SQL connection string | n/a | **Implemented** registration, unused |
| Mobenzi | Inbound HTTP, if the comment is right | GET learner detail | user id, shared query secret; response is name | Shared secret query parameter | Unhandled exception on a bad secret | Comment is the only link. Caller **Requires Confirmation** |
| Font Awesome kit | Browser loads a script from `kit.fontawesome.com` | Every page via `index.html` | Kit script | None in repo | Icons missing if the script fails. Not handled | **Implemented** |
| Google Analytics | `index.html` loads gtag | Page load | Measurement id from `REACT_APP_GOOGLE_ANALYTICS_ID` | None | `gtag` no-ops when the id is empty. Both committed env files set it to empty | **Implemented** snippet, inactive in the committed env |
| Programme redirect target | Learner-login response carries a URL from `portal.pUserRedirectTo` | Successful RE login | URL and URL parameters | n/a | If the procedure returns null, the controller dereferences it. Not guarded | **Implemented** assignment. Destination systems **Unknown** |

## Constraints

- Staff API actions under `[Authorize]` require a Bearer JWT. Anonymous exceptions are marked `[AllowAnonymous]` on the action.
- JWT validates issuer, audience, lifetime, and signature. Issuer and audience are both the configured issuer (`clicklearning.org`). Lifetime comes from `Jwt:Expires` (3600 seconds in `appsettings.json`). Development sets `RequireHttpsMetadata` false via `ConfigureDevelopmentServices`. Other environments set it true.
- CORS allows any origin, method, and header (`Startup.Configure`).
- The React client talks to `./api/` (relative), so the API origin follows the page.
- Production `PUBLIC_URL` in `.env` is `/admin/`. Development `PUBLIC_URL` is `/`.
- Secrets (SQL passwords, JWT signing key, quiz shared secret) are committed in source. This PRD does not copy them. Whether they are still valid **Requires Confirmation**, and they should be treated as exposed.

## Requirements discovered from the implementation

### FR-001 — Google sign-in for an existing user

The login page offers "Sign in using Google". The server accepts the sign-in only when Google validates the ID token and `portal.pIdentityByUsername` returns a user for the token email. Success returns `userName`, `token`, `created`, `expires`. The client stores that object and continues to the return URL.

- Actor: person with a Google account.
- Trigger: Google button success.
- Validation: `[Required]` on `email` and `tokenId`. Google audience check. User must already exist.
- Errors: HTTP 400 with `message` for Google failure or unknown user. HTTP 400 from the API model state when email or token id is missing.
- Evidence: `AccountLoginPage.js`, `AuthenticationService.loginGoogle`, `AccountController.AuthenticateGoogle`, `IdentityService.AuthenticateGoogle`.
- Implementation: **Implemented**. Confidence: high.

### FR-002 — Staff username/password sign-in is not in the live path

`POST api/account/authenticate` is anonymous and, after an empty `try`, always returns HTTP 401. The call to `IdentityService.Authenticate` is commented out. The React submit handler does not call `authenticationService.login`. The service method and the client function still exist. `IdentityService.Authenticate` would look up the user and verify `PasswordHash` with `PasswordHasher`, then issue a JWT.

- Evidence: `AccountController.Authenticate`, `AccountLoginPage.js` `onSubmit`, `IdentityService.Authenticate`, `AuthenticationService.login`.
- Implementation: the live path is **Implemented** as "always unauthorized". The hash check is **Implemented** but not reachable from the controller. Whether disabling it was intentional is **Unknown / Requires Confirmation**.

### FR-003 — Client session

The client keeps `currentUser` in `localStorage`. Axios adds `Authorization: Bearer <token>` when a token is present. If `expires` is in the past, the client logs out. An API response of 401 or 403 also logs out. Logout on 401/403 does not navigate by itself (`location.reload` is commented out).

- Evidence: `AuthenticationService.js`.
- Implementation: **Implemented**. Confidence: high.

### FR-004 — Private support routes

`/support` and `/support/findlearner` render only when `currentUserValue()` is set. Otherwise the user is redirected to `/account/login` with the attempted location in router state. After Google login the page pushes that location, or `/support` when there is no state.

- Evidence: `PrivateRoute.js`, `App.js`, `AccountLoginPage.js`, `.env`.
- Implementation: **Implemented**. Confidence: high.

### FR-005 — Find a learner

A signed-in user searches by cellphone or by school.

Client rules, enforced with Yup before the request:

- Cellphone mode: cellphone is required, minimum 10 characters and maximum 10 characters.
- School mode: school id is required and at least 1. Grade and class are required (the "All" values are allowed as values).
- School mode, when grade is "All" (`-99`) and class is "All" (`-99.`): first name or last name must be at least 3 characters.
- The search button stays disabled until the form is dirty and valid.

The request is `GET api/learner/search` with `searchBy`, `cellphone`, `schoolId`, `gradeId`, `classId`, `firstName`, `lastName`. Grade and class "All" are sent as null. The API does not repeat those Yup rules. It requires a JWT and calls `portal.pLearnerSearch`.

The table columns are school name, first name, last name, grade, class, RE login, Matific login, Refl login, password, and a copy action.

- Actor: signed-in user.
- Errors: the page sets status "Error retrieving results" and does not clear previous rows. Empty `learners` renders a header with no body.
- Evidence: `SupportFindLearnerPage.js`, `LearnerController.Search`, `LearnerRepository.Search`.
- Implementation: **Implemented** for the client rules and the API call. What the procedure matches on is **Unknown**. Confidence: high for the client, low for match semantics.

### FR-006 — Copy learner logins

The copy control writes a text block of whichever of RE, Matific, and Refl are active (`reActive`, `matificActive`, `reflActive`), then always a password line. It uses a temporary textarea and `document.execCommand("copy")`.

- Evidence: `SupportFindLearnerPage.js`.
- Implementation: **Implemented**. Confidence: high.

### FR-007 — School hierarchy on the search form

On load the page gets `api/school/provinces` and splits the rows into provinces, regions, and districts. One province is selected automatically. Region and district lists follow the selection. Schools load per province (`api/school/list?provinceID=`) and are filtered in the browser by region and district. Classes load per school (`api/school/classes?schoolId=`).

Server actions are authorized and pass `CurrentUserID` (JWT name identifier) into:

- `portal.pSchoolListProvinceRegionDistrictFilter`
- `portal.pSchoolList` (province id, school-name filter always null from the controller)
- `portal.pClassBySchool`

- Evidence: `SupportFindLearnerPage.js`, `SchoolController`, `SchoolRepository`.
- Implementation: **Implemented**. Which schools a user id may see is **Unknown** (procedure not in repo).

### FR-008 — Learner RE login API

See the workflow above. Programme code `RE` is fixed in the controller. `ModelState` 0 is the validation/failure default, 1 is success, 2 is inactive. The password is removed from the response. HTTP status stays 200 for those message cases.

- Evidence: `LearnerController.Login`, `LearnerRepository.FindByUsername`, `LearnerRepository.RedirectTo`, `LearnerLoginModel`.
- Implementation: **Implemented**. No UI in this repo calls it. Confidence: high for the action, unknown for callers.

### FR-009 — Programme record by id

`GET api/programme/{id}` is anonymous. It calls `portal.pProgrammeGetByID` and maps id, code, name, URL, login URL, tech-related flag, login-card URL, and supplier organisation. No client page calls it. A null procedure result is not guarded.

- Evidence: `ProgrammeController`, `ProgrammeRepository`, `ProgrammeModel`.
- Implementation: **Implemented** API. Unused by this UI. Confidence: high.

### FR-010 — Quiz learner detail

See the workflow above.

- Evidence: `QuizController.LearnerDetail`, `QuizLearnerDetailModel`.
- Implementation: **Implemented**. Confidence: high for the action. Caller **Requires Confirmation**.

### FR-011 — Startup configuration API

`GET api/config/startup` is anonymous and returns `AppID` from `AppSettings:AppID` and the Google client id from options. The React app does not call it. The login button uses the client id hardcoded in `ConfigService.js`.

- Evidence: `ConfigController`, `ConfigService.js`.
- Implementation: API **Implemented**. Client consumption is not in the live path. Confidence: high.

### FR-012 — Home and support redirect

`/` always navigates to `/support/findlearner`. `/support` always navigates to `/support/findlearner`. Both navigations run during render.

- Evidence: `HomePage.js`, `SupportHomePage.js`.
- Implementation: **Implemented**. Confidence: high.

## Out of scope for this document

- Inventing a learner portal UI because the folder `pages/learner` exists and is empty.
- Treating the Create React App README as product requirements.
- Treating commented template navigation (Counter, Fetch data) as features.
- Copying secrets from `appsettings.json`, `appsettings.development.json`, or `QuizController` into this file.

## Human confirmation

- Product purpose, operating organisation, and whether the portal is still deployed.
- Whether support staff are allowed to see and copy learner passwords.
- Whether username/password staff login should stay disabled.
- Whether the quiz redirect overwrite is accepted behaviour.
- Who calls `api/learner/login` and `api/quiz/learnerdetail`.
- The real rules inside the `portal` stored procedures.
- Whether committed credentials are still in use and must be rotated.
