# Technical documentation — ClickPortal

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

The scan-host tree listed `sonar-project.properties` and `sonarqube_scan.bat`. Those paths are not in git.

## Architecture

One ASP.NET Core web project hosts a React single-page app and a JSON API.

```text
ClickPortal.sln
└── ClickPortal/                      netcoreapp3.1 web
    ├── Program.cs / Startup.cs       host, auth, CORS, SPA, Serilog
    ├── Controllers/                  API
    ├── Services/IdentityService.cs   Google token + JWT
    ├── Repositories/                 Dapper stored procedures
    ├── Data/                         SqlConnection wrappers
    ├── Models/                       procedure row shapes
    ├── ClientModels/                 request and response shapes
    ├── Configuration/                options classes
    ├── Pages/Error.cshtml            non-development error page
    └── ClientApp/                    React 16 (Create React App)
```

Request pipeline in `Startup.Configure`: developer exception page or `/Error`, HSTS outside development, HTTPS redirection, static files, SPA static files, routing, CORS (any origin / method / header), authentication, authorization, Serilog request logging, MVC default route `{controller}/{action=Index}/{id?}`, then the SPA fallback.

There is no second project, no message bus, no background worker, and no `DbContext`.

## Frameworks and languages

| Area | Choice | Evidence |
|---|---|---|
| Solution | Visual Studio 2019 (format 16.00) | `ClickPortal.sln` |
| Server | C#, ASP.NET Core, `netcoreapp3.1` | `ClickPortal.csproj` |
| Data access | Dapper 2.0.35, Dapper.Contrib 2.0.35, `Microsoft.Data.SqlClient` 1.1.3 | csproj. Contrib is referenced; no `contrib` call was found in the repositories. |
| Auth | `Microsoft.AspNetCore.Authentication.JwtBearer` 3.1.5, `Google.Apis.Auth` 1.46.0 | csproj, `IdentityService` |
| Logging | Serilog 2.9.0, AspNetCore sink, thread enricher, configuration, debug and file sinks | csproj, `appsettings.json` |
| JSON | Newtonsoft.Json 12.0.3 is referenced. Response attributes that fix names use `System.Text.Json.Serialization.JsonPropertyName`. | csproj, `QuizLearnerDetailModel`, `SchoolModel`, `ClassModel` |
| SPA | React 16.13.1, react-scripts 3.4.1, React Router 5, reactstrap 8 / Bootstrap 4, Formik 2, Yup 0.28, axios 0.19, rxjs 6 | `ClientApp/package.json` |
| SPA package name / version | `clickportal` `0.2.0`, private | `package.json` |

`Microsoft.AspNetCore.SpaServices.Extensions` 3.1.5 runs `npm start` in Development.

## Application structure

### API routes

Controller route prefixes are attribute routes. Class-level `[Authorize]` applies unless the action says `[AllowAnonymous]`.

| Method | Path | Auth | Action |
|---|---|---|---|
| POST | `api/account/authenticate` | anonymous | Empty try, then HTTP 401. Body `AuthenticateModel` (`username`, `password`, both `[Required]`). |
| POST | `api/account/authenticate-google` | anonymous | Google sign-in. Body `email`, `tokenId`. 200 `AuthenticatedModel` or 400 `{ message }`. |
| POST | `api/learner/login` | anonymous | RE learner login. Returns `LearnerLoginModel` with password cleared. |
| GET | `api/learner/search` | JWT | Query `searchBy`, `cellphone`, `schoolId`, `gradeId`, `classId`, `firstName`, `lastName`. |
| GET | `api/school/provinces` | JWT | Rows of province, region, district for `CurrentUserID`. |
| GET | `api/school/list` | JWT | Query `provinceID`. School-name filter is not exposed. |
| GET | `api/school/classes` | JWT | Query `schoolID`. |
| GET | `api/programme/{id:int}` | anonymous | Programme row. |
| GET | `api/quiz/learnerdetail` | anonymous | Query `id`, `userID`, `securityToken`. |
| GET | `api/config/startup` | anonymous | `appId`, `googleClientId`. |

