# Business rules — ClickPortal

Evidence date: 2026-10-18. Source: `git@bitbucket.org:overdrive/clickportal.git`, `master` at `0e2adfb`.

No business-rules document exists in the source repository. Every rule below is **Inferred** from code unless a string in the UI or a comment states it. Stored procedures can add further rules; those bodies are not in this repo and are marked **Unknown**.

## Identity

| ID | Rule | Provenance |
|---|---|---|
| BR-001 | A Google sign-in is accepted only when the ID token validates for the configured Google client id and the email **inside that token** matches an identity user (`portal.pIdentityByUsername`). | **Inferred** from `IdentityService.AuthenticateGoogle`. The request `email` field is required by the model but is not the value used for the lookup. |
| BR-002 | An unknown Google email is rejected with "{email} is not a registered user." The portal does not create the user. | **Implemented** message. |
| BR-003 | A failed Google token check returns "Unable to verify sign in details with Google." | **Implemented** message. |
| BR-004 | On success the server stores a login time (`portal.pIdentityLoginUpdate`) and returns a JWT whose lifetime is `Jwt:Expires` seconds (3600 in `appsettings.json`). Issuer and audience are both `clicklearning.org`. | **Implemented**. |
| BR-005 | The live staff login does not check a portal password. `POST api/account/authenticate` returns HTTP 401. A password-hash check still exists on `IdentityService.Authenticate` and is not called. | **Implemented**. Whether the hash check is a retired rule **Requires Confirmation**. |
| BR-006 | Any valid JWT satisfies `[Authorize]`. The token carries no role. | **Inferred** from the claims built in `GenerateJSONWebToken`. |
| BR-007 | The browser treats the session as ended when `expires` is not in the future, and when an API call returns 401 or 403. | **Implemented** in `AuthenticationService.js`. |

## Support search

These checks run in the browser before `GET api/learner/search`. The API action does not repeat them.

| ID | Rule | Provenance |
|---|---|---|
| BR-010 | Search is either by cellphone or by school. The form starts on school. | **Implemented** (`searchBy` default `"school"`). |
| BR-011 | A cellphone search requires a value of exactly 10 characters. The placeholder text is `082000000` (9 characters). | **Implemented** Yup min(10) and max(10). The placeholder disagrees with the rule. |
| BR-012 | A school search requires a school. Grade and class must be present; "All" is a valid selection. | **Implemented**. |
| BR-013 | If grade and class are both "All", the first name or the last name must be at least 3 characters. If one of those names is already at least 3 characters, the other may be empty. | **Implemented** Yup `when` rules. |
| BR-014 | "All" grade (`-99`) and "All" class (`-99.`) are sent as null grade and class ids. | **Implemented** in `onSubmit`. |
| BR-015 | The search button is disabled until the form has been changed and Yup says it is valid. | **Implemented**. |
| BR-016 | The API returns the learner password to the client. The table shows it. The copy action includes it. | **Implemented**. Whether this is an approved support rule **Requires Confirmation**. |
| BR-017 | Copy includes an RE line only when `reActive` is true, a Matific line only when `matificActive` is true, and a Refl line only when `reflActive` is true. The password line is always included. The table still shows login columns when the flag is false. | **Implemented**. |

What `portal.pLearnerSearch` treats as a match (partial name, exact cellphone, inactive learners) is **Unknown**.

## Schools and classes

| ID | Rule | Provenance |
|---|---|---|
| BR-020 | Province, region, district, school, and class lists are loaded for the signed-in user id. | **Implemented** parameter. The filter itself is inside the procedures: **Unknown**. |
| BR-021 | If the province list has one row, that province is selected. If a province has one region or one district, that row is selected. | **Implemented** in `SupportFindLearnerPage`. |
| BR-022 | Schools are fetched once per province and then filtered in the browser by the selected region and district. The server school-name filter is always null. | **Implemented**. |
| BR-023 | Grades are the distinct grades of the selected school's classes, sorted by grade id, with "All" first. Classes follow the selected grade unless grade is "All". A class with an empty class id is labelled "{grade} - No Class". "All" is offered when there is more than one class row. | **Implemented**. |
| BR-024 | School records include an EMIS code. The find-learner table does not show it. | **Implemented** on `SchoolModel` / `SchoolController.List`. |

