# Technical documentation — ClickLearnerPortal

Evidence date: 2026-10-19. Source: `git@bitbucket.org:overdrive/clicklearnerportal.git`, `master` at `2cb0ae55b32459d58471c6b571372ad8ae978a1b` (2023-02-07, Dirk, "Initial commit"). `origin/HEAD` is `master`. No other branches and no tags.

Secret values (database passwords, JWT signing key, shared query secret) are committed in the source tree. They are not copied into this document.

## Stack

| Layer | Choice | Evidence |
|---|---|---|
| Solution | Visual Studio 2019 (`VisualStudioVersion` 16.0), one project | `ClickLearnerPortal.sln` |
| Server | ASP.NET Core on `netcoreapp3.1` | `ClickLearnerPortal.csproj` |
| API style | Attribute-routed controllers, mostly `Task` results | `Controllers/` |
| Data | Dapper 2.0.35 over Npgsql 6.0.4 | csproj; `Data/DWSqlConnection.cs` |
| Auth library | `Microsoft.AspNetCore.Authentication.JwtBearer` 3.1.5 | Registered. No live issuer and no live `[Authorize]` action |
| Logging | Serilog 2.9.0, AspNetCore sink, thread enricher, configuration reader, Debug and File sinks | `Program.cs`, `appsettings.json` |
| Client | React 16.13.1, react-scripts 3.4.1, react-router-dom 5.2, reactstrap 8.4.1, axios 0.19.2 | `ClientApp/package.json` |
| SPA host | `Microsoft.AspNetCore.SpaServices.Extensions` 3.1.5 | `Startup.cs` |
| JSON | Default ASP.NET Core 3.1 serializer is System.Text.Json. `Newtonsoft.Json` is referenced and used only to log the login result. `AddNewtonsoftJson` is not called | `LearnerController.cs`, `Startup.cs` |

`Package.json` name is `clicklearnerportal`, version `0.2.0`, `private` true, `homepage` `.`.

## Repository layout

```text
ClickLearnerPortal.sln
ClickLearnerPortal/
  Program.cs
  Startup.cs
  ClickLearnerPortal.csproj
  appsettings.json
  appsettings.production.json          empty object
  web.config
  web.production.config                XDT rewrite rules, not referenced by a profile in git
  libman.json                          empty library list
  .config/dotnet-tools.json            dotnet-ef 3.1.0
  Configuration/                       AppSettingsOptions, JwtOptions
  Controllers/
  Repositories/
  Data/                                Npgsql wrappers
  Models/
  ClientModels/
  Exceptions/HttpResponseException.cs
  Pages/Error.cshtml
  Properties/launchSettings.json
  ClientApp/                           React application
```

There is no README at the repository root, no test project, no Dockerfile, no `bitbucket-pipelines.yml`, no `.pubxml`, and no `appsettings.Development.json`.

`ClientApp/README.md` is the Create React App template (it still links to `facebookincubator/create-react-app`).

## Request pipeline

`Program.Main` builds a configuration from `appsettings.json` only (it does not add the environment-specific file itself), creates the Serilog logger, then runs the generic host. The generic host still applies the normal ASP.NET Core configuration stack, including `appsettings.{Environment}.json` and environment variables, because `CreateDefaultBuilder` is used.

`Startup.Configure` order:

1. Developer exception page, or `/Error` plus HSTS when not Development.
2. HTTPS redirection.
3. Static files, then SPA static files (`ClientApp/build`).
4. Routing.
5. CORS: any origin, any method, any header.
6. Authentication, then authorization.
7. Serilog request logging (level and client IP / user agent enrichment).
8. Endpoints: `{controller}/{action=Index}/{id?}`.
9. SPA: source `ClientApp`; in Development, `npm start` via `UseReactDevelopmentServer`.

`ConfigureDevelopmentServices` exists and sets `envIsDevelopment = true` before `ConfigureServices`. The field's initializer is already `true`, and nothing sets it to `false`. `AddJwtBearer` therefore sets `RequireHttpsMetadata` to false on both the Development method and the normal `ConfigureServices` path.