`CurrentUserID` is the JWT `ClaimTypes.NameIdentifier` claim, or empty string when the claim is missing (`Controllers/Controller.cs`).

`HttpResponseExceptionFilter` maps `HttpResponseException` to an `ObjectResult`. No live action throws that type. The quiz action's 401 throw is commented out.

### Client routes

`App.js` basename is the `<base href>` from `index.html` (`%PUBLIC_URL%/`).

| Path | Guard | Component |
|---|---|---|
| `/account/login` | public | `AccountLoginPage` |
| `/account/logout` | public | `AccountLogoutPage` |
| `/support/findlearner` | `PrivateRoute` | `SupportFindLearnerPage` |
| `/support` | `PrivateRoute` | `SupportHomePage` (redirects) |
| `/` | public | `HomePage` (redirects) |

Axios instance base URL is `./api/` (`src/api.js`).

History is `createBrowserHistory` with the same basename (`src/_helpers/history.js`).

### Stored procedures

All calls use `CommandType.StoredProcedure` on `DWSqlConnection`. Schema `portal`.

| Procedure | Caller | Parameters |
|---|---|---|
| `portal.pIdentityByUsername` | `IdentityRepository.FindByUsername` | `UserName` |
| `portal.pIdentityByID` | `IdentityRepository.FindByID` | `ID`. No other class calls `FindByID`. |
| `portal.pIdentityLoginUpdate` | `IdentityRepository.LoginUpdate` | `ID`, `LoginTime` |
| `portal.pUserByUserName` | `LearnerRepository.FindByUsername` | `UserName`, `ProgrammeCode`, `ActiveOnly` |
| `portal.pUserByUserID` | `LearnerRepository.FindByUserID` | `UserID` |
| `portal.pUserRedirectTo` | `LearnerRepository.RedirectTo` | `UserID` |
| `portal.pLearnerSearch` | `LearnerRepository.Search` | `SearchBy`, `Cellphone`, `SchoolID`, `GradeID`, `ClassID`, `FirstName`, `LastName` |
| `portal.pSchoolList` | `SchoolRepository.GetSchools` | `UserID`, `ProvinceId`, `SchoolNameFilter` |
| `portal.pSchoolListProvinceRegionDistrictFilter` | `SchoolRepository.GetSchoolsProvinceFilter` | `UserID` |
| `portal.pClassBySchool` | `SchoolRepository.GetSchoolClasses` | `UserID`, `SchoolID` |
| `portal.pProgrammeGetByID` | `ProgrammeRepository.GetProgrammeByID` | `ID` |

Procedure text, indexes, and row-level rules are **Unknown**.

Repositories open the SQL connection in the constructor. They are registered transient.

## Databases

Two connection-string names:

| Name | Options key | Database name in the committed JSON | Wrapper |
|---|---|---|---|
| Default | `ConnectionStrings:DefaultConnection` | `ClickDW` | `DWSqlConnection` — used by every repository |
| Staging | `ConnectionStrings:StagingConnection` | `ClickSTAGING` | `StagingSqlConnection` — constructed in `Startup` only |

`appsettings.json` (the file the host loads when `ASPNETCORE_ENVIRONMENT` is not Development, and also the base file) points both names at AWS RDS hostnames in `af-south-1`. The user id written there is `postgres`. The code opens those strings with `Microsoft.Data.SqlClient` (`SqlConnection`). A commented pair in the same file points at a Windows host and the same database names.

`appsettings.development.json` replaces both connection strings with a SQL Server host, a non-default port, and SQL authentication (`MultipleActiveResultSets=true`).

Passwords and full hostnames are in those files. They are not repeated here. They are committed to git. Treat them as exposed. Whether the strings still match a live server, and whether a PostgreSQL user id can work with `SqlClient`, is **Unknown / Requires Confirmation**.

There is no schema script, seed, or migration in the repository.

### Row shapes the code expects