## Learner programme login

| ID | Rule | Provenance |
|---|---|---|
| BR-030 | Learner login looks up programme code `RE` only, with `ActiveOnly` true. | **Implemented** arguments to `portal.pUserByUserName`. |
| BR-031 | Username is required. Message: "The username cannot be empty." | **Implemented**. |
| BR-032 | Password is required. Message: "The password cannot be empty." | **Implemented**. |
| BR-033 | More than one matching row returns "Username not found." | **Implemented**. The message does not say that more than one row was found. |
| BR-034 | Password comparison is case-sensitive. Mismatch message: "Incorrect password." | **Implemented** (`string.Compare` ignore-case false). |
| BR-035 | `ActiveStatus` false sets `ModelState` 2 and "Inactive." and does not build a redirect. | **Implemented**. `ActiveOnly` true is also passed to the procedure, so this branch may be unreachable. That depends on the procedure: **Unknown**. |
| BR-036 | Success sets `ModelState` 1, sets the display name to first name plus last name, and clears the password on the response. Validation failures leave `ModelState` at 0. The HTTP status for these cases is 200. | **Implemented**. |
| BR-037 | When the redirect programme equals `QUIZ`, the action builds a `dataURL` for `api/quiz/learnerdetail` and assigns `RedirectToURL`, then always overwrites `RedirectToURL` with `redirectTo.URL`. The caller receives the procedure URL. | **Implemented**. Whether the overwrite is intended **Requires Confirmation**. |
| BR-038 | Zero matching rows are not turned into "Username not found." The code then uses the first row, which is null. | **Inferred** from the condition `learners == null \|\| count > 1`. |

## Quiz and programme

| ID | Rule | Provenance |
|---|---|---|
| BR-040 | Quiz learner detail requires a query parameter `securityToken` equal to a literal in `QuizController`. A mismatch throws `InvalidOperationException("Unauthorized.")` rather than an HTTP 401 result. | **Implemented**. The literal is not copied here. |
| BR-041 | A quiz lookup that finds no user throws `ArgumentException` ("Not found.", parameter name "UserID"). | **Implemented**. |
| BR-042 | A successful quiz response uses JSON names `LearnerId`, `FirstName`, and `LastName`. | **Implemented** (`JsonPropertyName`). |
| BR-043 | Programme get-by-id is anonymous. It returns the procedure row's code, name, URLs, tech-related flag, and supplier organisation. | **Implemented** mapping. Null row is not handled. |

## Access and hosting rules visible in code

| ID | Rule | Provenance |
|---|---|---|
| BR-050 | Anonymous actions: account authenticate, account authenticate-google, learner login, programme get, quiz learner detail, config startup. Search, provinces, school list, and classes require a JWT. | **Implemented** attributes. |
| BR-051 | CORS allows any origin, method, and header. | **Implemented** in `Startup.Configure`. |
| BR-052 | Production static cache header on the IIS `web.config` is `Cache-Control: no-cache`, and static client cache max-age is 30 seconds. The ASP.NET Core module section in that file is commented out. | **Implemented** in `web.config`. |
| BR-053 | Non-development unhandled exceptions are shown by the `/Error` Razor page, which does not render the exception text. Development uses the developer exception page. | **Implemented**. |

## Rules that are not established

- Who may be an identity user, and how they are added. No registration screen exists.
- Which schools a user id may see. The procedure receives the id; the rule is in the database.
- Whether learner passwords are allowed to be shown to support staff.
- Whether RE, Matific, and Refl are the full programme set or only the set this search displays.
- Business meaning of EMIS, grade ids, and class ids beyond the field names.
- Any retention, audit, or consent rule. Request logs include the user id. No other audit log was found.