`AddControllers` registers `HttpResponseExceptionFilter` at order `int.MaxValue - 10`. The filter writes `StatusCode` and `Value` only for `HttpResponseException`. No live action throws that type.

Controllers are not versioned. There is no API explorer / Swagger package.

## Client

Entry: `ClientApp/src/index.js` imports Bootstrap CSS, renders `App` into `#root`, and calls `unregister()` from `registerServiceWorker.js`.

`App.js` uses `react-router-dom` `Router` with `basename` from the `<base href>` and a browser history from `_helpers/history.js`. Routes:

| Path | Page |
|---|---|
| `/learner/login` | `LearnerLoginPage` |
| `/click-home-learning` | `ClickHomeLearningPage` |
| `/` | `HomePage`, which only renders `LearnerLoginPage` |

`api.js` is axios with `baseURL: './api/'`. Calls are relative to the page. `LearnerLoginPage` requests `programme/999` and `learner/login`. `ClickHomeLearningPage` requests `tao/tao-form-submission-received`.

`NODE_PATH=src/` in `.env` is why imports such as `pages/learner/LearnerLoginPage` and `_helpers/history` resolve.

`index.html` loads a Font Awesome kit (`37337f48a1`) and, when the placeholders are non-empty, Google Analytics and Intercom. Create React App replaces `%REACT_APP_…%` at build time.

| Variable | `.env` | `.env.development` |
|---|---|---|
| `PUBLIC_URL` | `/` | `/` |
| `REACT_APP_GOOGLE_ANALYTICS_ID` | `UA-162415700-1` | empty |
| `REACT_APP_LEARNER_HELP_WIDGET_ENABLE` | `1` | empty |

Intercom `app_id` in the HTML is `nszrac5k`.

Fonts: `custom.css` declares `roboto` (Roboto Regular) and `PassionOne` (Passion One Regular) from `public/fonts`. The login page CSS uses those family names. Other weights are in `public/fonts` and have no `@font-face` rule. Licenses for both families are in those folders (SIL Open Font License, Apache License).

`manifest.json` short name `ClickLearnerPortal`, name `Click Foundation Learner Portal`, `display` `standalone`, start URL `./index.html`.

`Layout.js` wraps children in a reactstrap `Container` and has no nav.

### Test file

`ClientApp/src/App.test.js` imports `App` as a default export and wraps it in `MemoryRouter`. `App.js` uses a named export (`export class App`) and already creates its own `Router`. The test file does not match the module. No CI job runs it. Provenance: **Implemented** file that does not match the component.

`package.json` script `test` is `cross-env CI=true react-scripts test --env=jsdom`. `lint` is `eslint ./src/`.

## Databases

Two connection-string names in `appsettings.json`:

| Name | Options key | Database name in the committed JSON | Wrapper | Callers |
|---|---|---|---|---|
| Default | `ConnectionStrings:DefaultConnection` | `ClickDW` | `DWSqlConnection` | `LearnerRepository`, `ProgrammeRepository`, `MobenziRepository` |
| Staging | `ConnectionStrings:StagingConnection` | `ClickSTAGING` | `StagingSqlConnection` | `NotificationRepository`, `TAORepository` |

Both strings use port `5432`, user id `postgres`, and AWS RDS hostnames in `af-south-1` (`tcf-prod-auroradb-dw…` and `tcf-prod-auroradb-staging…`). The full hostnames and passwords are in `appsettings.json`. They are not repeated here.

`appsettings.production.json` is `{}`, so the production environment file does not replace those strings.