`Learner` (search): id, first name, last name, current school id and name, current grade id and name, RE / Matific / Refl logins, password, current class id, internal class name, class name, and three active flags.

`LearnerUser`: id, person id, programme, username, password, first name, last name, active status.

`LearnerRedirectTo`: user id, programme, URL, URL parameters, default flag.

`IdentityUser`: id, user name, password hash.

`School`: id, name, EMIS code, province / region / district ids and names.

`Class`: grade id, grade, class id, system name, name.

`Programme`: id, code, name, URL, login URL, is-tech-related, supplier organisation, login-card URL. `Programme.ID` is a string. The action parameter is an `int`.

`ProvinceRegionDistrict`: province, region, and district ids and names.

JSON from anonymous projections in the learner-search and province actions uses the camelCase names written in the `Select`. `SchoolModel` forces `provinceId`, `regionId`, and `districtId`, and leaves `ID` to the serializer (`id`). `ClassModel` forces `gradeId` and `classId`. `QuizLearnerDetailModel` forces `LearnerId`, `FirstName`, `LastName`.

## Authentication

JWT bearer is the only scheme. `DefaultAuthenticateScheme` and `DefaultForbidScheme` are JWT. `DefaultChallengeScheme` is not set in the snippet.

Validation: signing key, issuer, lifetime, audience. Audience equals issuer. Signing algorithm on issue is `HmacSha256Signature`. Claims: `sub` and `NameIdentifier` = user id; `email`, `Email`, and `Name` = user name.

`ConfigureDevelopmentServices` sets a flag then calls `ConfigureServices`, which sets `RequireHttpsMetadata` false only when that flag is true. ASP.NET Core calls `ConfigureDevelopmentServices` when the environment is Development. Other environments call `ConfigureServices` with the flag left false, so metadata requires HTTPS.

The React client does not refresh tokens. Expiry is checked locally against the `expires` value returned at login.

Google: `GoogleJsonWebSignature.ValidateAsync` with audience equal to `Google:Auth:ClientID`. The same client id is also hardcoded in `ConfigService.js`. The login page reads `configService.startupValues().googleClientId` and does not call `api/config/startup`.

`IdentityService.Authenticate` (unused by the controller) uses `PasswordHasher<IdentityUser>.VerifyHashedPassword` against `IdentityUser.PasswordHash`.

Learner login compares the posted password to `LearnerUser.Password` as plain text. That is separate from the staff hash.

## Configuration

| Key | File | Used for |
|---|---|---|
| `ConnectionStrings:DefaultConnection` | `appsettings.json`, overridden in development | `ClickDW` |
| `ConnectionStrings:StagingConnection` | same | `ClickSTAGING` (unused by repositories) |
| `Serilog` | `appsettings.json` | Debug sink and a daily file at `c:/websites/portal/logs/log.txt`. Template includes timestamp, level, thread id, `UserID`, scope, message. Microsoft override Warning. |
| `AllowedHosts` | `appsettings.json` | `*` |
| `AppSettings:AppID` | `appsettings.json` | Returned by `api/config/startup`. A GUID is committed. Not copied here. |
| `Jwt:Key`, `Jwt:Issuer`, `Jwt:Expires` | `appsettings.json`. Development overrides `Expires` to 3600 as well. | Issuer `clicklearning.org`. Expires 3600 seconds. The key is committed. Not copied here. |
| `Google:Auth:ClientID` | `appsettings.json` | Google token audience. Also hardcoded in the client. |

`Program.Main` builds the first Serilog logger from `appsettings.json` only (it does not add `appsettings.Development.json`). The host then calls `.UseSerilog()` with no callback, which uses that static logger.

Client env:

| Variable | `.env` | `.env.development` |
|---|---|---|
| `PUBLIC_URL` | `/admin/` | `/` |
| `NODE_PATH` | `src/` | `src/` |
| `REACT_APP_GOOGLE_ANALYTICS_ID` | empty | empty |
| `REACT_APP_REDIRECT_ACCOUNT_LOGIN_TO` | `/support` | `/support` |