The wrapper classes implement `IDbConnection` by forwarding to `NpgsqlConnection`. Comments say the `SqlConnection` adapter was removed. Constructors do not open the connection. Each repository calls `Open()` in its own constructor. Repositories are transient, so a connection is opened per resolution and is not disposed by the repository (the wrapper's `Dispose` closes the Npgsql connection only if something disposes the wrapper). Provenance: **Implemented**.

There is no schema script, seed, or EF migration. `dotnet-ef` 3.1.0 is listed as a local tool and has nothing to operate on.

### Routines the code calls

Arguments are the anonymous-object property names Dapper sends.

| Routine | Caller | Connection | Arguments |
|---|---|---|---|
| `portal.puserbyusername` | `LearnerRepository.FindByUsername` | ClickDW | `par_username`, `par_programmecode`, `par_activeonly`. `CommandType.StoredProcedure` |
| `portal.puserbyuserid` | `LearnerRepository.FindByUserID` | ClickDW | `par_userid`. Stored procedure |
| `portal.puserredirectto` | `LearnerRepository.RedirectTo` | ClickDW | `par_userid`. Stored procedure |
| `portal.pProgrammeGetByID` | `ProgrammeRepository.GetProgrammeByID` | ClickDW | `ID`. Stored procedure. Name was not converted to the `par_` style |
| `portal.pmobenziparentverifyidnumberandfoodparcel` | `MobenziRepository.VerifyIDNumberAndFoodParcel` | ClickDW | `IDNumber`. Stored procedure |
| `portal.pmobenziformsubmissionreceived` | `MobenziRepository.FormSubmissionReceived` | ClickDW | `Data`. Stored procedure |
| `portal.pSMSNotificationInsert` | `NotificationRepository.SMSNotificationInsert` | ClickSTAGING | PascalCase fields (`Received`, `Status`, `StatusDescription`, `MessageReference`, `PhoneNumber`, `CreateTime`, `ScheduledTime`, `SubmitTime`, `ConfirmTime`, `FinalTime`, `UserReference`, `MessageText`). Stored procedure |
| `portal.pSMSReplyInsert` | `NotificationRepository.SMSReplyInsert` | ClickSTAGING | `Received`, `MessageReference`, `PhoneNumber`, `SourceNumber`, `CreateTime`, `MessageText`, `UserReference`, `Flags`, `OriginalText`. Stored procedure |
| `portal.ptaoformsubmissionreceived` | `TAORepository.FormSubmissionReceived` | ClickSTAGING | Raw `CALL` with the two ids interpolated as quoted literals. Not `CommandType.StoredProcedure` |
| `portal.pLearnerStartedQuiz` | `TAORepository.LearnerStartedQuiz` | ClickSTAGING | Same raw `CALL` style |

Comments on the learner lookups say "changed sp name to postgres names". The programme, Mobenzi, and SMS calls do not all follow that rename. Whether Npgsql + Dapper `StoredProcedure` matches functions that were created with those argument names is **Unknown / Requires Confirmation**. The procedure bodies are not in git.

`silicon_ark/ClickPortal/` documents a different call list (`portal.pUserByUserName` and `SqlClient`) against the same database names. That document is not the contract for this repository.

### Row shapes

See business-rules.md. Dapper maps columns onto these properties by name.

## Configuration

| Key | File | Used by |
|---|---|---|
| `ConnectionStrings:DefaultConnection` | `appsettings.json` | `DWSqlConnection` |
| `ConnectionStrings:StagingConnection` | `appsettings.json` | `StagingSqlConnection` |
| `Serilog` | `appsettings.json` | `Program.Main` and `UseSerilog` |
| `AllowedHosts` | `*` | Host filtering |
| `AppSettings:AppID` | `appsettings.json` | Bound, never injected |
| `Jwt:Key`, `Jwt:Issuer`, `Jwt:Expires` | `appsettings.json` | `AddJwtBearer`. Issuer `learn.clicklearning.org`. `Expires` `3600` (bound to `int` on `JwtOptions`). The key is a long literal in git. No issuer uses `Expires` |
| `ASPNETCORE_ENVIRONMENT` | `launchSettings.json` | `Development` for IIS Express and the project profile |

Serilog file sink: path `c:/websites/learnerportal/logs/log.txt`, rolling interval `Day`, template `{Timestamp:o} {ThreadId} {Level:u3} {Scope} {Message}{NewLine}{Exception}`. Minimum level Information, `Microsoft` Warning.

## Local run

`launchSettings.json`:

| Profile | URL | Notes |
|---|---|---|
| IIS Express | `http://localhost:58409` | `ASPNETCORE_ENVIRONMENT=Development`. `sslPort` 0 |
| ClickLearnerPortal (Project) | `https://localhost:5001` and `http://localhost:5000` | launches the browser |
| IIS (not a profile; `iisSettings.iis`) | `http://localhost/ClickLearnerPortal` | `windowsAuthentication` true, `anonymousAuthentication` false |

The IIS authentication flags are in the launch settings file. `web.config` does not set Windows authentication. A Debug build without `node_modules` runs `npm install` (`DebugEnsureNodeEnv` in the csproj) and fails the build if `node` is missing.

Publish target `PublishRunWebpack` runs `npm install` and `npm run build` in `ClientApp` and adds `ClientApp/build/**` to the publish set. SPA source is excluded from content (`SpaRoot`) except as `None` items so the files show in Visual Studio. `node_modules` is excluded.

`ClientApp/public/NewFolder` is removed from compile, content, embedded resource, and none items. That folder is not in git.

## Deployment and hosting

What is in git:

- A Windows log path `c:/websites/learnerportal/logs/`.
- `web.config` with a commented-out ASP.NET Core Module handler, a `Cache-Control: no-cache` custom header, and static `clientCache` of 30 seconds.
- `web.production.config`, an XDT document that inserts IIS rewrite rules: HTTPS redirect (`303`), exact `support` → `/admin/support` (`301`), `support/*` → `/admin/{R:0}` (`301`), and a rule named `Redirect /account/login` whose match URL is `support` again (so it does not match `account/login`, and the earlier exact rule already stops processing). No publish profile in git references this file.
- JWT issuer `learn.clicklearning.org`.
- No Docker, no pipeline, no appspec, no systemd unit.

How the site is actually deployed, and whether `web.production.config` is applied, is **Unknown / Requires Confirmation**.

The task-creation listing included `sonar-project.properties` and `sonarqube_scan.bat`. They are not in commit `2cb0ae5`.

## Authentication

`AddAuthentication` sets the default authenticate and forbid schemes to JWT bearer. Token validation checks signature, issuer, lifetime, and audience. Audience must equal the issuer. The signing key is ASCII bytes of `Jwt:Key`.

`ConfigureDevelopmentServices` / the default `true` flag leaves `RequireHttpsMetadata` false, as described above. `SaveToken` is true.

No controller action reads `User`. Login does not call `JwtSecurityTokenHandler` or any equivalent. `AccountController` would have returned a token from `identityService.Authenticate`, and that file is entirely commented out. `IdentityUser.PasswordHash` is unused.

Client `PrivateRoute` expects `authenticationService.currentUserValue()` and sends anonymous users to `/account/login`. That service file is not in git, and `App.js` does not mount `PrivateRoute`. There is no `/account/login` route in the router.

Shared secrets, separate from JWT:

- `MobenziController.MobenziLookupIDNumber` compares `token` to a compiled string and returns `401` on mismatch.
- `QuizController.LearnerDetail` compares `securityToken` to the same string and throws on mismatch.

The Mobenzi form POST and the SMS POST do not check a secret.

## Background work

None. No `IHostedService`, Hangfire, queue, or timer.

## Storage

No blob store, S3 client, or uploaded-file handler. Static files live under `ClientApp/public` (images, fonts, PDF, favicon). `pages/learner.zip` is a second copy of the login page sources, not user data. See features.md.

## Monitoring

Serilog request logging and the file sink are the only monitoring in the tree. No health-check endpoint, no metrics package, no APM agent. Google Analytics is the only client telemetry, and only when the env id is non-empty.

The login action logs the submitted password through a scope property named `Password`, then later logs the result JSON after the password field has been cleared.

## Security notes (as implemented)

These are observations for human review. They are not a penetration procedure.

- Database passwords and the JWT signing key are in `appsettings.json` on `master`.
- The Mobenzi/quiz shared secret is a literal in two controllers, so it is in git history.
- Learner passwords are compared in plaintext and included in a log scope.
- `TAORepository` builds `CALL` statements by interpolating `learnerId` and `quizId`.
- CORS is unrestricted. `AllowedHosts` is `*`.
- SMS and Mobenzi form endpoints are anonymous.
- The quiz failure path throws instead of returning `401`. A commented block shows that a `401` was considered.
- The password box starts as `type="text"`.
- `postMessage` on the Mobenzi lookup uses target origin `*`.
- No rate limiting on `api/learner/login`.

`Pages/Error.cshtml` tells the reader not to enable the Development environment on a deployed app. That is the template text. It is accurate relative to `UseDeveloperExceptionPage`.

## Integrations (technical)

| System | Where it is wired |
|---|---|
| PostgreSQL `ClickDW` / `ClickSTAGING` | Connection strings and the two wrappers |
| Reading Eggs | Absolute URL in `ClickHomeLearningPage.js` (`sso.readingeggs.com`, redirect host `app.readingeggs.com`, `locale=za`) |
| Matific | `https://www.matific.com/za/en-za/login-page/` |
| Reflective Learning | `https://app.reflectivelearning.co.za/auth/login` |
| sacoronavirus.co.za | Banner href |
| Google Analytics `UA-162415700-1` | `index.html` plus `.env` |
| Intercom `nszrac5k` | `index.html`, flag in `.env` |
| Font Awesome kit | `index.html` |
| Mobenzi | Comments plus the two un-prefixed routes and the quiz comment |
| iTouch | Comment plus the field parser in `NotificationController` |
| TAO | Controller, repository, and the home-learning return handler |

The LTI platform repository documented in `silicon_ark/click-learner-portal-platform/` builds a return URL onto `/click-home-learning` with `learner_id` and `quiz_id`, and launches `GET /default/{learnerId}/{quizId}/{quizEndDate}`. This app's QUIZ redirect is `{stored URL}/{id}/{sourceQuizId}/{endDate}`. The stored URL is not in this repo. The fit is **Inferred**.

## Development and test process

What the repository shows:

- Open the solution in Visual Studio 2019, or `dotnet run` on the web project with the project profile.
- The SPA dev server is started by SpaServices when `ASPNETCORE_ENVIRONMENT` is Development. `npm start` deletes `ClientApp/build` (`rimraf`) and runs react-scripts.
- `npm test` and `npm run lint` exist as scripts. No server-side test project exists. The one React test does not match `App.js`.
- No pipeline file runs those scripts.

## Dependency versions (server)

| Package | Version in csproj |
|---|---|
| Dapper | 2.0.35 |
| Dapper.Contrib | 2.0.35 (no usages found) |
| Microsoft.AspNetCore.Authentication.JwtBearer | 3.1.5 |
| Microsoft.AspNetCore.SpaServices.Extensions | 3.1.5 |
| Microsoft.Data.SqlClient | 1.1.3 (live path uses Npgsql) |
| Newtonsoft.Json | 12.0.3 |
| Npgsql | 6.0.4 |
| Serilog | 2.9.0 |
| Serilog.AspNetCore | 3.2.0 |
| Serilog.Enrichers.Thread | 3.1.0 |
| Serilog.Settings.Configuration | 3.1.0 |
| Serilog.Sinks.Debug | 1.0.1 |
| Serilog.Sinks.File | 4.1.0 |

Client dependency ranges are in `ClientApp/package.json`. The lockfile is `package-lock.json` (present in git). `node_modules` is gitignored.

## Models with no live caller

`Models/Learner.cs`, `Models/School.cs`, `Models/IdentityUser.cs`, `Models/Authenticated.cs`, `ClientModels/School.cs`, `ClientModels/AuthenticateModel.cs`, `ClientModels/AuthenticatedModel.cs`.

`ClientModels/AuthenticateModel.cs` has `[Required]` on username and password. It is only referenced from the commented account controller. The live login model (`LearnerLoginModel`) has no data annotations. Validation is the manual empty checks.