`package.json` `homepage` is `/`. Create React App also reads `PUBLIC_URL` from the env file. Which value wins at publish time was not executed in this investigation. Both values are committed.

`Properties/launchSettings.json`: IIS Express `http://localhost:58409` (SSL port 0). Project profile `https://localhost:5001;http://localhost:5000`. Both set `ASPNETCORE_ENVIRONMENT=Development`. Windows authentication off, anonymous on.

## Frontend build

`ClientApp/package.json` scripts: `start` deletes `./build` then `react-scripts start`; `build`; `test` with `CI=true`; `eject`; `lint` (`eslint ./src/`).

The csproj Debug target runs `npm install` when `ClientApp/node_modules` is missing. The publish target always runs `npm install` and `npm run build`, then includes `ClientApp/build/**`.

`index.html` sets the document title "Click Foundation Portal", loads gtag (no-op while the measurement id is empty), a Font Awesome kit script, favicon, and manifest. Manifest `short_name` is `ClickPortal`, `name` is `Click Foundation Portal`, `display` is `standalone`.

Fonts under `public/fonts` (Passion One, Roboto) ship with their licence texts. `index.js` unregisters the service worker.

`App.test.js` imports a default `App` and renders it inside `MemoryRouter`. `App.js` exports a named class only; the default export is commented out. No test run was executed for this document. There is no server-side test project.

`ClientApp/README.md` is the upstream Create React App guide (about 2,200 lines), not project documentation.

## Deployment and infrastructure

No Dockerfile, Bitbucket pipeline, GitHub Actions, CodeDeploy, or Terraform was found.

Clues that a Windows/IIS site was expected:

- Serilog path `c:/websites/portal/logs/log.txt`.
- `web.config` at the project root with `Cache-Control: no-cache` and a 30-second static client cache. The AspNetCore handler block is commented out, so this file does not by itself register the ASP.NET Core module.
- `web.release.config` is an empty transform.
- `.env` `PUBLIC_URL=/admin/`.

Whether that site exists, and what process starts the app in production, is **Unknown / Requires Confirmation**.

## Security notes (from the tree, not a pentest)

These are observations for a human reviewer. Secret values are intentionally omitted.

- SQL passwords, a JWT signing key, and a quiz shared secret are committed in `appsettings.json`, `appsettings.development.json`, and `QuizController.cs`.
- Learner search returns passwords to any holder of a valid staff JWT, and the UI displays them.
- Learner login compares a plain-text password.
- Quiz detail is anonymous apart from the shared query secret, and a bad secret becomes an unhandled exception rather than HTTP 401.
- CORS is unrestricted.
- `AllowedHosts` is `*`.
- Google client id is in config and duplicated in client source. A client id is normally public; the duplication means the startup endpoint is not what the UI uses.
- Staff password authentication is compiled out of the controller. The hash verifier remains.

## Development process

- Clone `git@bitbucket.org:overdrive/clickportal.git`, branch `master`.
- Open `ClickPortal.sln` in a Visual Studio that can target .NET Core 3.1, or `dotnet build` with a 3.1 SDK.
- Node is required for a Debug build once `ClientApp/node_modules` is absent (the csproj checks `node --version`).
- F5 uses the launch profiles above and the Development environment, which also starts the React dev server.
- Client lint: `npm run lint` in `ClientApp`. Client test script: `npm test`. The committed `App.test.js` import does not match the named export in `App.js`.
- No CI definition was found, so nothing in git shows those commands running on a server.

## Monitoring

Serilog writes to the debug sink and to a rolling file. Each request log line can include `UserID:<id>` or `UserID:N/A`. No metrics, health-check endpoint, or APM package was found. Google Analytics is wired in HTML and left without an id.

## Related documents

- `project-plan.md` — purpose, scope, status.
- `prd.md` — workflows and functional requirements.
- `features.md` — live path versus code that is present but unused.
- `business-rules.md` — validation and access behaviour inferred from code.
