Skip to content

11. Case Study: The RdvMedecins Application

11.1. The Problem

The RdvMedecins application manages appointments for a medical practice. Each doctor has time slots (from 8:00 a.m. to 12:00 p.m. in 20-minute increments, for example); an appointment consists of a day, a time slot, and a client (a patient). This is the exact implementation, in [ASP.NET Core MVC], of the [NestJS] application from the previous lesson: same database, same business rules, same pages, same translation dictionaries. The HTML pages are generated by the server using [Razor] views.

Three user roles share the application:

Role
Who
What it can do
ADMIN
The administrator ([admin])
View schedules, make and cancel appointments for any patient; manage (list, add, edit, delete) doctors and clients
DOCTOR
Doctors
View schedules, make appointments, and cancel appointments for any patient
USER
clients (patients)
view schedules without seeing other patients’ names; book an appointment for themselves; cancel their appointment; manage their account; create an account

Key management rules:

  • No appointments in the past;
  • a time slot can only be booked once per day;
  • a patient may have only one upcoming appointment at a time;
  • Two doctors (or two clients) cannot have the same first and last names, and a login belongs to only one account;
  • A doctor with appointments cannot be deleted; A client is deleted along with their appointments;
  • the login page is protected against bots (CAPTCHA, limited attempts), and creating a client account requires first solving the CAPTCHA on that page.

11.2. Using the Application

11.2.1. The login page

Image

The page features two buttons: Log in and Create a patient account. Both require you to enter the characters shown in the image. The buttons FR / EN change the language:

Image

An error (in this case, an incorrectly entered CAPTCHA) is displayed at the bottom of the page on a light pink background:

Image

11.2.2. The calendar, as seen by the administrator and doctors

Once logged in, the administrator is taken to the calendar page. They select a doctor and a day:

Image

Image

Each time slot is either available (Book button) or occupied (patient’s name, Cancel button). Clicking Book opens a window asking for the patient’s information:

Image

Clicking “Cancel” prompts a confirmation (here by Dr. Pélissier, whose navigation bar does not include the administration menus):

Image

11.2.3. The Patient Dashboard

A patient (role USER) arrives at My area: their upcoming appointment, which they can cancel, and links to their account management and calendar:

Image

In the calendar, they see their own appointment, but not the names of other patients. Since they already have an upcoming appointment, they cannot schedule another one:

Image

A patient without an appointment can book an available time slot for themselves:

Image

Account management: edit personal information, change the password, or delete the account (and its appointments). The current password is required for any changes:

Image

A patient who attempts to open a page reserved for the administrator receives an error page:

Image

11.2.4. Managing Doctors and Clients

The administrator has two additional menus, “Doctors” and “Patients”:

Image

Image

The form for adding a doctor, submitted with errors:

Image

A name that matches an existing doctor is rejected:

Image

Deleting a client requires confirmation:

Image

11.2.5. Creating a customer account

From the login page, the "Create a patient account" button only opens the form if the CAPTCHA has been entered correctly:

Image

Image

11.3. Installation and Execution

The installation steps were provided at the beginning of this document (in the “Running the Case Study” section of the introduction): create the database using the script SQL, verify the connection string using [appsettings.json], then run [dotnet run] in the application folder. The application folder:

rdvmedecins2-aspnetcore-mvc/
├── Auth/
│   ├── AttemptLimiterService.cs
│   ├── AuthService.cs
│   ├── CaptchaService.cs
│   ├── ConnectedUser.cs
│   ├── InvalidSessionException.cs
│   ├── JwtAuthentication.cs
│   ├── README.md
│   └── RegistrationPassService.cs
├── Business/
│   ├── BusinessException.cs
│   ├── BusinessService.cs
│   ├── Dates.cs
│   ├── IBusiness.cs
│   └── README.md
├── Config/
│   ├── AuthOptions.cs
│   ├── ProtectionOptions.cs
│   └── README.md
├── Controllers/
│   ├── AuthController.cs
│   ├── ClientsController.cs
│   ├── DoctorsController.cs
│   ├── ErrorController.cs
│   ├── LanguageController.cs
│   ├── PatientAreaController.cs
│   ├── PersonsControllerBase.cs
│   ├── README.md
│   ├── RegistrationController.cs
│   └── ScheduleController.cs
├── Data/
│   ├── RdvMedecinsDbContext.cs
│   └── README.md
├── database/
│   ├── dbrdvmedecins2.sql
│   └── README.md
├── Domain/
│   ├── DoctorDaySchedule.cs
│   ├── DoctorDaySlot.cs
│   └── README.md
├── Entities/
│   ├── AbstractEntity.cs
│   ├── Appointment.cs
│   ├── Client.cs
│   ├── Doctor.cs
│   ├── Person.cs
│   ├── README.md
│   ├── Role.cs
│   ├── Slot.cs
│   └── User.cs
├── i18n/
│   ├── en.json
│   └── fr.json
├── Models/
│   ├── Forms.cs
│   ├── README.md
│   └── ViewModels.cs
├── Properties/
│   └── launchSettings.json
├── Repositories/
│   ├── AppointmentRepository.cs
│   ├── PersonRepository.cs
│   ├── README.md
│   ├── SlotRepository.cs
│   └── UserRepository.cs
├── Views/
│   ├── Auth/
│   │   ├── _Captcha.cshtml
│   │   └── Login.cshtml
│   ├── PatientArea/
│   │   ├── Account.cshtml
│   │   └── Area.cshtml
│   ├── Persons/
│   │   ├── Form.cshtml
│   │   └── List.cshtml
│   ├── Registration/
│   │   └── Registration.cshtml
│   ├── Schedule/
│   │   ├── _Booking.cshtml
│   │   └── Schedule.cshtml
│   ├── Shared/
│   │   ├── _Confirmation.cshtml
│   │   ├── _Errors.cshtml
│   │   ├── _Flash.cshtml
│   │   ├── _Layout.cshtml
│   │   ├── _Navbar.cshtml
│   │   ├── _PersonFields.cshtml
│   │   └── Error.cshtml
│   ├── _ViewImports.cshtml
│   ├── _ViewStart.cshtml
│   └── README.md
├── Web/
│   ├── ApplicationModel.cs
│   ├── Flash.cs
│   ├── HtmlExceptionFilter.cs
│   ├── README.md
│   ├── Translator.cs
│   ├── Utils.cs
│   ├── Validation.cs
│   └── ViewPage.cs
├── wwwroot/
│   ├── css/
│   │   └── styles.css
│   ├── js/
│   │   └── app.js
│   └── lib/
│       └── bootstrap/
│           ├── css/
│           │   └── bootstrap.min.css
│           └── LICENSE
├── .gitignore
├── appsettings.Development.json
├── appsettings.json
├── Program.cs
├── RdvMedecins.csproj
└── README.md

Each folder contains a file named [README.md] that lists its files. The console at startup:

dotnet run
Using launch settings from Properties/launchSettings.json...
Building...
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:8081
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
      Content root path: C:\course\rdvmedecins2-aspnetcore-mvc

In development mode (see [Properties/launchSettings.json]), the console also displays each SQL command sent to the database by EF Core. You can also:

  • launch the application using [dotnet watch]: it is recompiled and relaunched whenever a file is modified;
  • step through it in [Visual Studio Code]: with the [C# Dev Kit] extension, the F5 key starts the application under the debugger’s control; Set a breakpoint with a single click in the margin of a line of code.

11.4. The application architecture

Image

  • [Program.cs] configures the application: services (dependency injection), middleware (static files, error pages, language, authentication), controllers;
  • [Auth]: connection, JWT tokens, roles, CAPTCHA, and attempt limits;
  • [Controllers]: web layer controllers. They all pass through a single service, [ApplicationModel] (folder [Web]), which stores the lists of doctors and clients in memory and forwards other requests to the business layer;
  • [Web]: Common elements of the web layer: translation, flash messages, validation, view parent class, exception filter; [Models]: Form and view templates;
  • [Business]: business rules;
  • [Repositories]: the DAO layer, one class per table; [Data]: the [DbContext] layer of EF Core;
  • [Entities]: the EF Core entities; [Domain]: the calendar objects, calculated by the business layer;
  • [Views]: the views [Razor]; [i18n]: dictionaries; [wwwroot]: the style sheet, the page script, and [Bootstrap].

The suite comments on all files, from the lower layers (database) to the upper layers (views).

11.5. The database

The [dbrdvmedecins2] database is created by the [database/dbrdvmedecins2.sql] script, which is run in the same way as in Example 28 (the [HeidiSQL] tool from [Laragon], or the [mysql -u root < database/dbrdvmedecins2.sql] command). It is, word for word, the script for the [NestJS] application: both applications can work on the same database, and one can replace the other without making any changes.

11.5.1. database/dbrdvmedecins2.sql

-- =========================================================================
-- Database [dbrdvmedecins2] - MySQL 8
--
-- Evolution of [dbrdvmedecins]:
--   1. the [doctors] and [clients] tables get a [user] column,
--      foreign key to users(id): every doctor and every client is
--      now also a user able to log in;
--   2. a third role appears: DOCTOR (in addition to ADMIN and USER);
--   3. the script creates a [users] row for each doctor (role DOCTOR)
--      and for each client (role USER):
--        - login        = last name in lower case
--        - password     = last name in lower case, hashed with bcrypt (10 rounds)
--        - display name = first name + last name
--
--   4. uniqueness constraints, all NAMED (the application recognizes
--      the name of the violated constraint in the MySQL error message):
--        - unq_users_login                   : a login belongs to one account only;
--        - unq_doctors_first_name_last_name  : two doctors cannot have the
--                                              same first and last names;
--        - unq_clients_first_name_last_name  : same for the clients.
--      Beware: the utf8mb4_unicode_ci collation compares regardless of
--      case and accents - "Hélène DUPONT" and "helene dupont"
--      are therefore considered identical.
--
-- The [users] table must now be created BEFORE [doctors] and [clients]
-- (they reference it), and dropped AFTER them.
-- =========================================================================

-- we start again from an empty database on each run of the script (handy in development)
CREATE DATABASE IF NOT EXISTS dbrdvmedecins2 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE dbrdvmedecins2;

-- drop order: from the "child" tables to the "parent" tables
DROP TABLE IF EXISTS appointments;
DROP TABLE IF EXISTS slots;
DROP TABLE IF EXISTS clients;
DROP TABLE IF EXISTS doctors;
DROP TABLE IF EXISTS users;

-- -------------------------------------------------------------------------
-- table [users]: the users allowed to log in to the application
-- -------------------------------------------------------------------------
CREATE TABLE users (
  id       INT AUTO_INCREMENT PRIMARY KEY,
  version  INT NOT NULL DEFAULT 0,
  login    VARCHAR(30) NOT NULL,                 -- login identifier
  password VARCHAR(100) NOT NULL,                -- HASHED password (bcrypt) - never in clear
  name     VARCHAR(40) NOT NULL,                 -- name displayed once logged in
  role     VARCHAR(10) NOT NULL,                 -- 'ADMIN', 'DOCTOR' or 'USER'
  -- a login can belong to one account only
  CONSTRAINT unq_users_login UNIQUE (login)
);

-- -------------------------------------------------------------------------
-- table [doctors]: the doctors of the practice
-- -------------------------------------------------------------------------
CREATE TABLE doctors (
  id         INT AUTO_INCREMENT PRIMARY KEY,   -- primary key (technical identifier)
  version    INT NOT NULL DEFAULT 0,            -- row version number (optimistic lock)
  title      VARCHAR(5)  NOT NULL,              -- Melle, Mme, Mr
  last_name  VARCHAR(20) NOT NULL,
  first_name VARCHAR(20) NOT NULL,
  `user`     INT NOT NULL,                      -- foreign key -> users(id)
  CONSTRAINT fk_doctors_user FOREIGN KEY (`user`) REFERENCES users(id),
  -- one account per doctor, and one doctor per account
  CONSTRAINT unq_doctors_user UNIQUE (`user`),
  -- two doctors cannot have the same first and last names
  CONSTRAINT unq_doctors_first_name_last_name UNIQUE (first_name, last_name)
);

-- -------------------------------------------------------------------------
-- table [clients]: the doctors' patients
-- -------------------------------------------------------------------------
CREATE TABLE clients (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  version    INT NOT NULL DEFAULT 0,
  title      VARCHAR(5)  NOT NULL,
  last_name  VARCHAR(20) NOT NULL,
  first_name VARCHAR(20) NOT NULL,
  `user`     INT NOT NULL,                      -- foreign key -> users(id)
  CONSTRAINT fk_clients_user FOREIGN KEY (`user`) REFERENCES users(id),
  -- one account per client, and one client per account
  CONSTRAINT unq_clients_user UNIQUE (`user`),
  -- two clients cannot have the same first and last names
  CONSTRAINT unq_clients_first_name_last_name UNIQUE (first_name, last_name)
);

-- -------------------------------------------------------------------------
-- table [slots]: the time slots of each doctor
-- -------------------------------------------------------------------------
CREATE TABLE slots (
  id           INT AUTO_INCREMENT PRIMARY KEY,
  version      INT NOT NULL DEFAULT 0,
  doctor_id    INT NOT NULL,                     -- foreign key -> doctors(id)
  start_hour   INT NOT NULL,                     -- start hour of the slot
  start_minute INT NOT NULL,                     -- start minute of the slot
  end_hour     INT NOT NULL,                     -- end hour of the slot
  end_minute   INT NOT NULL,                     -- end minute of the slot
  CONSTRAINT fk_slots_doctor FOREIGN KEY (doctor_id) REFERENCES doctors(id)
);

-- -------------------------------------------------------------------------
-- table [appointments]: the appointments booked by the clients
-- -------------------------------------------------------------------------
CREATE TABLE appointments (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  version    INT NOT NULL DEFAULT 0,
  day        DATE NOT NULL,                      -- day of the appointment
  slot_id    INT NOT NULL,                       -- foreign key -> slots(id)
  client_id  INT NOT NULL,                       -- foreign key -> clients(id)
  CONSTRAINT fk_appointments_slot   FOREIGN KEY (slot_id)   REFERENCES slots(id),
  CONSTRAINT fk_appointments_client FOREIGN KEY (client_id) REFERENCES clients(id),
  -- the same slot, on the same day, can be booked only once
  CONSTRAINT unq1_appointments UNIQUE (day, slot_id)
);

-- =========================================================================
-- Demo data
-- =========================================================================

-- the users: passwords hashed with bcrypt (10 salt rounds)
--   admin/admin (ADMIN)
--   one DOCTOR account per doctor, one USER account per client
--   (the user/user demo account is gone: a USER account is
--   now ALWAYS a client's account, who can book and manage the account)
--   (login = password = last name in lower case)
INSERT INTO users (login, password, name, role) VALUES
  ('admin',     '$2b$10$YSNBJXXHX9mp13nPZb6YGu8M1jR6uvPwO1s2X73z0QAqBR7TOQjoq', 'Administrator',    'ADMIN'),
  -- doctors
  ('pelissier', '$2b$10$DnSDOK5ONrdMxTTr1NKCHOphR3WSp5QAyG9S9ueruw9HwlxI.16ba', 'Marie PELISSIER',  'DOCTOR'),
  ('tatou',     '$2b$10$PeovBO.gmJUKP4PglI/fwu4xtWOYSxoM5VTWjTj7uQ4wcd9P9.htS', 'Paul TATOU',       'DOCTOR'),
  ('dupont',    '$2b$10$az7wvPKC1h0EiAxXwu/PU.vYjzmB04rd5B7QnK0mvxp4niYIHVoc.', 'Sophie DUPONT',    'DOCTOR'),
  -- clients
  ('martin',    '$2b$10$GT6S5pp.WdGapFw/UmocXesbC6LEjJfZzgatCUyG9Sf4o1iBNlqZW', 'Jean MARTIN',      'USER'),
  ('durand',    '$2b$10$l2gl/zCder8E7Nh4VxGRX.0.SsO3FDiXt6YwqYq3v9nFzgC/jQCy6', 'Alice DURAND',     'USER'),
  ('bistrou',   '$2b$10$naeRp2YUU6c.QDl5itJMAum3Y5aHdUV6jwIcsbp0D.64jPG4F6E9m', 'Brigitte BISTROU', 'USER'),
  ('german',    '$2b$10$tTWxh3ROWF7NjhZOMnWDMOsZcnSWUiwwfy5C3G0.xhccTXALKyLeW', 'Luc GERMAN',       'USER');

-- the doctors: the [user] column is looked up from the login,
-- rather than assuming the values of the primary keys of [users]
INSERT INTO doctors (title, last_name, first_name, `user`) VALUES
  ('Mme',  'PELISSIER', 'Marie',  (SELECT id FROM users WHERE login = 'pelissier')),
  ('Mr',   'TATOU',     'Paul',   (SELECT id FROM users WHERE login = 'tatou')),
  ('Melle','DUPONT',    'Sophie', (SELECT id FROM users WHERE login = 'dupont'));

INSERT INTO clients (title, last_name, first_name, `user`) VALUES
  ('Mr',   'MARTIN',  'Jean',     (SELECT id FROM users WHERE login = 'martin')),
  ('Mme',  'DURAND',  'Alice',    (SELECT id FROM users WHERE login = 'durand')),
  ('Melle','BISTROU', 'Brigitte', (SELECT id FROM users WHERE login = 'bistrou')),
  ('Mr',   'GERMAN',  'Luc',      (SELECT id FROM users WHERE login = 'german'));

-- slots of doctor PELISSIER (doctor_id = 1): from 8:00 to 12:00 in 20-minute steps
INSERT INTO slots (doctor_id, start_hour, start_minute, end_hour, end_minute) VALUES
  (1, 8, 0, 8, 20), (1, 8, 20, 8, 40), (1, 8, 40, 9, 0),
  (1, 9, 0, 9, 20), (1, 9, 20, 9, 40), (1, 9, 40, 10, 0),
  (1, 10, 0, 10, 20), (1, 10, 20, 10, 40), (1, 10, 40, 11, 0),
  (1, 11, 0, 11, 20), (1, 11, 20, 11, 40), (1, 11, 40, 12, 0);

-- slots of doctor TATOU (doctor_id = 2): from 14:00 to 18:00 in 20-minute steps
INSERT INTO slots (doctor_id, start_hour, start_minute, end_hour, end_minute) VALUES
  (2, 14, 0, 14, 20), (2, 14, 20, 14, 40), (2, 14, 40, 15, 0),
  (2, 15, 0, 15, 20), (2, 15, 20, 15, 40), (2, 15, 40, 16, 0),
  (2, 16, 0, 16, 20), (2, 16, 20, 16, 40), (2, 16, 40, 17, 0),
  (2, 17, 0, 17, 20), (2, 17, 20, 17, 40), (2, 17, 40, 18, 0);

-- an appointment already booked, to see an occupied slot right away in the schedule
INSERT INTO appointments (day, slot_id, client_id) VALUES
  (CURDATE(), 2, 3);

Let’s go over this script:

  • Line 30: The database is created if it does not yet exist, using the character set [utf8mb4] and the collation [utf8mb4_unicode_ci], as in Example 28: string comparisons are case-insensitive and ignore accents. This also applies to uniqueness constraints: [Hélène DUPONT] and [helene dupont] are, for [MySQL], two instances of the same person;
  • Lines 34–38: The script starts with an empty database on each execution. Tables are deleted from the “child” tables to the “parent” tables: a table referenced by a foreign key ([users], for example, referenced by [doctors] and [clients]) cannot be deleted as long as another table still references it. Tables are created in the reverse order;
  • lines 43–52: user accounts. Each table begins with a primary key [id], assigned by the database ([AUTO_INCREMENT]), and a column [version], which is set to 0 when the row is created;
  • rows 46–49: the login, password, display name after logging in, and role ([ADMIN], [DOCTOR], or [USER]). The [password] column never contains the password itself, but rather its hash, [bcrypt] (see Example 25): 60 characters, hence the column width;
  • Line 51: A login belongs to only one account. As in Example 28, the constraint is named: when [MySQL] rejects a write operation, its error message specifies the name of the violated constraint, and the business layer uses this to select the message to display (see [Business/BusinessService.cs]);
  • lines 57–69: doctors: title, last name, first name;
  • Line 59: The column [version] is used for optimistic locking (see Example 28): it is incremented each time the row is modified, which makes it possible to detect whether another user has modified the row between the time it was read and the time it was written. As in Example 28, the application increments this value (see [DbContext], below);
  • lines 63–64: Each doctor has an account: the column [user] is a foreign key referencing [users(id)]. The database rejects a value that does not refer to any account, and refuses to delete an account that is still referenced by a doctor. Since [user] is a reserved word in [MySQL], the column name is enclosed in backticks;
  • line 66: an account belongs to only one doctor: the relationship between [doctors] and [users] is a “one-to-one” relationship;
  • line 68: Two doctors cannot have the same first and last names. The constraint applies to the pair of columns: two doctors can have the same last name if they do not have the same first name;
  • Lines 74–86: Clients (patients), following the same pattern: one account per client (foreign key [fk_clients_user], uniqueness constraint [unq_clients_user]); no two clients can have the same first and last names;
  • rows 91–100: each doctor’s time slots (foreign key [doctor_id]): start hour and minute, end hour and minute. A time slot does not have a date; it repeats every day;
  • lines 105–115: appointments: a date (type [DATE], without a time), a time slot, and a client, identified by two foreign keys;
  • line 114: the same time slot can only be booked once per day. The database itself enforces this rule: if two users book the same time slot at the same time, the second [INSERT] is rejected, regardless of what the application verified just before;
  • Lines 127–137: Demo accounts: the administrator, three doctors (role [DOCTOR]), and four clients (role [USER]). Each user’s password is their login ([admin] / [admin], [pelissier] / [pelissier]...). The hashes begin with [$2b$10$]: the algorithm [bcrypt], with a cost of 10 (see Example 25); the application’s library [BCrypt.Net-Next] knows how to verify them;
  • lines 141–144: each doctor’s [user] column is retrieved via a subquery on the login, rather than assuming the identifiers assigned by the database to the accounts;
  • lines 146–150: clients are handled in the same way;
  • lines 153–157: twelve 20-minute time slots, from 8:00 a.m. to 12:00 p.m., for Dr. PELISSIER. Here, the doctor’s ID (1) is hard-coded: the table [doctors] has just been created, so its IDs start at 1;
  • lines 160–164: twelve time slots, from 2:00 PM to 6:00 PM, for Dr. TATOU. Dr. DUPONT has no time slots: his schedule is empty;
  • lines 167–168: an appointment has already been scheduled for Brigitte BISTROU (client #3) during the second time slot of Dr. PELISSIER (8:20 a.m.). [CURDATE()] is the date the script is run: upon the first login, today’s calendar shows a slot that is already booked.

11.6. Project configuration

11.6.1. RdvMedecins.csproj

<!--
  =========================================================================
  RdvMedecins.csproj - the project file (the equivalent of package.json).

  Microsoft.NET.Sdk.Web: the SDK of ASP.NET Core applications. On its own it
  brings the whole web framework (MVC, Razor views, authentication,
  localization, static files...): only the libraries
  EXTERNAL to the framework are listed below (PackageReference), and
  downloaded from nuget.org by [dotnet restore] / [dotnet build].
  =========================================================================
-->
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <!-- .NET 10, LTS version (long-term support) -->
    <TargetFramework>net10.0</TargetFramework>
    <!-- "nullable" reference types: the compiler reports the risks of
         NullReferenceException (the equivalent of strictNullChecks in TypeScript) -->
    <Nullable>enable</Nullable>
    <!-- implicit usings: System, System.Linq, System.Threading.Tasks... -->
    <ImplicitUsings>enable</ImplicitUsings>
    <RootNamespace>RdvMedecins</RootNamespace>
  </PropertyGroup>

  <ItemGroup>
    <!-- Entity Framework Core + its MySQL / MariaDB provider.
         Pomelo itself pulls Microsoft.EntityFrameworkCore (and MySqlConnector,
         the ADO.NET driver) in the version that matches it. -->
    <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
    <!-- JWT token authentication (reading and verifying the token) -->
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
    <!-- password hashing (bcrypt), compatible with the
         $2b$ hashes of the SQL script -->
    <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
  </ItemGroup>

  <ItemGroup>
    <!-- the translation dictionaries are read at startup. The Web SDK
         already puts all .json files among the files to publish (Content):
         we simply specify to always copy them (dotnet publish) -->
    <Content Update="i18n\*.json" CopyToPublishDirectory="PreserveNewest" />
    <!-- the SQL script does not need to be published -->
    <Content Remove="database\**" />
    <None Include="database\**" />
  </ItemGroup>

</Project>

Let’s go over this file:

  • line 12: the SDK for web applications, as in all the examples (see introduction);
  • lines 14–23: the settings that the examples found in the shared file [examples/Directory.Build.props]: [.NET] 10, “nullable” reference types, [using] implicit. The case study is a standalone project: it writes these files itself. [RootNamespace] is the project’s root namespace, the one that [Visual Studio Code] proposes for a new file;
  • line 29: the [MySQL] provider from EF Core, which includes EF Core and the [MySqlConnector] driver (see Example 28);
  • lines 31–34: the two authentication packages, already used in Chapter 8: JWT token verification and password hashing;
  • line 41: the translation dictionaries [i18n/*.json] are read from disk at startup. When preparing the application for a production server (command [dotnet publish]), they must be copied along with it: this is what [CopyToPublishDirectory] does;
  • Lines 43–44: The script SQL is not an application file; it is excluded from the published files, as shown in Example 28.

11.6.2. appsettings.json

The file [appsettings.json] serves the same purpose as the file [.env] in the [NestJS] application. One important difference: the file [.env] was never shared, and a file named [.env.example] served as its template. Here, [appsettings.json] is part of the project; machine-specific values (such as a password) can be provided in another way, without modifying it (see below).

// =========================================================================
// appsettings.json - the configuration of the application (the equivalent of
// the .env file of the NestJS project). ASP.NET Core accepts comments
// in this JSON file.
//
// The sensitive values (MySQL password, JWT secret) can be
// replaced without touching this file: environment variables or
// "user secrets" (see README.md, Configuration section).
// =========================================================================
{
  // listening port. The JSON server already listens on 8080: we choose another
  // port to be able to run both at the same time.
  "Urls": "http://localhost:8081",

  // connection to the MySQL database [dbrdvmedecins2] (script: database/dbrdvmedecins2.sql)
  "ConnectionStrings": {
    "RdvMedecins": "Server=localhost;Port=3306;Database=dbrdvmedecins2;User=root;Password=;"
  },
  "MySql": {
    // server version, so that EF Core generates the right SQL dialect:
    // "8.0.0-mysql" for MySQL 8, "10.11.0-mariadb" for MariaDB 10.11...
    "ServerVersion": "8.0.0-mysql"
  },

  // authentication (see Auth/)
  "Auth": {
    // secret key of the JWT tokens: at least 32 characters (HMAC-SHA256).
    // Must absolutely be changed in production (long random string).
    "JwtSecret": "change-me-to-a-long-random-string",
    // validity duration of a token, in seconds (7200 s = 2 h); the cookie
    // that carries the token has the same lifetime
    "JwtExpiresInSeconds": 7200,
    // true in production (site served over HTTPS): the authentication cookie
    // is then never sent over an unencrypted connection
    "CookieSecure": false
  },

  // protection against robots (see Auth/AttemptLimiterService.cs)
  "Protection": {
    // login: failures allowed for the same login from the same IP address...
    "LoginMaxFailures": 5,
    // ... before a block of this duration (the whole IP address is also
    // blocked if it accumulates 5 times more failures, all logins combined)
    "LoginBlockMinutes": 15,
    // client account creation: at most this many accounts created per hour and per
    // IP address. In a classroom, all the computers often go out through the
    // same address: increase if needed.
    "RegistrationMaxPerHour": 10
  },

  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.EntityFrameworkCore": "Warning"
    }
  },
  "AllowedHosts": "*"
}

Let’s go over this file:

  • lines 1–9: Unlike the standard JSON, [ASP.NET Core] allows comments in its configuration files;
  • line 13: the address and listening port. The examples listened on port 5000, the default port; the application listens on port 8081, like the [NestJS] version;
  • lines 16–18: the connection string to the [dbrdvmedecins2] database, using the default values from [Laragon] (username [root], no password); see Example 28;
  • lines 19–23: the server version, which tells the provider [Pomelo] which dialect SQL to generate (see Example 28);
  • lines 26–36: the authentication section: the secret key that signs the tokens (at least 32 characters; must be replaced in production), the token’s lifetime (two hours), and the [Secure] option in the cookie that carries it. In production, the site is served as HTTPS, and the cookie must never travel over an unencrypted connection: the option is set to [true];
  • lines 39–49: bot protection thresholds (see Example 27): number of allowed failed login attempts and duration of the resulting block; number of customer accounts created per hour from the same IP address IP;
  • lines 51–57: logging levels (see introduction). Messages from EF Core are only displayed starting at level [Warning]: SQL commands, which are at level [Information], do not appear (see Example 28);
  • Line 58: All domain names are accepted.

The sections [Auth] and [Protection] are interpreted as typed classes (see [Config/AuthOptions.cs], below). Each value is designated by its path, with the levels separated by [:]: [Auth:JwtSecret], [ConnectionStrings:RdvMedecins], [MySql:ServerVersion]. [ASP.NET Core] reads the configuration from several successive sources, each of which can override the values from the previous ones: [appsettings.json], then [appsettings.{environment}.json] (see the next file), then, in development, “user secrets” (values stored outside the project, in the user’s profile), then environment variables, and finally command-line arguments. A password can thus be provided via an environment variable, where [:] is written as [__]:

export ConnectionStrings__RdvMedecins="Server=localhost;Port=3306;Database=dbrdvmedecins2;User=root;Password=myPassword;"

The application’s [README.md] file details these options.

11.6.3. appsettings.Development.json

// Override of appsettings.json in development (ASPNETCORE_ENVIRONMENT=
// Development, see Properties/launchSettings.json): we display in the
// console the SQL queries generated by EF Core - as the
// logging: true option of TypeORM did.
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.EntityFrameworkCore.Database.Command": "Information"
    }
  }
}
  • Line 10: [ASP.NET Core] knows the environment in which the application is running: [Development], [Production]... It reads this from the environment variable [ASPNETCORE_ENVIRONMENT] (see the following file); otherwise, it uses [Production]. After [appsettings.json], it reads the file [appsettings.{environment}.json], if it exists, and merges the two: the keys present in the second file replace those in the first, while the others are retained. During development, we therefore add the level [Information] for the category [Microsoft.EntityFrameworkCore.Database.Command]: the more specific rule takes precedence over the more general one for [appsettings.json] ([Warning] for any [Microsoft.EntityFrameworkCore]), and every SQL command sent to the database is displayed in the console. This is equivalent to the [logging] option of [TypeORM], which the [NestJS] version enabled outside of production. All other values (connection, authentication, etc.) remain the same as in [appsettings.json].

11.6.4. Properties/launchSettings.json

{
  "$schema": "https://json.schemastore.org/launchsettings.json",
  "profiles": {
    "http": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,
      "applicationUrl": "http://localhost:8081",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}

This file is used only during development by [dotnet run], [dotnet watch], and [Visual Studio Code]; it is not published. Let’s comment on it:

  • lines 3–4: A launch profile describes a way to launch the application. There is only one here; [dotnet run] uses the first one;
  • line 5: the application is launched directly, with its own web server;
  • lines 6–7: [dotnet run] displays its compilation messages, and the browser opens to the application when it is ready;
  • line 8: the listening address, the same as the key [Urls] from [appsettings.json]. When launched via this profile, the application uses this address; when launched outside the profile (for example, once published), it uses the address from [appsettings.json];
  • Lines 9–10: the environment variables passed to the application. [ASPNETCORE_ENVIRONMENT=Development] sets the environment: [appsettings.Development.json] is read, and the commands in SQL are displayed. On a production server, without this file or this variable, the environment is [Production].

11.6.5. Config/AuthOptions.cs

// =========================================================================
// Config/AuthOptions.cs
// -------------------------------------------------------------------------
// The "Auth" section of appsettings.json, as a typed class.
//
// It is the "options pattern" of ASP.NET Core: in
// Program.cs, builder.Services.Configure<AuthOptions>(...) binds the section
// to the class; a class that needs it then receives IOptions<AuthOptions>
// by dependency injection. We thus avoid reading character strings
// all over the place, as process.env.JWT_SECRET did.
// =========================================================================

namespace RdvMedecins.Config;

public class AuthOptions
{
    public const string Section = "Auth";

    // secret key used to sign / verify the JWT tokens. The HMAC-SHA256
    // algorithm requires a key of at least 256 bits, i.e. 32 bytes.
    public string JwtSecret { get; set; } = "";

    // validity duration of a token, in seconds; the cookie that carries it
    // has the same lifetime
    public int JwtExpiresInSeconds { get; set; } = 7200;

    // true in production (HTTPS): the authentication cookie is never
    // sent over an unencrypted connection
    public bool CookieSecure { get; set; }
}

Let’s comment on this code:

  • line 15: the [Auth] section of [appsettings.json], in the form of a regular class: one property per key, with the same name. This is the options template ([options pattern]) for [ASP.NET Core]. In [Program.cs], [Configure<AuthOptions>(...)] links the section to the class: the framework creates a [AuthOptions] object and populates its properties with the values from the section, converting them to the correct type (["7200"] becomes the integer 7200, [false] becomes the boolean [false]). A class that needs it receives it via dependency injection, in the form of a ***[IOptions<AuthOptions>], whose ***[Value] property is the populated object: This is what [Auth/AuthService.cs] does, with its parameter [IOptions<AuthOptions> options] and its field [auth = options.Value]. This avoids having to read character strings all over the place, as [process.env.JWT_SECRET] did in version [NestJS]: the key names are verified by the compiler, and the values are already converted;
  • line 17: the section name, written only once;
  • Lines 21–29: The three values for this section. The default values ([7200], [false] for a Boolean) are those that are used if the key is missing from the configuration.

11.6.6. Config/ProtectionOptions.cs

// =========================================================================
// Config/ProtectionOptions.cs
// -------------------------------------------------------------------------
// The "Protection" section of appsettings.json: the thresholds of the protection
// against robots (see Auth/AttemptLimiterService.cs).
// =========================================================================

namespace RdvMedecins.Config;

public class ProtectionOptions
{
    public const string Section = "Protection";

    // login: number of failures (wrong captcha or password) allowed
    // for the same login from the same IP address, before this pair is blocked...
    public int LoginMaxFailures { get; set; } = 5;

    // ... for this duration (an IP address is also blocked if it accumulates
    // 5 times more failures, all logins combined)
    public int LoginBlockMinutes { get; set; } = 15;

    // client account creation: maximum number of accounts created per hour
    // from the same IP address. In a classroom, all the computers often go out
    // through the same address: increase if needed.
    public int RegistrationMaxPerHour { get; set; } = 10;
}
  • Lines 10–25: the [Protection] section of [appsettings.json], following the same pattern. It is injected, in the form of a [IOptions<ProtectionOptions>], into [Auth/AttemptLimiterService.cs], which reads its thresholds from it.

11.7. Application startup

11.7.1. Program.cs

The file [Program.cs] serves both the role of [main.ts] (startup, middleware) and that of [app.module.ts] (which service provides what) from version [NestJS]. It follows the structure of all the examples: first register the services, then build the middleware pipeline. We’ll break it down into sections.

11.7.1.1. The configuration

// =========================================================================
// Program.cs
// -------------------------------------------------------------------------
// The entry point of the application: the equivalent of both main.ts
// (startup, middlewares) and app.module.ts (who provides what).
//
// Two stages, always in this order:
//
//   1. builder.Services... : we REGISTER the services in the dependency
//      injection container (the equivalent of the "providers" of the NestJS
//      modules). Each service has a LIFETIME:
//        - Singleton : a single instance for the whole application;
//        - Scoped    : one instance per HTTP request (the DbContext, and all
//                      that uses it);
//        - Transient : a new instance every time it is requested.
//
//   2. app.Use... : we build the middleware PIPELINE, which every
//      request goes through in the order in which they are declared.
// =========================================================================

using System.Globalization;
using System.Text.Encodings.Web;
using System.Text.Unicode;
using Microsoft.AspNetCore.Localization;
using Microsoft.EntityFrameworkCore;
using RdvMedecins.Auth;
using RdvMedecins.Config;
using RdvMedecins.Data;
using RdvMedecins.Business;
using RdvMedecins.Repositories;
using RdvMedecins.Web;

var builder = WebApplication.CreateBuilder(args);

// =========================================================================
// 1. the services
// =========================================================================

// ---- the typed configuration (sections of appsettings.json, see Config/)
builder.Services.Configure<AuthOptions>(builder.Configuration.GetSection(AuthOptions.Section));
builder.Services.Configure<ProtectionOptions>(builder.Configuration.GetSection(ProtectionOptions.Section));
var authOptions = builder.Configuration.GetSection(AuthOptions.Section).Get<AuthOptions>() ?? new AuthOptions();
  • lines 21–31: the namespaces used, in addition to the implicit [using] ones: those of the framework (localization, EF Core, encoding HTML), and those of the application, one per folder;
  • line 33: the application constructor, as in all examples. It has already read the configuration ([appsettings.json], [appsettings.Development.json], environment variables...), accessible via [builder.Configuration];
  • Lines 40–41: the options template (see [Config/AuthOptions.cs]): each section is linked to its class. [GetSection("Auth")] designates the section; [Configure<T>] registers the corresponding service [IOptions<T>], which the authentication services will receive via injection;
  • Line 42: the same section, this time read directly as an object ([Get<AuthOptions>()]), or an object with default values if the section is missing. The configuration of [JwtBearer] (below) requires the secret key right now, during service registration: at this point, the dependency injection container has not yet been built, and no [IOptions<AuthOptions>] can be injected.

11.7.1.2. The database and the DAO and business layers

// ---- Entity Framework Core: the DbContext, one per request (Scoped).
// UseMySql: the Pomelo provider for MySQL / MariaDB. The server
// version tells it which SQL dialect to generate.
var connectionString = builder.Configuration.GetConnectionString("RdvMedecins")
    ?? throw new InvalidOperationException("ConnectionStrings:RdvMedecins missing from appsettings.json");
var serverVersion = ServerVersion.Parse(builder.Configuration["MySql:ServerVersion"] ?? "8.0.0-mysql");
builder.Services.AddDbContext<RdvMedecinsDbContext>(options => options.UseMySql(connectionString, serverVersion));

// ---- DAO layer and business layer: Scoped, like the DbContext they
// depend on (a service must never live LONGER than its dependencies)
builder.Services.AddScoped<DoctorRepository>();
builder.Services.AddScoped<ClientRepository>();
builder.Services.AddScoped<SlotRepository>();
builder.Services.AddScoped<AppointmentRepository>();
builder.Services.AddScoped<UserRepository>();
// "when an IBusiness is requested, provide a BusinessService" (NestJS:
// { provide: BUSINESS_TOKEN, useClass: BusinessService }). In C#, the interface
// itself serves as the injection token.
builder.Services.AddScoped<IBusiness, BusinessService>();

// ---- the cache (singleton) and its facade (scoped), see Web/ApplicationModel.cs
builder.Services.AddSingleton<ListCache>();
builder.Services.AddScoped<ApplicationModel>();
  • Lines 4–5: the connection string, as in Example 28. The operator [??] returns the value on the left if it is not [null]; otherwise, it returns the value on the right: if the key is missing, the application stops immediately, displaying a message indicating what is missing, rather than returning an obscure error on the first request;
  • line 6: the server version [MySQL], with a default value if the key is missing;
  • line 7: the application’s [DbContext] (see [Data/RdvMedecinsDbContext.cs]), one per request ([Scoped]), with the provider [MySQL] (see Example 28);
  • Lines 11–15: the DAO layer: one class per table (except for [users], whose repository is used for authentication). It is [Scoped], just like the [DbContext] it uses. A service must never outlive its dependencies: a DAO [Singleton] would retain the [DbContext] from the first request indefinitely, even though it is destroyed at the end of that request (see Chapter 6);
  • Line 19: the business layer. [AddScoped<IBusiness, BusinessService>()] reads: “when a service requests an [IBusiness], provide it with a [BusinessService].” Those who use the business layer depend on the interface, not the class: we could replace the implementation (with a test version, for example) by modifying only this line. The [NestJS] version required a token ([BUSINESS_TOKEN]) for this, since the [TypeScript] interfaces disappear at compile time; in [C#], the interface itself serves as the token;
  • Lines 22–23: The web layer cache (the lists of doctors and clients) has application-level scope: [Singleton], a single instance shared by all requests (see Example 23). Its facade, [ApplicationModel], through which all controllers pass, uses the business layer: it is therefore [Scoped]. The cache itself, which is independent of anything else, can outlive the facade.

11.7.1.3. Authentication, translation, and encoding: HTML

// ---- authentication
builder.Services.AddScoped<AuthService>();
builder.Services.AddSingleton<CaptchaService>(); // pending captchas: in memory
builder.Services.AddSingleton<AttemptLimiterService>(); // counters: in memory
builder.Services.AddSingleton<RegistrationPassService>(); // passes: in memory
builder.Services.AddJwtAuthentication(authOptions); // see Auth/JwtAuthentication.cs
builder.Services.AddAuthorization();

// ---- translation
builder.Services.AddSingleton<Translator>();

// ---- the Razor HTML encoder. By default, it replaces every non-ASCII
// character with an entity ("é" -> "&#xE9;"): the page displays correctly,
// but its source code becomes unreadable. We allow it to write as they are
// the characters of all languages; < > & " ' are, for their part, always escaped.
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
  • line 2: the login service (verifying the username and password, generating the token; see Example 25). It uses the account repository: it is [Scoped];
  • Lines 3–5: pending CAPTCHAs, failure counters, and account creation passcodes (see Example 27). This data is kept in memory and must be the same for all requests: all three services are [Singleton]. The downside: they are lost every time the server restarts;
  • line 6: the configuration of [JwtBearer] (token read from the cookie, redirection to the login page...), as shown in Example 25. It is stored in an extension method of [IServiceCollection] (see [Auth/JwtAuthentication.cs]), which is called like a framework method and thereby lightens the load on [Program.cs];
  • line 7: the authorization services, used by [[Authorize]] and by the middleware [UseAuthorization];
  • line 10: the translation service, which reads the dictionaries once and for all (see Example 22);
  • line 16: the HTML encoder from [Razor] allows characters from all languages to pass through, as in Example 15.

11.7.1.4. MVC and its options

// ---- MVC: controllers + Razor views
builder.Services.AddControllersWithViews(options =>
{
    // InvalidSessionException -> back to the login page
    options.Filters.Add<HtmlExceptionFilter>();

    // with "nullable" types enabled, MVC automatically treats a (non-nullable)
    // string property as required. We prefer to say it ourselves,
    // explicitly, with [Required] (see Models/Forms.cs).
    options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true;

    // the messages of the BINDING errors (a text where a number is
    // expected: version="abc"...) are translation keys too
    var messages = options.ModelBindingMessageProvider;
    messages.SetValueMustNotBeNullAccessor(_ => "ERRORS.INVALID_FORM");
    messages.SetAttemptedValueIsInvalidAccessor((_, _) => "ERRORS.INVALID_FORM");
    messages.SetValueIsInvalidAccessor(_ => "ERRORS.INVALID_FORM");
    messages.SetValueMustBeANumberAccessor(_ => "ERRORS.INVALID_FORM");
    messages.SetUnknownValueIsInvalidAccessor(_ => "ERRORS.INVALID_FORM");
    messages.SetNonPropertyAttemptedValueIsInvalidAccessor(_ => "ERRORS.INVALID_FORM");
    messages.SetNonPropertyUnknownValueIsInvalidAccessor(() => "ERRORS.INVALID_FORM");
    messages.SetNonPropertyValueMustBeANumberAccessor(() => "ERRORS.INVALID_FORM");
    messages.SetMissingBindRequiredValueAccessor(_ => "ERRORS.INVALID_FORM");
    messages.SetMissingKeyOrValueAccessor(() => "ERRORS.INVALID_FORM");
    messages.SetMissingRequestBodyRequiredValueAccessor(() => "ERRORS.INVALID_FORM");
});
  • line 2: the [Razor] controllers and views. The function passed as a parameter sets the options for MVC;
  • line 5: a global exception filter (see Example 24), which applies to all actions. It handles only one case: a [InvalidSessionException] (the token is valid, but the account it refers to has since been deleted) leads to the login page. The other exceptions continue on their path to the [UseExceptionHandler] middleware (below);
  • line 10: a pitfall of “nullable” types. When enabled, MVC treats a (non-nullable) property [string] of a model as required, exactly as if it had the [[Required]] attribute, with an error message in English that we do not understand. This option removes this default behavior: a field is required only if it explicitly has the [[Required]] attribute, with our own message (see [Models/Forms.cs]);
  • Lines 14–25: binding error messages (see Example 19): text where a number is expected ([version=abc]), a missing value... Example 19 redefined only two of them, in English; the case study redefines them all, and each returns the same translation key, [ERRORS.INVALID_FORM], rather than a text: the message is translated at the time of display, in the user’s language. The character [_] refers to a parameter that is not used (the field name, the entered value): the message is the same regardless of the field. These errors should never occur with the application’s forms: they indicate a manually constructed request.

11.7.1.5. Loading the cache

var app = builder.Build();

// =========================================================================
// loading of the cache (lists of doctors and clients). Scoped services
// only exist within a "scope" (normally: a request): we create
// one, for the time of this loading. Database unreachable -> the application
// stops here, with the EF Core exception in the console.
// =========================================================================
using (var scope = app.Services.CreateScope())
{
    await scope.ServiceProvider.GetRequiredService<ApplicationModel>().LoadAsync();
}
  • Line 1: Application setup. The dependency injection container is now created: you can no longer register services, but you can retrieve them;
  • Lines 9–12: The lists of doctors and clients are loaded into the cache before the server starts listening. [ApplicationModel] is a service of [Scoped]: it can only be created within a scope, and outside of a request, there is no scope. [CreateScope()] creates one; [GetRequiredService<ApplicationModel>()] obtains the service from it (along with the business layer, the repositories, and the [DbContext] on which it depends), and [LoadAsync()] populates the cache, which is a [Singleton] and therefore survives the scope. The [using] statement destroys the scope—and the [DbContext] along with it—when the block ends. If the database is inaccessible, the EF Core exception is not caught: the application terminates, with an error message displayed in the console. This is the first sign of an incorrect connection string or a [MySQL] server that has stopped.

11.7.1.6. The middleware pipeline

// =========================================================================
// 2. the middleware pipeline - the ORDER matters
// =========================================================================

// ---- errors become HTML pages (see Web/HtmlExceptionFilter.cs):
// unhandled exception -> 500 page (the exception is written to the log);
// 400 / 403 / 404 response without content -> corresponding error page
app.UseExceptionHandler("/error/500");
app.UseStatusCodePagesWithReExecute("/error/{0}");

// ---- static files of the wwwroot/ folder: /css/..., /js/..., /lib/bootstrap/...
app.UseStaticFiles();

// ---- the language of each request: read from a cookie (see LanguageController),
// English by default. It sets CultureInfo.CurrentCulture (date formats)
// and CultureInfo.CurrentUICulture (language of the texts, see Web/Translator.cs).
var cultures = Translator.Languages.Select(l => new CultureInfo(l)).ToList();
var localization = new RequestLocalizationOptions
{
    DefaultRequestCulture = new RequestCulture(Translator.DefaultLanguage),
    SupportedCultures = cultures,
    SupportedUICultures = cultures,
};
// only the cookie decides (not the browser language, nor a URL parameter)
localization.RequestCultureProviders = [new CookieRequestCultureProvider()];
app.UseRequestLocalization(localization);

// ---- routing, then authentication (who is it? -> HttpContext.User, from
// the token), then authorization (is he allowed? -> [Authorize])
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

// ---- the controllers, with their routes declared by attributes ([HttpGet("...")])
app.MapControllers();

app.Run();

Each request passes through the middleware components in the order in which they are declared (see Chapter 7): this order is therefore important.

  • Line 8: The first middleware wraps all the others. If an exception has not been handled by anyone (a programming error, a database that has become inaccessible, etc.), it logs the exception and re-executes the request with the address [/error/500]: the user sees an application error page, not a technical message (see Chapter 7);
  • Line 9: An empty error response (400, 403, 404) is converted into a page in the same way, via the [/error/{code}] action of the [ErrorController] controller (see Examples 24 and 26). The re-executed request passes through the following middleware again: the error page is therefore rendered and recognizes the logged-in user;
  • line 12: the static files in the [wwwroot] folder (see Example 15): the style sheet, the page script, and [Bootstrap], stored here in [wwwroot/lib/bootstrap]. Placed early in the pipeline, this middleware responds to file requests without routing them through localization or authentication;
  • lines 17–23: the localization configuration, as in Example 22. The supported languages and the default language are those of the translation service ([Translator.Languages], [Translator.DefaultLanguage]): they are defined in only one place;
  • line 25: only the cookie set by [LanguageController] determines the language (see Example 22). The syntax [[...]] is a collection expression: in this case, a list with a single element;
  • line 26: the localization middleware, which sets the language for each request;
  • lines 30–32: routing, followed by authentication, followed by authorization. Routing selects the action that will handle the request, based on the routes declared by attributes. The examples did not call it: [WebApplication] therefore adds it automatically at the beginning of the pipeline. Calling it explicitly makes the order visible: authorization must come after routing, because it is the selected action that carries the attribute [[Authorize]] to be verified; and after authentication, which determines, based on the token, who the user is (see Example 25);
  • line 35: the controllers and their routes, declared by attributes ([[Route("schedule")]]...);
  • line 37: the server starts up and waits for requests on port 8081.

11.8. The entities

The EF Core entities correspond to the database tables and the [C#] classes (see Example 28). They replicate, one by one, the [TypeORM] entities from version [NestJS]: the attributes [[Table]], [[Column]], [[Key]], [[ForeignKey]] serve as the decorators [@Entity], [@Column], [@PrimaryGeneratedColumn], and [@JoinColumn]. The database is not created by EF Core, but by the script SQL [database/dbrdvmedecins2.sql]: the entities simply need to accurately describe the tables that already exist.

11.8.1. Entities/AbstractEntity.cs

// =========================================================================
// Entities/AbstractEntity.cs
// -------------------------------------------------------------------------
// The two fields common to all the entities: the identifier [id] and the
// version number [version] (optimistic lock).
//
// As with TypeORM (and @MappedSuperclass in JPA), it is enough for an
// ABSTRACT class to be the parent of the entity classes: EF Core does not create a table
// for it, but adds its properties to the tables of the child classes.
//
// The [Key], [Column]... attributes come from the standard namespace
// System.ComponentModel.DataAnnotations: EF Core reads them to build
// its "model" (which class goes in which table, which property in
// which column). They play the role of the @PrimaryGeneratedColumn,
// @Column decorators of TypeORM.
// =========================================================================

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace RdvMedecins.Entities;

public abstract class AbstractEntity
{
    // [Key]: the primary key. An integer key is, by convention, generated
    // by the database (AUTO_INCREMENT column): EF Core reads its value back after
    // each INSERT.
    [Key]
    [Column("id")]
    public int Id { get; set; }

    // [ConcurrencyCheck]: the optimistic lock. Each UPDATE generated by EF
    // Core carries the condition "WHERE id = ... AND version = <value read>".
    // If someone has modified the row in the meantime, no row is
    // updated and EF Core throws a DbUpdateConcurrencyException.
    //
    // Difference with TypeORM's @VersionColumn: EF Core does NOT INCREMENT
    // an integer version number by itself. It is our DbContext that does it,
    // just before each save (see Data/RdvMedecinsDbContext.cs).
    [Column("version")]
    [ConcurrencyCheck]
    public int Version { get; set; }
}
  • line 23: an abstract class with no attributes [[Table]]: it does not correspond to any table, but its properties are added to those of its child classes’ tables. All entities in the application inherit from it and therefore have an identifier and a version;
  • Lines 28–30: the primary key ([[Key]]), column [id]. By convention in EF Core, an integer key is generated by the database ([AUTO_INCREMENT]): EF Core re-reads its value after each [INSERT];
  • Lines 40–42: the line version for the optimistic lock (see Example 28). [[ConcurrencyCheck]] adds the condition [AND version = <value read>] to each [UPDATE]. Unlike [@VersionColumn] from [TypeORM], EF Core does not increment the version itself: [DbContext] handles this (see below).

11.8.2. Entities/Person.cs

// =========================================================================
// Entities/Person.cs
// -------------------------------------------------------------------------
// Parent class (without a table) of the [Doctor] and [Client] entities: a title, a
// last name, a first name - and the associated user account ([user] column).
//
// A relationship declared in an abstract class is inherited by the
// child classes exactly like the columns: EF Core therefore adds the
// [User] relationship to the [doctors] table AND to the [clients] table.
// =========================================================================

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace RdvMedecins.Entities;

public abstract class Person : AbstractEntity
{
    [Column("title")]
    [MaxLength(5)]
    public string Title { get; set; } = ""; // Melle, Mme, Mr

    [Column("last_name")]
    [MaxLength(20)]
    public string LastName { get; set; } = "";

    [Column("first_name")]
    [MaxLength(20)]
    public string FirstName { get; set; } = "";

    // -----------------------------------------------------------------------
    // The relationship to the user account, in TWO properties - it is the
    // usual way of doing it with EF Core:
    //   - UserId : the foreign key itself ([user] column). It can be
    //     read without loading the full User (TypeORM: @RelationId);
    //   - User   : the "navigation property", the User object itself. It
    //     is only filled if it is requested in the query (.Include(...),
    //     see Repositories/), otherwise it is null.
    // [ForeignKey] links the two. The "one to one" nature of the relationship
    // (UNIQUE [user] column) is specified in RdvMedecinsDbContext.
    // -----------------------------------------------------------------------
    [Column("user")]
    public int UserId { get; set; }

    // "= null!": we promise the compiler to read this property only
    // when it has been loaded (Include) or assigned
    [ForeignKey(nameof(UserId))]
    public User User { get; set; } = null!;
}
  • Line 17: the parent class—which is also abstract—of [Doctor] and [Client], itself a child of [AbstractEntity]. Doctors and clients have the same columns: they are defined only once;
  • Lines 19–29: the title (Mme, Mr, Melle), last name, and first name. [[MaxLength]] specifies the column width. These properties are of type [string] (not [string?]): they must never be set to [null], and the compiler therefore requires an initial value, in this case the empty string [""];
  • lines 42–43: the foreign key to the user account, column [user]. The account ID can be read without loading the account itself: this is what [@RelationId] did in version [NestJS];
  • lines 47–48: the navigation property: the [User] object itself. [[ForeignKey(nameof(UserId))]] indicates which property contains the foreign key ([nameof(UserId)] is equal to the string ["UserId"], but the compiler verifies that the property exists). A navigation property is populated only if the query requests it ([Include]; see the DAO layer); otherwise, it is set to [null]. The notation [= null!] suppresses the compiler warning: it guarantees that the property will only be read after it has been loaded. The “one-to-one” nature of the relationship is specified in [DbContext]. A relationship declared in a parent class is inherited, just like columns: the tables [doctors] and [clients] each have their own relationship to [users].

11.8.3. Entities/Doctor.cs

// =========================================================================
// Entities/Doctor.cs
// -------------------------------------------------------------------------
// A doctor of the practice ([doctors] table).
//
// SQL uniqueness constraint: unq_doctors_first_name_last_name (two doctors
// cannot have the same first and last names) - created by the SQL script,
// recognized by its name in the business layer.
// =========================================================================

using System.ComponentModel.DataAnnotations.Schema;

namespace RdvMedecins.Entities;

[Table("doctors")]
public class Doctor : Person
{
    // inverse (optional) relationship: the time slots of this doctor.
    // It shows the "1 doctor -> several slots" relationship in both
    // directions; like any navigation, it is only filled on demand.
    public List<Slot> Slots { get; set; } = [];
}
  • Line 15: The class [Doctor] corresponds to the table [doctors]. It inherits all its properties from [Person]. The uniqueness constraint [unq_doctors_first_name_last_name], mentioned in the comment, is created by the script SQL; the business layer recognizes it by its name (see Example 28);
  • line 21: the inverse relationship of that from [Slot] to its physician: this physician’s appointment slots. EF Core automatically associates the two navigations ([Doctor.Slots] and [Slot.Doctor]), which describe the same “one doctor, multiple time slots” relationship as seen from each side. Like any navigation, it is populated only on request; the application does not actually use it: it reads the appointment slots via the DAO layer. [[]] is a collection expression: in this case, an empty list.

11.8.4. Entities/Client.cs

// =========================================================================
// Entities/Client.cs
// -------------------------------------------------------------------------
// Beware: [Client] here designates a PATIENT of the medical practice, not an
// HTTP client! It is the vocabulary of the original business domain, kept
// as is ([clients] table).
//
// SQL uniqueness constraint: unq_clients_first_name_last_name (see Doctor.cs).
// =========================================================================

using System.ComponentModel.DataAnnotations.Schema;

namespace RdvMedecins.Entities;

[Table("clients")]
public class Client : Person
{
}

A client, meaning a patient at the practice (not an HTTP client): a [Person] from the [clients] table (row 15), with no additional properties.

11.8.5. Entities/Slot.cs

// =========================================================================
// Entities/Slot.cs
// -------------------------------------------------------------------------
// A time slot of a doctor ([slots] table): 8:00 - 8:20, etc.
// =========================================================================

using System.ComponentModel.DataAnnotations.Schema;

namespace RdvMedecins.Entities;

[Table("slots")]
public class Slot : AbstractEntity
{
    [Column("start_hour")] public int StartHour { get; set; } // start hour of the slot
    [Column("start_minute")] public int StartMinute { get; set; } // start minute of the slot
    [Column("end_hour")] public int EndHour { get; set; } // end hour of the slot
    [Column("end_minute")] public int EndMinute { get; set; } // end minute of the slot

    // several slots for the same doctor: foreign key [doctor_id]
    // + navigation [Doctor] (same technique as Person.UserId / User)
    [Column("doctor_id")]
    public int DoctorId { get; set; }

    [ForeignKey(nameof(DoctorId))]
    public Doctor Doctor { get; set; } = null!;
}
  • lines 14–17: the hour and minute of the slot’s start and end. The attribute and property are written on the same line: this is allowed and more compact for these four similar columns;
  • Lines 21–25: the “many-to-one” relationship to the doctor: multiple time slots for one doctor. Same technique as in [Person]: the foreign key [DoctorId] (column [doctor_id]) and the navigation key [Doctor], linked by [[ForeignKey]]. EF Core requires no further clarification: a reference on one side, a list on the other—this is a “one-to-many” relationship.

11.8.6. Entities/Appointment.cs

// =========================================================================
// Entities/Appointment.cs
// -------------------------------------------------------------------------
// An appointment ([appointments] table): a client, on a slot, on a given day.
//
// SQL uniqueness constraint: unq1_appointments (day, slot_id) - a slot is only
// booked once per day. Created by the SQL script, recognized by its
// name in the business layer.
// =========================================================================

using System.ComponentModel.DataAnnotations.Schema;

namespace RdvMedecins.Entities;

[Table("appointments")]
public class Appointment : AbstractEntity
{
    // DateOnly: a date WITHOUT time, which corresponds exactly to the SQL type
    // DATE of the [day] column. (The NestJS project used a string
    // "yyyy-MM-dd": JavaScript has no "date without time" type.)
    // Two DateOnly values are compared directly: day < today...
    [Column("day")]
    public DateOnly Day { get; set; }

    [Column("client_id")]
    public int ClientId { get; set; }

    [ForeignKey(nameof(ClientId))]
    public Client Client { get; set; } = null!;

    [Column("slot_id")]
    public int SlotId { get; set; }

    [ForeignKey(nameof(SlotId))]
    public Slot Slot { get; set; } = null!;
}
  • Line 15: the appointment table. Its uniqueness constraint [unq1_appointments (day, slot_id)] ensures that a time slot is reserved only once per day;
  • Lines 22–23: the day of the appointment. The type [DateOnly] (a date without a time) corresponds exactly to the type SQL and [DATE]: no time, so no time zone issue. The [NestJS] version handled a string of type [yyyy-mm-dd], since there was no such type as [JavaScript]. Two [DateOnly] records compare directly with [<], [>=]..., in [C#] as in the [LINQ] queries;
  • Lines 25–35: the customer and the appointment time slot: two “many-to-one” relationships, each defined with a foreign key and a navigation. To create an appointment, simply populate the foreign keys [ClientId] and [SlotId]: this is what the business layer does.

11.8.7. Entities/Role.cs

// =========================================================================
// Entities/Role.cs
// -------------------------------------------------------------------------
// The three roles of a user (users.role column):
//   - ADMIN  : the administrator - appointments + management of doctors and clients;
//   - DOCTOR : a doctor - books and cancels appointments;
//   - USER   : a client (patient) - books for themselves, manages their account.
//
// In C#, an enumeration (enum) rather than a union type of strings
// ('ADMIN' | 'DOCTOR' | 'USER'): the compiler refuses any other value.
// EF Core stores it in the database as TEXT ('ADMIN'...), as the
// SQL script does (see HasConversion<string>() in RdvMedecinsDbContext).
// =========================================================================

namespace RdvMedecins.Entities;

public enum Role
{
    ADMIN,
    DOCTOR,
    USER,
}
  • lines 17–21: the three roles, in the form of an enumeration ([enum]): A variable of type [Role] can only take the values [Role.ADMIN], [Role.DOCTOR], or [Role.USER], and the compiler rejects any other value. This is equivalent to the string union type ['ADMIN' | 'DOCTOR' | 'USER'] from version [NestJS]. By default, EF Core would store a base enumeration as a number (0, 1, 2); the [role] column contains text: the conversion is requested in [DbContext].

11.8.8. Entities/User.cs

// =========================================================================
// Entities/User.cs
// -------------------------------------------------------------------------
// A user allowed to log in to the application ([users] table).
// Each doctor and each client has THEIR own user account ([user]
// column of the [doctors] and [clients] tables, see Person.cs).
//
// SQL uniqueness constraint: unq_users_login (a login belongs to only one
// account). It is created by the database/dbrdvmedecins2.sql script;
// the business layer recognizes it by its NAME (see Business/BusinessService.cs).
// =========================================================================

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace RdvMedecins.Entities;

[Table("users")]
public class User : AbstractEntity
{
    [Column("login")]
    [MaxLength(30)]
    public string Login { get; set; } = "";

    // the HASHED password (bcrypt) - never in clear
    [Column("password")]
    [MaxLength(100)]
    public string Password { get; set; } = "";

    // the name displayed once logged in - for a doctor or a client:
    // "firstName LASTNAME" (e.g. "Marie PELISSIER")
    [Column("name")]
    [MaxLength(40)]
    public string Name { get; set; } = "";

    // ADMIN, DOCTOR or USER, stored in the database as text
    [Column("role")]
    public Role Role { get; set; }
}
  • Line 18: the accounts of users authorized to log in: the administrator, each doctor, and each client. The constraint [unq_users_login] prohibits two accounts with the same login;
  • Lines 21–23: the username;
  • lines 26–28: the hash [bcrypt] of the password, never the password itself (see example 25);
  • lines 32–34: the name displayed in the navigation bar once logged in (“Marie PELISSIER”);
  • lines 37–38: the role, of type [Role]. Since the class [User] does not declare a navigation to the doctor or client that owns it, the relationship is known only on the [Person] side.

11.9. The DbContext

The [DbContext] in the application is based on the one from Example 28, with five tables instead of one, and a few clarifications about the model that the attributes cannot express.

11.9.1. Data/RdvMedecinsDbContext.cs

// =========================================================================
// Data/RdvMedecinsDbContext.cs
// -------------------------------------------------------------------------
// The DbContext is the central piece of Entity Framework Core. It plays
// at the same time the role:
//   - of the TypeORM DataSource: the connection to the database;
//   - of the TypeORM Repository<T>: one DbSet<T> per table, which is
//     queried in LINQ (db.Doctors.Where(...)...);
//   - of a "unit of work": it TRACKS the entities it has
//     loaded (change tracking). When a property is modified, nothing
//     goes to the database; it is SaveChangesAsync() that sends, in one go, the
//     necessary INSERT / UPDATE / DELETE.
//
// Lifetime: one DbContext per HTTP request ("scoped" registration,
// see Program.cs). All the repositories of the same request therefore share
// the SAME DbContext - and consequently the same connection, and the same
// transaction when the business layer opens one.
//
// The class <-> table mapping is described mostly by the attributes
// of the entities ([Table], [Column], [ForeignKey]...); OnModelCreating completes
// what the attributes cannot express.
// =========================================================================

using Microsoft.EntityFrameworkCore;
using RdvMedecins.Entities;

namespace RdvMedecins.Data;

public class RdvMedecinsDbContext(DbContextOptions<RdvMedecinsDbContext> options) : DbContext(options)
{
    // one "table" per entity. Set<T>() returns the DbSet that EF Core has created
    // for the type T.
    public DbSet<Doctor> Doctors => Set<Doctor>();
    public DbSet<Client> Clients => Set<Client>();
    public DbSet<Slot> Slots => Set<Slot>();
    public DbSet<Appointment> Appointments => Set<Appointment>();
    public DbSet<User> Users => Set<User>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // a doctor (a client) has ONE account, and an account belongs to at most
        // ONE person (UNIQUE [user] column in the SQL script): "one to one"
        // relationship, whose foreign key is carried by doctors.user
        // (clients.user). Without this precision, EF Core would assume "several
        // doctors for the same account" - which would work too, but
        // would describe the model poorly.
        modelBuilder.Entity<Doctor>().HasOne(d => d.User).WithOne().HasForeignKey<Doctor>(d => d.UserId);
        modelBuilder.Entity<Client>().HasOne(c => c.User).WithOne().HasForeignKey<Client>(c => c.UserId);

        // the role is a C# enumeration, stored in the database as text
        // ('ADMIN', 'DOCTOR', 'USER') and not as a number (0, 1, 2)
        modelBuilder.Entity<User>().Property(u => u.Role).HasConversion<string>();
    }

    // -----------------------------------------------------------------------
    // The optimistic lock (see AbstractEntity.Version): just before each
    // save, we increment the version of every MODIFIED entity.
    // EF Core compares the OLD value (the one read from the database) in the
    // WHERE clause of the UPDATE, and writes the new one:
    //     UPDATE doctors SET last_name = ..., version = 4 WHERE id = 1 AND version = 3
    //
    // ChangeTracker: the "change tracking" of the DbContext. It knows, for
    // each loaded entity, whether it has been added (Added), modified
    // (Modified), deleted (Deleted) or left as is (Unchanged).
    //
    // All the variants of SaveChanges(Async) end up in this method.
    // -----------------------------------------------------------------------
    public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
    {
        foreach (var entry in ChangeTracker.Entries<AbstractEntity>())
        {
            if (entry.State == EntityState.Modified)
            {
                entry.Entity.Version++;
            }
        }
        return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
    }
}

Let’s comment on this code:

  • line 29: As in Example 28, the [DbContext] receives its options ([MySQL] provider, connection string) from [AddDbContext], within [Program.cs]. It is registered with the [Scoped] lifetime: one [DbContext] per request, shared by all repositories for that request, meaning a single connection and, if applicable, a single transaction. It thus fulfills the roles of both the [DataSource] and the [Repository<T>] for [TypeORM];
  • Lines 33–37: one [DbSet] per table. There are none for either [AbstractEntity] or [Person]: these abstract classes have no tables. EF Core does not include them in the model as entities; it simply adds their properties to those of the child classes;
  • Line 39: [OnModelCreating] is called once, when EF Core constructs its model (the description of tables and relationships). It supplements the entity attributes with a fluid API: a chain of method calls;
  • Lines 47–48: The relationship between a doctor (a customer) and their account is “one-to-one”: [HasOne(d => d.User)] (a doctor has one account), [WithOne()] (an account has at most one doctor; the empty parentheses indicate that [User] has no reverse lookup), [HasForeignKey<Doctor>(d => d.UserId)] (the foreign key is in table [doctors]). Without these rows, EF Core would assume a “many-to-one” relationship (multiple doctors for a single account), which would also work but would inaccurately describe the model: in the database, the [user] column is unique;
  • line 52: [HasConversion<string>()] stores the role in the database as text (['ADMIN']...), and converts it back to [Role] upon reading;
  • lines 68–77: the version number of each modified entity is incremented just before saving, as in Example 28. [ChangeTracker.Entries<AbstractEntity>()] iterates through all tracked entities whose class derives from [AbstractEntity]: thanks to the common parent class, this single loop applies to all five tables. The variant of [SaveChangesAsync] is redefined with two parameters, because it is this variant that ultimately calls [SaveChangesAsync()] without parameters. EF Core compares the old version in the [WHERE] clause of [UPDATE] and writes the new one: if the line has changed in the meantime, no lines are modified, and EF Core raises a [DbUpdateConcurrencyException].

11.10. The domain: the calendar

The [Domain] folder contains two classes that do not correspond to any tables: they describe a doctor’s schedule for a single day, calculated by the business layer ([BusinessService.GetDoctorDayScheduleAsync]) and displayed by the schedule controller.

11.10.1. Domain/DoctorDaySlot.cs

// =========================================================================
// Domain/DoctorDaySlot.cs
// -------------------------------------------------------------------------
// A "business" object (without a table) that associates a time slot with the
// appointment possibly booked on this slot, on a given day.
//
// A C# "record" is a class whose properties are set once
// and for all by the constructor (immutable object): exactly what is needed
// for a computed object, that nobody modifies afterwards.
// =========================================================================

using RdvMedecins.Entities;

namespace RdvMedecins.Domain;

// Appointment? : null if the slot is free that day
public record DoctorDaySlot(Slot Slot, Appointment? Appointment);
  • Line 17: a time slot, and the appointment scheduled for that time slot on that day, [null] if the time slot is available (hence the type [Appointment?]). A [record] written this way, with its parameters in parentheses (positional record), declares in a single line a class, its constructor, and its properties [Slot] and [Appointment], which cannot be modified after construction: This is equivalent to the [constructor(public slot: Slot, ...)] syntax in the [NestJS] version. We have already encountered the records for data passed to the business layer (see Example 28).

11.10.2. Domain/DoctorDaySchedule.cs

// =========================================================================
// Domain/DoctorDaySchedule.cs
// -------------------------------------------------------------------------
// The complete schedule of a doctor for a given day: the list of their
// time slots, each one free or taken by an appointment. Computed by
// BusinessService.GetDoctorDayScheduleAsync, turned into an HTML table by
// ScheduleController.
// =========================================================================

using RdvMedecins.Entities;

namespace RdvMedecins.Domain;

public record DoctorDaySchedule(Doctor Doctor, DateOnly Day, List<DoctorDaySlot> DoctorDaySlots);

The schedule (line 14): a doctor, a day, and the list of their available or booked time slots, in chronological order.

11.11. The DAO layer

One class per table, as in Example 28: short methods, without business logic, that simply query or modify the database. Here, they are called “repositories,” as in the version [NestJS]. Two differences from that version:

  • queries are written in [LINQ], which EF Core translates into SQL: you navigate from one entity to another using navigation properties, and EF Core writes the joins itself;
  • The methods have no [manager] parameter for transactions: all repositories in a query share the same [DbContext], and a transaction opened on this [DbContext] by the business layer automatically encompasses all their queries.

All methods are asynchronous and return a [Task]: the caller waits for it with [await]. Many are written in the form [=> expression]: they directly return the [Task] for the request, without [async] or [await], since they have nothing else to do afterward.

11.11.1. Repositories/PersonRepository.cs

In version [NestJS], the repositories for doctors and clients were identical, except for a single line. In [C#], a generic class writes them once.

// =========================================================================
// Repositories/PersonRepository.cs
// -------------------------------------------------------------------------
// The DAO layer common to the doctors and the clients.
//
// The two DAOs of the NestJS project (DoctorRepository, ClientRepository)
// were identical but for one line. In C#, a GENERIC class writes them
// only once: PersonRepository<T>, where T is Doctor or Client.
// "where T : Person" guarantees that T has LastName, FirstName, User...
//
// Transactions: the methods no longer need the optional EntityManager parameter
// of TypeORM. All the repositories of a request share the
// SAME DbContext (see Data/RdvMedecinsDbContext.cs): when the business layer
// opens a transaction on this DbContext, all their queries are
// automatically part of it.
// =========================================================================

using Microsoft.EntityFrameworkCore;
using RdvMedecins.Data;
using RdvMedecins.Entities;

namespace RdvMedecins.Repositories;

public abstract class PersonRepository<T>(RdvMedecinsDbContext db) where T : Person
{
    protected readonly RdvMedecinsDbContext db = db;

    // the "table" of the persons of type T (db.Doctors or db.Clients)
    protected DbSet<T> Table => db.Set<T>();

    // list sorted by last name then first name, user account included.
    // Include(p => p.User): ALSO brings back the account, through a join
    // (SELECT ... FROM doctors INNER JOIN users ...). Without it, p.User
    // would be null.
    // AsNoTracking(): these lists are kept in memory by the cache
    // (Web/ApplicationModel.cs), well beyond the HTTP request: it is useless
    // (and costly) for the DbContext to track them.
    public Task<List<T>> FindAllAsync() =>
        Table.AsNoTracking().Include(p => p.User).OrderBy(p => p.LastName).ThenBy(p => p.FirstName).ToListAsync();

    public Task<T?> FindByIdAsync(int id) =>
        Table.Include(p => p.User).FirstOrDefaultAsync(p => p.Id == id);

    // the person whose user account is [userId], or null
    public Task<T?> FindByUserIdAsync(int userId) =>
        Table.Include(p => p.User).FirstOrDefaultAsync(p => p.UserId == userId);

    // the person who has these first and last names, if there is one. The comparison
    // is made by MySQL, hence with the collation of the database
    // (utf8mb4_unicode_ci: regardless of case and accents) -
    // exactly the same rule as that of the SQL uniqueness constraint.
    public Task<T?> FindByFirstNameLastNameAsync(string firstName, string lastName) =>
        Table.FirstOrDefaultAsync(p => p.FirstName == firstName && p.LastName == lastName);

    // creation (Id = 0: not yet in the database) or update (entity already
    // tracked by the DbContext: SaveChanges detects by itself what has changed)
    public async Task<T> SaveAsync(T person)
    {
        if (person.Id == 0)
        {
            Table.Add(person);
        }
        await db.SaveChangesAsync();
        return person;
    }

    // ExecuteDeleteAsync (EF Core 7+): a DELETE sent directly to the database,
    // without loading the entities first:  DELETE FROM doctors WHERE id = ?
    public Task DeleteByIdAsync(int id) => Table.Where(p => p.Id == id).ExecuteDeleteAsync();
}

// the two concrete DAOs: everything is inherited
public class DoctorRepository(RdvMedecinsDbContext db) : PersonRepository<Doctor>(db);

public class ClientRepository(RdvMedecinsDbContext db) : PersonRepository<Client>(db)
{
    // -----------------------------------------------------------------------
    // reads a client while putting a LOCK on its row until the end of the
    // current transaction (SELECT ... FOR UPDATE). A second transaction
    // that requests the same lock WAITS for the first one to finish.
    // Used for booking appointments (see BusinessService.AddAppointmentAsync):
    // two simultaneous bookings for the same patient are thus processed
    // one AFTER the other, and the second one sees the appointment created by the first.
    //
    // LINQ cannot express "FOR UPDATE": we write the SQL query
    // ourselves. FromSqlInterpolated turns {id} into an SQL PARAMETER (no
    // string concatenation, hence no possible SQL injection).
    // -----------------------------------------------------------------------
    public async Task<Client?> LockAsync(int id)
    {
        var clients = await Table.FromSqlInterpolated($"SELECT * FROM clients WHERE id = {id} FOR UPDATE").ToListAsync();
        return clients.FirstOrDefault();
    }
}

Let’s comment on this code:

  • Line 24: [PersonRepository<T>] is a generic class; [T] is a type parameter, replaced by an actual type ([Doctor] or [Client]) when the class is used, such as [T] in [List<T>]. The constraint [where T : Person] only accepts [Person] or its child classes for [T]: the compiler then knows that a [T] contains [Id], [LastName], [FirstName], [UserId], [User]... and accepts [p.LastName] in queries. The class is abstract: only its two child classes, listed below, are used;
  • line 26: the [db] parameter of the main constructor is visible only within this class. It is copied to a [protected] field so that the child classes ([ClientRepository]) can access it;
  • line 29: the table of persons of type [T]. [db.Set<T>()] maps [DbSet] to this type: [db.Doctors] for [T = Doctor], [db.Clients] for [T = Client];
  • lines 38–39: all individuals, sorted by last name then first name ([OrderBy], then [ThenBy] to break ties for identical last names). [Include(p => p.User)] also loads each person’s account via a join ([INNER JOIN users]): without it, [p.User] would be equivalent to [null]. The account is used to display the login in the lists. [AsNoTracking()] (see Example 28): these lists are kept in memory by [ApplicationModel] long after the query has ended; there is no need for [DbContext] to process them. Expressions such as [p => p.LastName] are not executed in [C#]: EF Core parses them to translate them into SQL ([ORDER BY last_name, first_name]);
  • lines 41–42: a person and their account, or [null]. Here, there is no [AsNoTracking()]: the business layer modifies the retrieved person (and their account), then calls [SaveAsync]; the [DbContext], which tracks both objects, knows what has changed;
  • lines 45–46: the person whose account is [userId]. This is how a logged-in patient, whose token contains the account ID, retrieves their customer record. The filter targets the foreign key itself ([WHERE clients.user = ...]); the join with [users] serves only to load the account ([Include]);
  • lines 52–53: a possible homonym. The operator [==] is converted to [=] SQL: the comparison is performed using [MySQL], according to the database’s collation, without regard to uppercase letters or accents, exactly as in the uniqueness constraint (see Example 28). A comparison using [C#] would take uppercase letters into account;
  • Lines 57–64: Creation or modification, as in Example 28: A new person ([Id] set to 0) is added to [DbSet]; a person already in the system requires no action. Note: [SaveChangesAsync] logs all pending changes for [DbContext], not just those from [person]: the account modified at the same time is included in the same call;
  • line 69: deletion via [ExecuteDeleteAsync] (see example 28): a [DELETE] sent directly to the database, without prior reading. It does not go through change tracking, nor therefore through the optimistic lock;
  • line 73: the doctors’ repository: [PersonRepository<Doctor>], with nothing else. It forwards the received [DbContext] to the constructor of its parent class; the final semicolon replaces an empty class body;
  • line 75: the client repository, with an additional method;
  • lines 89–92: reading a client with a lock: [SELECT ... FOR UPDATE] places a lock on the line until the end of the current transaction; another transaction requesting the same lock waits until the first one is complete. The appointment scheduling method ([BusinessService.AddAppointmentAsync]) uses this to process two simultaneous reservations for the same patient one after the other: the second reservation sees the appointment created by the first and thus complies with the “only one upcoming appointment” rule. [LINQ] cannot express [FOR UPDATE]: you must write the query SQL yourself using [FromSqlInterpolated]. The string [$"..."] is an interpolated string; [FromSqlInterpolated] does not copy it as-is: each value enclosed in curly braces ([{id}]) becomes a parameter SQL. Therefore, there is no string concatenation, and no SQL injection is possible. The query is executed as-is ([ToListAsync]), and the first client in the resulting list ([FirstOrDefault], in memory): an operator [LINQ] added after [FromSqlInterpolated] would cause EF Core to wrap our SQL in a subquery.

11.11.2. Repositories/SlotRepository.cs

// =========================================================================
// Repositories/SlotRepository.cs
// -------------------------------------------------------------------------
// DAO layer of the [Slot] entity.
// =========================================================================

using Microsoft.EntityFrameworkCore;
using RdvMedecins.Data;
using RdvMedecins.Entities;

namespace RdvMedecins.Repositories;

public class SlotRepository(RdvMedecinsDbContext db)
{
    public Task<Slot?> FindByIdAsync(int id) =>
        db.Slots.Include(s => s.Doctor).FirstOrDefaultAsync(s => s.Id == id);

    // the slots of a doctor, in chronological order
    public Task<List<Slot>> GetAllSlotsAsync(int doctorId) =>
        db.Slots
            .Where(s => s.DoctorId == doctorId)
            .OrderBy(s => s.StartHour)
            .ThenBy(s => s.StartMinute)
            .ToListAsync();

    // deletes all the slots of a doctor (deletion of a doctor):
    //   DELETE FROM slots WHERE doctor_id = ?
    public Task DeleteByDoctorAsync(int doctorId) =>
        db.Slots.Where(s => s.DoctorId == doctorId).ExecuteDeleteAsync();
}
  • lines 15–16: an appointment slot, with its doctor ([Include]);
  • lines 19–24: a doctor’s time slots, in chronological order: [Where] filters, [OrderBy] and [ThenBy] sort (by hour, then by start minute). The query is built method by method, as with QueryBuilder from [TypeORM], and is executed only when [ToListAsync] is called: [SELECT ... FROM slots WHERE doctor_id = @p ORDER BY start_hour, start_minute]. The value [doctorId] is also passed as a parameter to SQL;
  • Lines 28–29: Deleting a doctor’s appointment slots using a single [DELETE]. The business layer calls it before deleting the doctor; otherwise, the foreign key [slots.doctor_id] would prevent the deletion.

11.11.3. Repositories/AppointmentRepository.cs

// =========================================================================
// Repositories/AppointmentRepository.cs
// -------------------------------------------------------------------------
// DAO layer of the [Appointment] entity.
//
// The queries are written in LINQ: EF Core translates them into SQL. We navigate
// from one entity to another through the navigation properties (a.Slot.DoctorId),
// and EF Core writes the necessary joins by itself.
// =========================================================================

using Microsoft.EntityFrameworkCore;
using RdvMedecins.Data;
using RdvMedecins.Entities;

namespace RdvMedecins.Repositories;

public class AppointmentRepository(RdvMedecinsDbContext db)
{
    public Task<Appointment?> FindByIdAsync(int id) =>
        db.Appointments.Include(a => a.Client).Include(a => a.Slot).FirstOrDefaultAsync(a => a.Id == id);

    public async Task<Appointment> SaveAsync(Appointment appointment)
    {
        if (appointment.Id == 0)
        {
            db.Appointments.Add(appointment);
        }
        await db.SaveChangesAsync();
        return appointment;
    }

    public Task DeleteByIdAsync(int id) => db.Appointments.Where(a => a.Id == id).ExecuteDeleteAsync();

    // the appointments of a doctor on a given day, client and slot included.
    // a.Slot.DoctorId: the [appointments] table does not reference the
    // doctor directly; EF Core goes through the join with [slots].
    public Task<List<Appointment>> GetDoctorDayAppointmentsAsync(int doctorId, DateOnly day) =>
        db.Appointments
            .Include(a => a.Client)
            .Include(a => a.Slot)
            .Where(a => a.Slot.DoctorId == doctorId && a.Day == day)
            .ToListAsync();

    // the appointments of a client from day [fromDay] (included), in
    // chronological order, with the slot AND the doctor of this slot
    // (Include then ThenInclude: appointment -> slot -> doctor)
    public Task<List<Appointment>> FindUpcomingByClientAsync(int clientId, DateOnly fromDay) =>
        db.Appointments
            .Include(a => a.Slot).ThenInclude(s => s.Doctor)
            .Where(a => a.ClientId == clientId && a.Day >= fromDay)
            .OrderBy(a => a.Day)
            .ThenBy(a => a.Slot.StartHour)
            .ThenBy(a => a.Slot.StartMinute)
            .ToListAsync();

    // number of appointments of a client from day [fromDay] (included)
    public Task<int> CountUpcomingByClientAsync(int clientId, DateOnly fromDay) =>
        db.Appointments.CountAsync(a => a.ClientId == clientId && a.Day >= fromDay);

    // deletes ALL the appointments (past and future) of a client:
    //   DELETE FROM appointments WHERE client_id = ?
    public Task DeleteByClientAsync(int clientId) =>
        db.Appointments.Where(a => a.ClientId == clientId).ExecuteDeleteAsync();

    // number of appointments (past or future) of a doctor:
    //   SELECT COUNT(*) FROM appointments INNER JOIN slots s ON ... WHERE s.doctor_id = ?
    public Task<int> CountByDoctorAsync(int doctorId) =>
        db.Appointments.CountAsync(a => a.Slot.DoctorId == doctorId);
}

Let’s comment on this code:

  • lines 19–20: an appointment with a client and its time slot: two [Include] records, so two joins;
  • lines 22–29: same pattern as for people. In practice, it is used only for creation: the business layer generates an [Appointment] in which only the foreign keys are populated; after [SaveChangesAsync], [appointment.Id] contains the identifier assigned by the database;
  • line 32: canceling an appointment;
  • Lines 37–42: A doctor’s appointments for a given day, each with its own patient and time slot (the equivalent of [leftJoinAndSelect] in the [NestJS] version). The [appointments] table does not directly reference doctors: the [a.Slot.DoctorId == doctorId] filter goes through the [Slot] navigation, and EF Core adds the join with [slots]. It is important to distinguish between the two uses of a navigation: in [Where], it is used for filtering; only [Include] loads the object into the result;
  • lines 47–54: a client’s appointments starting from the day [fromDay], along with their time slot and the doctor for that slot: [Include(a => a.Slot)] loads the time slot, then [ThenInclude(s => s.Doctor)] loads the doctor for that time slot (two cascading joins). The sort is based first on the day, then on the time of the time slot. This is what is displayed in “My area”;
  • lines 57–58: the number of these appointments ([SELECT COUNT(*)]), for the rule “only one upcoming appointment.” [CountAsync] directly satisfies the condition, as does [FirstOrDefaultAsync];
  • lines 62–63: the deletion of all of a client’s appointments, past and future: the business layer calls this before deleting the client, which the foreign key [appointments.client_id] would otherwise protect;
  • lines 67–68: the number of appointments for a doctor, to prevent the deletion of a doctor who has any. Once again, the [a.Slot] navigation generates a join ([INNER JOIN slots]) without loading anything: this is the [innerJoin] from the [NestJS] version.

11.11.4. Repositories/UserRepository.cs

// =========================================================================
// Repositories/UserRepository.cs
// -------------------------------------------------------------------------
// DAO layer of the [User] entity. Creating (or deleting) a doctor or a
// client also creates (or deletes) their account - see Business/BusinessService.cs.
// =========================================================================

using Microsoft.EntityFrameworkCore;
using RdvMedecins.Data;
using RdvMedecins.Entities;

namespace RdvMedecins.Repositories;

public class UserRepository(RdvMedecinsDbContext db)
{
    // null if no user has this login
    public Task<User?> FindByLoginAsync(string login) => db.Users.FirstOrDefaultAsync(u => u.Login == login);

    public async Task<User> SaveAsync(User user)
    {
        if (user.Id == 0)
        {
            db.Users.Add(user);
        }
        await db.SaveChangesAsync();
        return user;
    }

    public Task DeleteByIdAsync(int id) => db.Users.Where(u => u.Id == id).ExecuteDeleteAsync();
}
  • line 17: the account associated with this login, or [null]. It is used for logging in ([AuthService]) and to verify that a login is available before assigning it;
  • Lines 19–26: Creating or modifying an account. To create a doctor or a client, you must first create their account: the person needs the account ID for their foreign key;
  • line 29: Deleting an account. The order is the reverse of the creation process: the business layer first deletes the person, who references the account, and then deletes the account.

11.12. The Business Layer

The business layer contains the application’s business rules: no appointments in the past, only one upcoming appointment per patient, unique usernames and individuals, and the deletion of a doctor or a client. It relies on the DAO layer (folder [Repositories]) for reading and writing, and knows nothing about HTML or the interface language. More specifically, this is the [PersonsBusiness] layer from Example 28.

11.12.1. Business/Dates.cs

// =========================================================================
// Business/Dates.cs
// -------------------------------------------------------------------------
// Today's date. It is used by BUSINESS RULES (no booking
// in the past, a single UPCOMING appointment per patient): it therefore lives
// in the business layer. The web layer reuses it.
// =========================================================================

namespace RdvMedecins.Business;

public static class Dates
{
    // today's date, in the time zone of the server
    public static DateOnly Today() => DateOnly.FromDateTime(DateTime.Now);
}
  • line 11: a [static] class is not instantiated; it simply groups methods, which are called by the class name ([Dates.Today()]);
  • line 14: today’s date, in the server’s time zone. [DateTime.Now] returns both the date and time; [DateOnly.FromDateTime] returns only the date (the type [DateOnly] was covered in Example 12; this is the format of the [appointments.day] column). Two [DateOnly] values can be compared directly with [<] or [>=]; the [NestJS] version, which processed [yyyy-mm-dd] strings, had to generate this format itself. The current date is used for business rules, so it resides in the business layer; the web layer reuses it (for example, in [ScheduleController], to determine whether a day is still bookable).

11.12.2. Business/BusinessException.cs

// =========================================================================
// Business/BusinessException.cs
// -------------------------------------------------------------------------
// The exception thrown by the business layer when a BUSINESS RULE is
// violated (login already taken, doctor who still has appointments, slot
// already booked...). It does not carry a text, but a translation
// KEY (e.g. "ERRORS.LOGIN_TAKEN"): it is the web layer that will
// translate it into French or English when displaying the page
// (see i18n/fr.json, i18n/en.json).
//
// A BusinessException is not an anomaly: it is a normal answer
// ("this login is already taken"), displayed to the user. Any other
// exception is a real anomaly, which ends up on the 500 error page.
// =========================================================================

namespace RdvMedecins.Business;

public class BusinessException(string key, IReadOnlyDictionary<string, string>? messageParams = null) : Exception(key)
{
    // translation key of the message
    public string Key { get; } = key;

    // possible parameters of the message ({minutes}...)
    public IReadOnlyDictionary<string, string> Params { get; } = messageParams ?? new Dictionary<string, string>();
}
  • Line 18: The exception thrown when a business rule rejects a transaction (see Example 28). The class uses a primary constructor (the parameters [key] and [messageParams] are listed after the class name); [: Exception(key)] passes the key to the constructor of the parent class [Exception], which sets it as the property [Message] (useful in error logs). Difference from Example 28: The exception does not carry text in English, but rather a translation key ([ERRORS.LOGIN_TAKEN]). The business layer thus ignores the interface language: it is the web layer that will translate the key, using the dictionaries [i18n/fr.json] and [i18n/en.json] (see Chapter 5);
  • line 21: the translation key, read-only ([get] without [set]);
  • line 24: any message parameters, which will replace markers such as [{minutes}] in the translated text. [IReadOnlyDictionary] is a read-only dictionary. The [??] operator returns an empty dictionary when no parameters are provided: the web layer never needs to check for [null]. The exceptions thrown by [BusinessService] actually only use keys without parameters.

A [BusinessException] is not an error: it is a normal response (“this login is already taken”), which controllers intercept to display to the user, either on the page or in a pop-up message. Any other exception is a true error, which results in a 500 error page.

11.12.3. Business/IBusiness.cs

// =========================================================================
// Business/IBusiness.cs
// -------------------------------------------------------------------------
// The contract of the business layer: reads, schedule, appointments, management
// of the doctors and the clients.
//
// .NET convention: an asynchronous method returns a Task<T> (the equivalent
// of a Promise<T>) and its name ends with "Async".
// =========================================================================

using RdvMedecins.Domain;
using RdvMedecins.Entities;

namespace RdvMedecins.Business;

// the data typed in the "doctor" or "client" form
public record PersonData
{
    public required string Title { get; init; } // Mr, Mme, Melle
    public required string LastName { get; init; }
    public required string FirstName { get; init; }

    // creation: optional, defaults to the last name in lower case
    // update: optional, the login is unchanged if it is absent
    public string? Login { get; init; }

    // creation: optional, defaults to the login
    // update: optional, the password is unchanged if it is absent
    public string? Password { get; init; }

    // update: the version of the record at the time the form was
    // displayed (optimistic lock, see AbstractEntity.Version)
    public int? Version { get; init; }
}

public interface IBusiness
{
    // ------------------------------------------------ reads
    Task<List<Client>> GetAllClientsAsync();
    Task<List<Doctor>> GetAllDoctorsAsync();
    Task<List<Slot>> GetAllSlotsAsync(int doctorId);
    Task<List<Appointment>> GetDoctorDayAppointmentsAsync(int doctorId, DateOnly day);
    Task<Client?> GetClientByIdAsync(int id);
    Task<Doctor?> GetDoctorByIdAsync(int id);
    Task<Appointment?> GetAppointmentByIdAsync(int id);
    Task<Slot?> GetSlotByIdAsync(int id);
    Task<DoctorDaySchedule?> GetDoctorDayScheduleAsync(int doctorId, DateOnly day);

    /// <summary>the client (patient) whose user account is [userId], or null</summary>
    Task<Client?> GetClientByUserIdAsync(int userId);

    /// <summary>the appointments of a client, of today or in the future, with slot and doctor</summary>
    Task<List<Appointment>> GetClientUpcomingAppointmentsAsync(int clientId);

    // ------------------------------------------------ appointments (ADMIN, DOCTOR, USER)
    /// <summary>
    /// throws a BusinessException if:
    ///  - the day is in the past ("ERRORS.PAST_DATE");
    ///  - the patient already has an upcoming appointment ("ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT");
    ///  - the slot is already booked that day ("ERRORS.SLOT_ALREADY_BOOKED").
    /// </summary>
    Task<Appointment> AddAppointmentAsync(DateOnly day, Slot slot, Client client);

    Task DeleteAppointmentAsync(Appointment appointment);

    // ------------------------------------------------ doctors (ADMIN)
    /// <summary>creates the doctor AND their user account (DOCTOR role)</summary>
    Task<Doctor> AddDoctorAsync(PersonData data);

    /// <summary>updates the doctor AND their user account</summary>
    Task<Doctor> EditDoctorAsync(int id, PersonData data);

    /// <summary>deletes the doctor, their slots AND their account - refused if they have appointments</summary>
    Task DeleteDoctorAsync(int id);

    // ------------------------------------------------ clients
    // (the ADMIN; AddClientAsync is also used for the registration of a patient,
    // EditClientAsync and DeleteClientAsync by the patient for their own account)
    /// <summary>creates the client AND their user account (USER role)</summary>
    Task<Client> AddClientAsync(PersonData data);

    Task<Client> EditClientAsync(int id, PersonData data);

    /// <summary>
    /// deletes the client, their account AND all their appointments (past and future).
    /// Used by the ADMIN ("Patients" page) and by the patient themselves ("Manage my account").
    /// </summary>
    Task DeleteClientAsync(int id);
}
  • Lines 17–33: Data for a doctor or client to be created or modified, as transmitted by the web layer (see Example 28). [required] requires the title, last name, and first name; [init] prevents these from being modified later (see Example 18). Username and password are optional ([string?]): upon creation, they default to the name in lowercase; upon modification, they change only if they have been entered. [Version] is the version of the record read when the edit form is displayed: it is used for optimistic locking (see below);
  • line 36: the business layer’s contract: what other layers can request from it, without specifying how it is done (interfaces were discussed in Example 06). By convention, [.NET], an asynchronous method returns a [Task<T>] (the equivalent of a [Promise<T>] of [JavaScript]) and its name ends with [Async];
  • lines 39–53: the readings. A type followed by [?] ([Task<Client?>]) indicates that the method can return [null]: no one has this identifier. The comments for [/// <summary>] are documentation comments: [Visual Studio Code] displays them when you hover over the method;
  • lines 57–64: appointments. The comment for [AddAppointmentAsync] lists the possible error codes: this is part of the contract that testers must be familiar with;
  • lines 68–74: doctor management (reserved for ADMIN). Each doctor is created, modified, and deleted along with their user account;
  • lines 80–88: client management, following the same logic. [AddClientAsync] is also used to register a patient, while [EditClientAsync] and [DeleteClientAsync] are for patients who manage their own accounts.

The implementation is recorded in [Program.cs] via line [builder.Services.AddScoped<IBusiness, BusinessService>()]: “When a constructor requests a [IBusiness], provide them with a [BusinessService],” one per request ([Scoped]), such as [DbContext] and the DAO entries it uses (see Example 28 and Chapter 6). With [NestJS], a [TypeScript] interface disappeared at compile time: a token ([BUSINESS_TOKEN]) and [@Inject(BUSINESS_TOKEN)] were required. In [C#], the interfaces exist at runtime: the interface itself serves as the token. The only service that receives a [IBusiness] is [ApplicationModel] (folder [Web]), which also implements [IBusiness]: the controllers therefore see the same contract, enriched with a cache of lists of doctors and clients.

11.12.4. Business/BusinessService.cs

This is the most comprehensive file in the application. We’ll show it in five successive excerpts.

11.12.4.1. The descriptors

// =========================================================================
// Business/BusinessService.cs
// -------------------------------------------------------------------------
// Implementation of the business layer.
//
// The management of the doctors and clients obeys three business rules:
//
//   1. a person (doctor or client) and their user account go
//      ALWAYS together: they are created, updated and deleted together, in
//      the same TRANSACTION - either everything succeeds, or nothing is written;
//   2. the login is unique in the whole application, and two doctors (or
//      two clients) cannot have the same first and last names;
//   3. deletion:
//      - a DOCTOR who has appointments cannot be deleted (their
//        patients would lose their appointment without being warned): they must
//        first be cancelled. Their time slots disappear with them;
//      - a CLIENT, on the other hand, disappears with ALL their appointments (past and
//        future) - whether the deletion comes from the ADMIN or from the patient
//        themselves.
//
// And two rules for booking appointments, whatever the role of
// the one who books (ADMIN, DOCTOR or the patient themselves):
//   4. no appointment in the past;
//   5. a patient has only ONE upcoming appointment at a time.
//
// Doctors and clients follow exactly the same rules: the common code
// is written ONCE (generic private methods XxxPersonAsync<T>), and
// parameterized by a small "descriptor" that says which type of person we are
// dealing with.
// =========================================================================

using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using MySqlConnector;
using RdvMedecins.Data;
using RdvMedecins.Domain;
using RdvMedecins.Entities;
using RdvMedecins.Repositories;

namespace RdvMedecins.Business;

public class BusinessService : IBusiness
{
    // number of bcrypt salting rounds - the same as in the SQL script ($2b$10$...)
    private const int BcryptRounds = 10;

    // everything that distinguishes a doctor from a client for the business rules.
    // "where T : Person, new()": T is a Person, and we can write new T().
    private record PersonDescriptor<T>(
        PersonRepository<T> Dao,
        Role Role, // DOCTOR or USER
        string NotFoundKey, // message if the person does not exist
        string AlreadyExistsKey, // message if the same first and last names already exist
        // optional: if present, the deletion is REFUSED as long as the
        // person has appointments (CountAppointments > 0), with the message HasAppointmentsKey
        Func<int, Task<int>>? CountAppointments = null,
        string? HasAppointmentsKey = null,
        // optional: what must be deleted BEFORE the person (the rows that
        // reference it through a foreign key)
        Func<int, Task>? BeforeDelete = null) where T : Person, new();

    private readonly RdvMedecinsDbContext db;
    private readonly DoctorRepository doctorRepository;
    private readonly ClientRepository clientRepository;
    private readonly SlotRepository slotRepository;
    private readonly AppointmentRepository appointmentRepository;
    private readonly UserRepository userRepository;
    private readonly PersonDescriptor<Doctor> doctorDescriptor;
    private readonly PersonDescriptor<Client> clientDescriptor;

    // all the dependencies are provided by the dependency injection
    // of ASP.NET Core (see Program.cs). The DbContext is used here ONLY to open
    // transactions - the queries themselves remain in the DAO layer.
    public BusinessService(
        RdvMedecinsDbContext db,
        DoctorRepository doctorRepository,
        ClientRepository clientRepository,
        SlotRepository slotRepository,
        AppointmentRepository appointmentRepository,
        UserRepository userRepository)
    {
        this.db = db;
        this.doctorRepository = doctorRepository;
        this.clientRepository = clientRepository;
        this.slotRepository = slotRepository;
        this.appointmentRepository = appointmentRepository;
        this.userRepository = userRepository;

        doctorDescriptor = new PersonDescriptor<Doctor>(
            doctorRepository,
            Role.DOCTOR,
            "ERRORS.DOCTOR_NOT_FOUND",
            "ERRORS.DOCTOR_ALREADY_EXISTS",
            CountAppointments: appointmentRepository.CountByDoctorAsync,
            HasAppointmentsKey: "ERRORS.DOCTOR_HAS_APPOINTMENTS",
            // a doctor without appointments may still have time slots:
            // they no longer make sense without them, we delete them with the doctor (otherwise
            // the foreign key slots.doctor_id would forbid the deletion)
            BeforeDelete: slotRepository.DeleteByDoctorAsync);

        clientDescriptor = new PersonDescriptor<Client>(
            clientRepository,
            Role.USER,
            "ERRORS.PATIENT_NOT_FOUND",
            "ERRORS.PATIENT_ALREADY_EXISTS",
            // no CountAppointments: the deletion of a client is never refused;
            // their appointments (past and future) are deleted with them (otherwise
            // the foreign key appointments.client_id would forbid the deletion)
            BeforeDelete: appointmentRepository.DeleteByClientAsync);
    }
  • lines 6–29: The header comment summarizes the five business rules applied by the class;
  • line 34: the [MySQL] driver (see Chapter 9): the class uses its [MySqlException] exception to detect violations of uniqueness constraints;
  • line 42: the class implements the interface; it must provide all of its methods;
  • line 45: the cost of [bcrypt] (see Example 25), which is the same as that of the passwords in the SQL script (prefix [$2b$10$]);
  • lines 49–60: everything that distinguishes a doctor from a client in terms of business rules: their DAO, their account role ([DOCTOR] or [USER]), their error messages, and what happens when they are deleted. Since doctors and clients follow the same rules, the code for these rules is written only once, in generic methods (parameterized by a [T] type) that accept a descriptor. The constraint [where T : Person, new()] requires that [T] inherit from [Person] (we could read [LastName], [FirstName], [User]...) and have a parameterless constructor (we could write [new T()]). [Func<int, Task<int>>] is the type of a function that takes a [int] and returns a [Task<int>]; [Func<int, Task>] is the type of an asynchronous function that returns nothing. These parameters have a default value ([= null]): they are optional;
  • lines 62–69: the class fields: the [DbContext], the five DAO, and the two descriptors. [readonly] prevents them from being modified after the constructor;
  • lines 74–87: all dependencies are provided by dependency injection. The [DbContext] is used here only to open transactions: the requests remain in the DAO layer. As in Example 28, DAO and the business layer receive the same [DbContext] during a query, and thus the same connection and the same transaction;
  • Lines 89–99: the doctor descriptor. The named arguments ([CountAppointments:], [HasAppointmentsKey:]...) refer to a parameter by name, which allows optional parameters to be skipped (the client descriptor, further down, thus omits [CountAppointments] and [HasAppointmentsKey]). [appointmentRepository.CountByDoctorAsync] is not called here: the method itself is passed, and it will be called later (a method can thus serve as a value of type [Func]). Deleting a doctor will be denied as long as they have appointments; their time slots, however, will be deleted before the doctor is deleted;
  • Lines 101–109: The client descriptor. No [CountAppointments]: Deleting a client is never denied, and its appointments (past and future) are deleted before the client itself; otherwise, the foreign key [appointments.client_id] would prevent the deletion.

11.12.4.2. Read operations and the calendar

    // =======================================================================
    // reads
    // =======================================================================

    public Task<List<Client>> GetAllClientsAsync() => clientRepository.FindAllAsync();

    public Task<List<Doctor>> GetAllDoctorsAsync() => doctorRepository.FindAllAsync();

    public Task<List<Slot>> GetAllSlotsAsync(int doctorId) => slotRepository.GetAllSlotsAsync(doctorId);

    public Task<List<Appointment>> GetDoctorDayAppointmentsAsync(int doctorId, DateOnly day) =>
        appointmentRepository.GetDoctorDayAppointmentsAsync(doctorId, day);

    public Task<Client?> GetClientByIdAsync(int id) => clientRepository.FindByIdAsync(id);

    public Task<Doctor?> GetDoctorByIdAsync(int id) => doctorRepository.FindByIdAsync(id);

    public Task<Appointment?> GetAppointmentByIdAsync(int id) => appointmentRepository.FindByIdAsync(id);

    public Task<Slot?> GetSlotByIdAsync(int id) => slotRepository.FindByIdAsync(id);

    public Task<Client?> GetClientByUserIdAsync(int userId) => clientRepository.FindByUserIdAsync(userId);

    public Task<List<Appointment>> GetClientUpcomingAppointmentsAsync(int clientId) =>
        appointmentRepository.FindUpcomingByClientAsync(clientId, Dates.Today());

    // same algorithm as the JSON server (and as the original Spring project)
    public async Task<DoctorDaySchedule?> GetDoctorDayScheduleAsync(int doctorId, DateOnly day)
    {
        var doctor = await GetDoctorByIdAsync(doctorId);
        if (doctor is null)
        {
            return null;
        }
        var timeSlots = await GetAllSlotsAsync(doctorId);
        var bookings = await GetDoctorDayAppointmentsAsync(doctorId, day);
        // slot no. -> appointment booked on this slot
        var bookingsBySlot = bookings.ToDictionary(booking => booking.SlotId);
        var doctorDaySlots = timeSlots
            .Select(slot => new DoctorDaySlot(slot, bookingsBySlot.GetValueOrDefault(slot.Id)))
            .ToList();
        return new DoctorDaySchedule(doctor, day, doctorDaySlots);
    }
  • lines 5–22: The read operations simply delegate to the DAO layer. The [=> expression] syntax (an expression body) replaces a [{ return expression; }] block. These methods are not [async]: they directly return the [Task] from the DAO, without waiting for it;
  • lines 24–25: a patient’s upcoming appointments: the business layer provides the current date to DAO;
  • lines 28–42: calculation of a doctor’s schedule for a given day, using the same algorithm as the [NestJS] version. An unknown doctor yields [null]; otherwise, the doctor’s time slots and appointments for the day are read;
  • line 38: [ToDictionary] stores the appointments in a dictionary where the key is the slot ID (equivalent to [Map] in the [NestJS] version). The dictionary eliminates the need to scan the list of appointments for each time slot;
  • lines 39–41: Each time slot is associated with its appointment, or with [null] ([GetValueOrDefault] returns the default value, [null], when the key is missing). [Select] transforms each item in a list (see [LINQ], Chapter 9). The result is a [DoctorDaySchedule] object in the [Domain] folder, which the calendar view will display.

11.12.4.3. Scheduling an appointment

    // =======================================================================
    // appointments
    // =======================================================================

    // -----------------------------------------------------------------------
    // booking of an appointment, in a transaction:
    //   1. no appointment in the past;
    //   2. we LOCK the row of the patient (SELECT ... FOR UPDATE): if the
    //      same patient books two slots at the same instant (two tabs,
    //      double click...), the second transaction waits for the end of the
    //      first one - and then sees the appointment it has just created;
    //   3. a single upcoming appointment per patient;
    //   4. finally the insertion. If the slot has just been taken by ANOTHER
    //      patient, it is the SQL constraint unq1_appointments (day, slot_id) that
    //      refuses the insertion - translated into a BusinessException by TransactionAsync.
    // -----------------------------------------------------------------------
    public Task<Appointment> AddAppointmentAsync(DateOnly day, Slot slot, Client client) =>
        TransactionAsync(async () =>
        {
            var today = Dates.Today();
            if (day < today)
            {
                throw new BusinessException("ERRORS.PAST_DATE");
            }
            if (await clientRepository.LockAsync(client.Id) is null)
            {
                throw new BusinessException("ERRORS.PATIENT_NOT_FOUND");
            }
            if (await appointmentRepository.CountUpcomingByClientAsync(client.Id, today) > 0)
            {
                throw new BusinessException("ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT");
            }
            // we fill in the foreign keys: no need to attach the
            // Slot and Client objects themselves to write the [appointments] row
            var appointment = new Appointment { Day = day, SlotId = slot.Id, ClientId = client.Id };
            return await appointmentRepository.SaveAsync(appointment);
        });

    public Task DeleteAppointmentAsync(Appointment appointment) => appointmentRepository.DeleteByIdAsync(appointment.Id);
  • lines 17–18: The entire booking takes place within a single transaction: the method passes an asynchronous anonymous function, [async () => { ... }], to [TransactionAsync] (see below), which performs the task;
  • lines 20–23: no appointments in the past. The rule is verified here regardless of the role of the person making the reservation (ADMIN, DOCTOR, or the patient), even if the web layer has already hidden the “Book” buttons for past dates: a request can be created manually;
  • lines 25–27: the patient’s record is locked: [LockAsync] executes [SELECT * FROM clients WHERE id = ... FOR UPDATE] (see [Repositories/PersonRepository.cs]). This is a pessimistic lock: it is acquired before processing begins, assuming a potential conflict, and remains in place until the end of the transaction. Without it, two reservations for the same patient sent at the exact same time (two tabs, a double-click) could both pass the following test before either one has written its appointment to the database. With this lock, the second transaction waits for the first to finish, then sees the appointment it created. A patient who cannot be found (deleted in the meantime) is rejected;
  • lines 29–31: only one upcoming appointment per patient;
  • lines 35–36: the insertion. Only the foreign keys [SlotId] and [ClientId] are specified: there is no need to link the objects [Slot] and [Client] to write the row [appointments]. If another patient has just booked the same time slot, the lock did not prevent it (the lock applies to the patient’s row): it is the database’s [unq1_appointments (day, slot_id)] uniqueness constraint that rejects the insertion, and [TransactionAsync] translates the error into [BusinessException] with the key [ERRORS.SLOT_ALREADY_BOOKED];
  • Line 39: Cancellation of an appointment: a simple [DELETE], with no specific rules.

11.12.4.4. Managing Doctors and Clients

    // =======================================================================
    // doctors and clients - the public methods delegate to the common
    // private methods, with the right descriptor
    // =======================================================================

    public Task<Doctor> AddDoctorAsync(PersonData data) => AddPersonAsync(doctorDescriptor, data);

    public Task<Doctor> EditDoctorAsync(int id, PersonData data) =>
        EditPersonAsync(doctorDescriptor, id, data);

    public Task DeleteDoctorAsync(int id) => DeletePersonAsync(doctorDescriptor, id);

    public Task<Client> AddClientAsync(PersonData data) => AddPersonAsync(clientDescriptor, data);

    public Task<Client> EditClientAsync(int id, PersonData data) =>
        EditPersonAsync(clientDescriptor, id, data);

    // the client, their account AND all their appointments (ADMIN, or the patient
    // themselves from "Manage my account": same method, same rule)
    public Task DeleteClientAsync(int id) => DeletePersonAsync(clientDescriptor, id);

    // -----------------------------------------------------------------------
    // creation: the user account first (the person references it),
    // then the person - all in ONE transaction.
    // -----------------------------------------------------------------------
    private Task<T> AddPersonAsync<T>(PersonDescriptor<T> d, PersonData data) where T : Person, new()
    {
        var lastName = data.LastName.Trim().ToUpperInvariant();
        var firstName = data.FirstName.Trim();
        // the rules of the dbrdvmedecins2 database: login = last name in lower case,
        // password = login - unless the ADMIN has typed other ones
        var login = (string.IsNullOrEmpty(data.Login) ? lastName : data.Login).ToLowerInvariant();
        var password = string.IsNullOrEmpty(data.Password) ? login : data.Password;

        return TransactionAsync(async () =>
        {
            await CheckPersonUniqueAsync(d, firstName, lastName, null);
            await CheckLoginFreeAsync(login);

            var user = new User
            {
                Login = login,
                Password = BCrypt.Net.BCrypt.HashPassword(password, BcryptRounds),
                Name = $"{firstName} {lastName}",
                Role = d.Role,
            };
            await userRepository.SaveAsync(user);

            var person = new T { Title = data.Title, LastName = lastName, FirstName = firstName, User = user };
            return await d.Dao.SaveAsync(person);
        });
    }

    // -----------------------------------------------------------------------
    // update: the person AND their account (the display name "firstName LASTNAME"
    // must follow; the login and the password only change if they have been
    // typed).
    // -----------------------------------------------------------------------
    private Task<T> EditPersonAsync<T>(PersonDescriptor<T> d, int id, PersonData data)
        where T : Person, new() =>
        TransactionAsync(async () =>
        {
            var person = await d.Dao.FindByIdAsync(id) ?? throw new BusinessException(d.NotFoundKey);

            // optimistic lock: if the version in the database is no longer the one of the
            // form, someone has modified this record in the meantime - we refuse
            // rather than silently overwrite their changes.
            // (If the concurrent update happens AFTER this read, it is
            // the "WHERE version = ..." clause of the UPDATE that detects it.)
            if (data.Version is not null && data.Version != person.Version)
            {
                throw new BusinessException("ERRORS.CONCURRENT_UPDATE");
            }

            // we merely modify the objects: the DbContext, which tracks them,
            // will send the necessary UPDATEs at the next SaveChangesAsync
            person.Title = data.Title;
            person.LastName = data.LastName.Trim().ToUpperInvariant();
            person.FirstName = data.FirstName.Trim();
            await CheckPersonUniqueAsync(d, person.FirstName, person.LastName, id);

            var user = person.User;
            user.Name = $"{person.FirstName} {person.LastName}";
            if (!string.IsNullOrEmpty(data.Login) && data.Login.ToLowerInvariant() != user.Login)
            {
                var login = data.Login.ToLowerInvariant();
                await CheckLoginFreeAsync(login);
                user.Login = login;
            }
            if (!string.IsNullOrEmpty(data.Password))
            {
                user.Password = BCrypt.Net.BCrypt.HashPassword(data.Password, BcryptRounds);
            }
            await userRepository.SaveAsync(user);
            return await d.Dao.SaveAsync(person);
        });

    // -----------------------------------------------------------------------
    // deletion, in ONE transaction, in the order imposed by the foreign
    // keys:
    //   1. possible refusal if appointments remain (doctor);
    //   2. what references the person (slots of a doctor, appointments
    //      of a client);
    //   3. the person (it references its account);
    //   4. its account.
    // -----------------------------------------------------------------------
    private Task DeletePersonAsync<T>(PersonDescriptor<T> d, int id) where T : Person, new() =>
        TransactionAsync(async () =>
        {
            var person = await d.Dao.FindByIdAsync(id) ?? throw new BusinessException(d.NotFoundKey);
            if (d.CountAppointments is not null && await d.CountAppointments(id) > 0)
            {
                throw new BusinessException(d.HasAppointmentsKey ?? "ERRORS.INVALID_FORM");
            }
            if (d.BeforeDelete is not null)
            {
                await d.BeforeDelete(id);
            }
            await d.Dao.DeleteByIdAsync(id);
            await userRepository.DeleteByIdAsync(person.UserId);
            return true; // TransactionAsync expects a result: we do not need one here
        });

    // refuses first and last names already used by ANOTHER person of the same type
    // (currentId: the person being updated, who obviously has the
    // right to keep their own first and last names; null for a creation)
    private static async Task CheckPersonUniqueAsync<T>(PersonDescriptor<T> d, string firstName, string lastName, int? currentId)
        where T : Person, new()
    {
        var namesake = await d.Dao.FindByFirstNameLastNameAsync(firstName, lastName);
        if (namesake is not null && namesake.Id != currentId)
        {
            throw new BusinessException(d.AlreadyExistsKey);
        }
    }

    private async Task CheckLoginFreeAsync(string login)
    {
        if (await userRepository.FindByLoginAsync(login) is not null)
        {
            throw new BusinessException("ERRORS.LOGIN_TAKEN");
        }
    }
  • Lines 6–20: The six public methods delegate to the three private generic methods, using the correct descriptor. The compiler infers [T] from the descriptor: [Doctor] for [doctorDescriptor], [Client] for [clientDescriptor];
  • Lines 26–50: Creating a person and their account;
  • lines 28–33: data preparation, outside of a transaction. [Trim] removes leading and trailing spaces from the string; the name is capitalized. By default, the login is the same as the name and is always in lowercase; by default, the password is the same as the login: these are the account rules for the SQL script;
  • lines 37–38: within the transaction, the two uniqueness rules: no duplicate names, and a free choice of login. These are checked before writing to the database to provide a clear message (see Example 28);
  • lines 40–47: The user account is created first, since the person references it (foreign key). The password is never stored in plain text: [BCrypt.Net.BCrypt.HashPassword] calculates its hash (see Example 25). [$"{firstName} {lastName}"] is an interpolated string: the displayed name of the account;
  • Lines 49–50: Then, based on the constraint [new()], the person uses [new T] to generate either [Doctor] or [Client]. If this second write operation fails, the transaction also rolls back the first one: an account can never exist without a person;
  • lines 59–95: modifying the person and their account in a single transaction;
  • line 63: a person not found is a [BusinessException], with the descriptor key. [?? throw] was seen in Example 28;
  • lines 70–72: the optimistic lock (see Example 28): the form has carried over the version of the record read to its display; if the version in the database is different, someone has modified the record in the meantime, and the system rejects the update rather than silently overwriting their work. Unlike the pessimistic lock used for reservations, nothing is blocked: we assume that conflicts are rare, and we simply detect them. If the concurrent modification occurs after this read, it is the condition [WHERE version = ...] of [UPDATE] that will detect it (see [TranslateDatabaseError], below);
  • Lines 77–80: The read object is simply modified: the subsequent [DbContext] will send the necessary [UPDATE] to the next [SaveChangesAsync]. The name check transmits the person’s identifier: they are, of course, entitled to keep their own first and last names;
  • lines 82–88: the account follows the person (navigation property [User], loaded by DAO): their displayed name is recalculated. The login changes only if it has been entered and differs from the current one; in that case, it must be available;
  • lines 90–92: The password changes only if it has been entered; it is then hashed;
  • lines 94–95: saving the account and the person. Our [DbContext] increments the version number of each modified entity (see [Data/RdvMedecinsDbContext.cs]);
  • lines 107–121: deletion, within a single transaction, in the order dictated by the foreign keys;
  • lines 111–113: potential rejection: a doctor with scheduled appointments cannot be deleted (their patients would lose their appointments without being notified). [d.CountAppointments(id)] calls the function stored in the descriptor;
  • lines 115–117: deletion of what references the person: a doctor’s time slots, a client’s appointments;
  • lines 119–120: the person, then their account, which the person was referencing;
  • line 121: [TransactionAsync] expects a function that returns a result: one is returned, but no one will use it;
  • lines 127–133: a homonym is rejected, unless it is the person currently being edited ([currentId], [null] for a new creation). The method is [static]: it does not use any fields from the object;
  • Lines 137–141: A login name already in use—by a doctor, a client, or the administrator—is rejected.

11.12.4.5. Transactions and uniqueness constraints

    // -----------------------------------------------------------------------
    // runs [work] in a transaction:
    //   BeginTransactionAsync -> START TRANSACTION;
    //   CommitAsync            -> COMMIT, if everything went well;
    //   if an exception is thrown (BusinessException included), we do not commit:
    //   "await using" closes the transaction on leaving the block, and a
    //   transaction closed without COMMIT is cancelled (ROLLBACK).
    //
    // The "login free" and "unique first + last names" checks are made BEFORE
    // the write, to give a clear message. But two ADMINs could
    // save the same login (or the same doctor) at the same instant: in this
    // rare case, it is an SQL uniqueness constraint that refuses the second
    // write - we then translate this SQL error into the same BusinessException.
    // -----------------------------------------------------------------------
    private async Task<R> TransactionAsync<R>(Func<Task<R>> work)
    {
        await using var transaction = await db.Database.BeginTransactionAsync();
        try
        {
            var result = await work();
            await transaction.CommitAsync();
            return result;
        }
        catch (Exception e)
        {
            // on failure, the DbContext "forgets" everything it was tracking: an
            // entity left in the Added state would otherwise be sent again to the database at the
            // next SaveChangesAsync of the same request
            db.ChangeTracker.Clear();
            if (TranslateDatabaseError(e) is { } businessError)
            {
                throw businessError;
            }
            throw; // "throw;" rethrows the original exception, with its call stack intact
        }
    }

    // -----------------------------------------------------------------------
    // Database errors -> BusinessException
    //
    // - DbUpdateConcurrencyException: the UPDATE "WHERE id = ... AND version = ..."
    //   modified no row (optimistic lock, see AbstractEntity);
    // - DbUpdateException whose cause (InnerException) is the MySQL error
    //   ER_DUP_ENTRY (no. 1062): a UNIQUE constraint refused the write. Its
    //   message quotes the NAME of the constraint:
    //     Duplicate entry 'marie-pelissier' for key 'doctors.unq_doctors_first_name_last_name'
    //   (MySQL 8 prefixes the name with that of the table, MariaDB does not). That is
    //   why the SQL script NAMES all its constraints.
    // Any other error is a real anomaly: we return null (not translated).
    // -----------------------------------------------------------------------
    private static readonly Dictionary<string, string> ConstraintMessages = new()
    {
        ["unq_users_login"] = "ERRORS.LOGIN_TAKEN",
        ["unq_doctors_first_name_last_name"] = "ERRORS.DOCTOR_ALREADY_EXISTS",
        ["unq_clients_first_name_last_name"] = "ERRORS.PATIENT_ALREADY_EXISTS",
        ["unq1_appointments"] = "ERRORS.SLOT_ALREADY_BOOKED",
    };

    private static BusinessException? TranslateDatabaseError(Exception e)
    {
        // beware of the order: DbUpdateConcurrencyException inherits from DbUpdateException
        if (e is DbUpdateConcurrencyException)
        {
            return new BusinessException("ERRORS.CONCURRENT_UPDATE");
        }
        // "pattern matching": is e a DbUpdateException
        // whose InnerException is a MySqlException with code DuplicateKeyEntry?
        if (e is DbUpdateException { InnerException: MySqlException { ErrorCode: MySqlErrorCode.DuplicateKeyEntry } sqlError })
        {
            // "... for key 'doctors.unq_doctors_first_name_last_name'" -> "unq_doctors_first_name_last_name"
            var match = Regex.Match(sqlError.Message, @"for key '(?:[^'.]*\.)?([^']+)'");
            if (match.Success && ConstraintMessages.TryGetValue(match.Groups[1].Value, out var key))
            {
                return new BusinessException(key);
            }
        }
        return null;
    }
}
  • line 15: all multi-step entries go through this generic method: [R] is the type of the result of the operation ([Appointment], [Doctor], [Client], [bool]), and [Func<Task<R>>] is the type of a parameterless asynchronous function that returns a [R]. This is the [TransactionAsync] method from Example 28, expanded;
  • line 17: [BeginTransactionAsync] opens a transaction ([START TRANSACTION]). [await using var] declares a variable that will be automatically closed upon exiting the block that contains it, regardless of what happens (normal termination or exception); the version [await] allows for asynchronous closure. A transaction that is closed without having been committed is rolled back ([ROLLBACK]);
  • Lines 20–22: The task is executed; if it succeeds, [CommitAsync] commits the transaction ([COMMIT]). If an exception is thrown—including [BusinessException]—[CommitAsync] is never reached: everything that has been written is rolled back;
  • line 24: Example 28 caught two specific types of exceptions; here, we catch all exceptions to clean up before restarting them;
  • line 29: the cleanup. The database has rolled back everything, but the [DbContext] is still tracking entities that no longer correspond to anything: for example, the doctor whose insertion failed remains in the [Added] state. Since the [DbContext] persists throughout the entire query, it would be sent back to the database with the next [SaveChangesAsync] for that query. [ChangeTracker.Clear()] causes the [DbContext] to forget all the entities it was tracking;
  • lines 30–32: if the error is a known database error, the corresponding [BusinessException] is raised instead. [is { } businessError] is a pattern filter: it checks that the value is not [null] and stores it in the variable [businessError];
  • line 34: otherwise, the original exception is rethrown as-is. [throw;] alone, within a [catch], keeps its call stack intact (the one that indicates where the error occurred), whereas [throw e;] would reset it. A [BusinessException] raised by the job is thus passed back, unchanged, to the controller; any other error is an anomaly, which will generate a 500 error page;
  • lines 51–56: for each named uniqueness constraint in the SQL script, its translation key. This is why the script SQL names all its constraints. [new()] without a type name is allowed when the type is already specified on the left; [["key"] = value] populates the dictionary;
  • lines 59–77: translation of a database error into [BusinessException], or [null] if the error is not recognized;
  • lines 62–64: [UPDATE ... WHERE id = ... AND version = ...] did not modify any lines: EF Core raises a [DbUpdateConcurrencyException] (optimistic lock; see Example 28). This test comes first because [DbUpdateConcurrencyException] inherits from [DbUpdateException]: if placed later, the following test would have intercepted it;
  • line 68: a uniqueness constraint violation. The error [MySQL] (code [DuplicateKeyEntry], No. 1062) occurs nested within a [DbUpdateException], in its property [InnerException]. Pattern filtering checks everything in a single expression, on two levels: [e] is a [DbUpdateException], whose [InnerException] is a [MySqlException], which is a [ErrorCode] that equals [DuplicateKeyEntry]; this [MySqlException] is contained within [sqlError]. The [NestJS] version also tested the [ER_DUP_ENTRY] code of a [QueryFailedError];
  • line 71: the error message lists the constraint name, for example, [Duplicate entry 'marie-pelissier' for key 'doctors.unq_doctors_first_name_last_name']. The regular expression extracts it: [(?:[^'.]*\.)?] skips the optional prefix [doctors.] (written by [MySQL] 8, not by [MariaDB]), and the group [([^']+)] captures the name up to the apostrophe. The [@] at the beginning of the string makes it a verbatim string, where [\] is not an escape character;
  • Lines 72–74: If the name was found and is listed in the dictionary, [TryGetValue] stores the translation key in [key], and the error becomes [BusinessException]. Any other error, including a violation of a constraint not present in the dictionary, is left untranslated: this is a true anomaly.

11.13. Authentication

The [Auth] folder includes, in a more comprehensive form, examples 25 through 27: the JWT token in a cookie, roles, CAPTCHA, and attempt limits. It also includes a “pass” for creating a customer account. The [AuthController] controller, discussed at the end of this section, brings these components together for the login page.

11.13.1. Auth/ConnectedUser.cs

// =========================================================================
// Auth/ConnectedUser.cs
// -------------------------------------------------------------------------
// What the JWT token contains, and how to read it back.
//
// In ASP.NET Core, the user of the request is a ClaimsPrincipal
// (User property of a controller, HttpContext.User elsewhere): a list of
// "claims", type / value pairs - exactly the
// fields of the JWT token once verified. The authentication middleware
// fills it on EVERY request (see Program.cs, UseAuthentication).
//
// The claims of our tokens (see AuthService.SetToken):
//   sub   : the id of the user account (JWT convention: "subject")
//   login : the login
//   name  : the display name ("Marie PELISSIER"), for the navigation bar
//   role  : ADMIN, DOCTOR or USER
// =========================================================================

using System.Security.Claims;
using RdvMedecins.Entities;

namespace RdvMedecins.Auth;

public static class JwtClaims
{
    public const string Sub = "sub";
    public const string Login = "login";
    public const string Name = "name";
    public const string Role = "role";
}

// the connected user, in a typed form
public record ConnectedUser(int UserId, string Login, string Name, Role Role);

// -------------------------------------------------------------------------
// An "extension method": it is called as if it were part of
// ClaimsPrincipal (User.CurrentUser()), whereas it is defined here.
// -------------------------------------------------------------------------
public static class ClaimsPrincipalExtensions
{
    // the connected user, or null if the request is not authenticated
    public static ConnectedUser? CurrentUser(this ClaimsPrincipal principal)
    {
        if (principal.Identity?.IsAuthenticated != true
            || !int.TryParse(principal.FindFirstValue(JwtClaims.Sub), out var userId)
            || !Enum.TryParse<Role>(principal.FindFirstValue(JwtClaims.Role), out var role))
        {
            return null;
        }
        return new ConnectedUser(
            userId,
            principal.FindFirstValue(JwtClaims.Login) ?? "",
            principal.FindFirstValue(JwtClaims.Name) ?? "",
            role);
    }
}
  • lines 24–29: the names of the token claims, grouped into constants: a typo in [JwtClaims.Name] is flagged by the compiler, whereas an error in the string ["name"] would only be discovered at runtime;
  • line 33: the user logged in using a typed form: an integer identifier, a role of type [Role] (the enumeration ADMIN, DOCTOR, USER from the [Entities] folder), instead of the list of string pairs that constitute the claims. This is equivalent to the [req.user] object created by the [Passport] strategy of the [NestJS] project;
  • lines 39–42: an extension method (see Example 06) on ***[ClaimsPrincipal], the type of the ***[User] property of controllers and views (see Example 25). The pages simply write [User.CurrentUser()];
  • lines 44–48: the method returns [null] if the request is not authenticated, or if the claims are not as expected. [Identity?.IsAuthenticated] is equal to [null] if [Identity] is missing: the [!= true] comparison treats this case as “unauthenticated.” [int.TryParse] and [Enum.TryParse<Role>] attempt a conversion and return [false] if the conversion fails, rather than throwing an exception; the result is stored in the variable declared by [out var];
  • lines 50–54: [FindFirstValue] returns the value of the first claim of the requested type, or [null]; the operator [??] then replaces it with an empty string.

11.13.2. Auth/AuthService.cs

// =========================================================================
// Auth/AuthService.cs
// -------------------------------------------------------------------------
// The authentication logic:
//   - ValidateUserAsync()      : checks a login / password pair;
//   - SetToken()               : builds the JWT token of a validated user
//                                and stores it in a cookie;
//   - ClearToken()             : deletes this cookie (logout).
//
// Where does the JWT token travel?
// With the Angular client, it was the JavaScript code of the client that kept the
// token and added it itself to each request ("Authorization:
// Bearer ..." header). A browser that displays ordinary HTML pages cannot
// do that: for it to present the token on its own with each request,
// it must be stored in a COOKIE. The options of this cookie matter for
// security:
//   - HttpOnly : the JavaScript of the page cannot read the cookie (an
//     injected script could therefore not steal the token);
//   - SameSite=Lax : the browser does NOT send the cookie with a POST coming
//     from another site. As all the actions that modify data are
//     POSTs, a booby-trapped page on another site cannot book or
//     delete anything by impersonating the user
//     (so-called CSRF attack);
//   - Secure : in production (HTTPS), the cookie never travels in clear;
//   - MaxAge : the cookie disappears at the same time as the token expires.
// =========================================================================

using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using RdvMedecins.Config;
using RdvMedecins.Entities;
using RdvMedecins.Repositories;

namespace RdvMedecins.Auth;

public class AuthService(UserRepository userRepository, IOptions<AuthOptions> options)
{
    // the name of the cookie that carries the token
    public const string JwtCookie = "rdvmedecins_jwt";

    private readonly AuthOptions auth = options.Value;

    // the signing key of the tokens, built from the secret of
    // appsettings.json. Also used to VERIFY the tokens (see Program.cs).
    public static SymmetricSecurityKey SigningKey(AuthOptions auth)
    {
        var bytes = Encoding.UTF8.GetBytes(auth.JwtSecret);
        if (bytes.Length < 32)
        {
            // HMAC-SHA256 requires a key of at least 256 bits: we say so
            // clearly at startup rather than at the first login
            throw new InvalidOperationException("Auth:JwtSecret must be at least 32 characters long (see appsettings.json).");
        }
        return new SymmetricSecurityKey(bytes);
    }

    // null if the login does not exist or if the password is wrong.
    // BCrypt.Verify hashes the typed password with the "salt" contained in the
    // stored hash, then compares the two results.
    public async Task<User?> ValidateUserAsync(string login, string password)
    {
        var user = await userRepository.FindByLoginAsync(login);
        if (user is null)
        {
            return null;
        }
        return BCrypt.Net.BCrypt.Verify(password, user.Password) ? user : null;
    }

    // -----------------------------------------------------------------------
    // builds the JWT token of [user] and puts it in a cookie.
    // The token is SIGNED (HMAC-SHA256) with the secret key: the browser
    // can read it (it is not encrypted), but not modify it - any
    // modification would make the signature invalid.
    // -----------------------------------------------------------------------
    public void SetToken(HttpResponse response, User user)
    {
        var descriptor = new SecurityTokenDescriptor
        {
            Claims = new Dictionary<string, object>
            {
                [JwtClaims.Sub] = user.Id,
                [JwtClaims.Login] = user.Login,
                [JwtClaims.Name] = user.Name,
                [JwtClaims.Role] = user.Role.ToString(),
            },
            Expires = DateTime.UtcNow.AddSeconds(auth.JwtExpiresInSeconds),
            SigningCredentials = new SigningCredentials(SigningKey(auth), SecurityAlgorithms.HmacSha256),
        };
        var token = new JsonWebTokenHandler().CreateToken(descriptor);

        response.Cookies.Append(JwtCookie, token, new CookieOptions
        {
            HttpOnly = true,
            SameSite = SameSiteMode.Lax,
            Secure = auth.CookieSecure,
            MaxAge = TimeSpan.FromSeconds(auth.JwtExpiresInSeconds),
            Path = "/",
        });
    }

    public static void ClearToken(HttpResponse response) =>
        response.Cookies.Delete(JwtCookie, new CookieOptions { Path = "/" });
}

Let’s comment on this code:

  • line 38: the service receives, via injection, the DAO containing user accounts ([UserRepository], discussed with the DAO layer) and the authentication configuration. [IOptions<AuthOptions>] provides the [Auth] section of [appsettings.json] in the form of a [AuthOptions] object ([Config] folder): its [Value] property contains the secret key, the token lifetime, and the [CookieSecure] option. The service is registered as [Scoped] in [Program.cs], just like the DAO it uses (see Chapter 6);
  • line 41: the name of the cookie that carries the token;
  • lines 47–56: the signature key (see Example 25). New feature: a key that is too short is rejected with a clear exception. The method is static: it depends only on its parameter, and it is called when the application starts, by [JwtAuthentication] (next file), at a time when no services yet exist. Since the length check occurs at this point, an incorrect configuration prevents the application from starting, rather than causing the first connection to fail;
  • lines 62–69: the account if the username and password are correct; otherwise, [null] (see Example 25). The method is asynchronous: the account lookup is a database query (see Chapter 9);
  • lines 78–92: the token, generated as in Example 25. It contains the account ID, the username, the name displayed in the navigation bar, and the role; its lifetime is determined by the configuration (two hours by default);
  • Lines 94–100: the cookie, with the options from Example 25 ([HttpOnly], [SameSite = Lax]) and two additional ones: [Secure], enabled in production (the site is served with [HTTPS]), prevents the browser from sending the cookie over an unencrypted connection; [Path = "/"] ensures it is included with every request to the site. [MaxAge] deletes the cookie when the token expires;
  • lines 104–105: Logging out deletes the cookie. For the browser to recognize the cookie to be deleted, the path ([Path]) must be that of the storage location. The method is static: it is also used by the handler [JwtBearer] (next file) and the exception filter [HtmlExceptionFilter] (folder [Web]), which do not have an instance of [AuthService] available.

11.13.3. Auth/JwtAuthentication.cs

// =========================================================================
// Auth/JwtAuthentication.cs
// -------------------------------------------------------------------------
// The configuration of JWT token authentication, called from
// Program.cs (builder.Services.AddJwtAuthentication(...)).
//
// The JwtBearer "handler" of ASP.NET Core does, on each request, what
// the Passport 'jwt' strategy and JwtAuthGuard did in NestJS:
//   1. find the token;
//   2. check its signature and its expiry date;
//   3. derive the user from it (HttpContext.User, see ConnectedUser.cs).
//
// Three adaptations to an HTML application, thanks to the "events" of the
// handler (functions that it calls at precise moments):
//   - OnMessageReceived : the token is read from a COOKIE, and not from
//     the "Authorization: Bearer ..." header;
//   - OnChallenge : when a protected page is requested without a valid token,
//     JwtBearer would answer 401 - which suits a JavaScript client. A
//     browser, however, must be SENT BACK to the login page;
//   - (403: insufficient role) nothing to do here: the empty 403 response is
//     turned into an error page by UseStatusCodePagesWithReExecute
//     (see Program.cs and Controllers/ErrorController.cs).
// =========================================================================

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using RdvMedecins.Config;
using RdvMedecins.Web;

namespace RdvMedecins.Auth;

public static class JwtAuthentication
{
    public static IServiceCollection AddJwtAuthentication(this IServiceCollection services, AuthOptions auth)
    {
        services
            .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(options =>
            {
                // we keep the claim names of the token as they are ("role", "sub"...)
                // instead of translating them into long Microsoft-style names
                options.MapInboundClaims = false;

                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateIssuerSigningKey = true,
                    IssuerSigningKey = AuthService.SigningKey(auth),
                    ValidateLifetime = true,
                    // an expired token is refused immediately (by default, a
                    // tolerance of 5 minutes is granted for clock differences)
                    ClockSkew = TimeSpan.Zero,
                    // our tokens have neither issuer (iss) nor audience (aud)
                    ValidateIssuer = false,
                    ValidateAudience = false,
                    // which claim contains the name, and which one the role:
                    // [Authorize(Roles = "ADMIN")] compares the "role" claim
                    NameClaimType = JwtClaims.Name,
                    RoleClaimType = JwtClaims.Role,
                };

                options.Events = new JwtBearerEvents
                {
                    // 1. the token is in the cookie
                    OnMessageReceived = context =>
                    {
                        context.Token = context.Request.Cookies[AuthService.JwtCookie];
                        return Task.CompletedTask;
                    },

                    // 2. protected page without a valid token -> login page
                    OnChallenge = context =>
                    {
                        // HandleResponse(): "I take care of the response", JwtBearer
                        // will therefore not write its 401
                        context.HandleResponse();
                        // a cookie was present but is no longer valid: the session has expired
                        if (context.Request.Cookies.ContainsKey(AuthService.JwtCookie))
                        {
                            AuthService.ClearToken(context.Response);
                            Flash.Put(context.HttpContext, FlashType.Danger, "ERRORS.SESSION_EXPIRED");
                        }
                        context.Response.Redirect("/login");
                        return Task.CompletedTask;
                    },
                };
            });

        return services;
    }
}

In Example 25, the configuration for [JwtBearer] was written in [Program.cs]. Here, it is stored in an extension method, which [Program.cs] calls in a single line: [builder.Services.AddJwtAuthentication(authOptions)]. Let’s comment on this code:

  • Lines 32–34: the extension method (see Example 06). It directly receives a [AuthOptions] object, read from the configuration by [Program.cs]: when services are registered, dependency injection is not yet available, so we cannot request a [IOptions<AuthOptions>];
  • lines 37–42: the authentication schema [JwtBearer], which stores the token’s claim names (see Example 25);
  • lines 44–54: the checks performed on each token: the signature, using the key generated by [AuthService.SigningKey], and the expiration date. New feature compared to Example 25: [ClockSkew = TimeSpan.Zero]. By default, [JwtBearer] still accepts a token for five minutes after it expires, to account for a slight discrepancy between the clocks of the server that generates the token and the one that verifies it. Here, it is the same server: an expired token is rejected immediately;
  • lines 57–58: the claim [name] yields [User.Identity.Name], the claim [role] is the one verified by [[Authorize(Roles = "ADMIN")]] and [User.IsInRole] (see Example 26);
  • lines 64–67: the token is read from the cookie, not from the [Authorization] header (see Example 25);
  • lines 71–82: A request for a protected page without a valid token redirects to the login page (see Example 25). New feature (lines 77–80): If the request included a token cookie, it means the user was logged in, but their token has expired (or was tampered with). We delete this now-useless cookie and display a flash message (see Example 20; the [Flash] class is discussed with the [Web] folder): the login page will display “Your session has expired,” rather than leaving the user wondering why they are there. The message is a translation key (see Example 22).

A 403 Forbidden response (insufficient role) does not require an event: as in Example 26, the empty response is converted into an error page by [UseStatusCodePagesWithReExecute]. In the [NestJS] project, this task was divided among the [Passport] and [jwt] strategies (read and verify the token), the guard [JwtAuthGuard] (require a token), and the exception filter (convert the denial to a redirect); here, the handler [JwtBearer], its events, and the attribute [[Authorize]] are sufficient.

11.13.4. Auth/InvalidSessionException.cs

// =========================================================================
// Auth/InvalidSessionException.cs
// -------------------------------------------------------------------------
// The token is valid, but the account it designates no longer exists: it has been
// deleted since the login (by the patient themselves in another
// tab, or by the ADMIN). We treat this case as an expired session:
// HtmlExceptionFilter clears the cookie and sends back to the login page
// (the equivalent of NestJS's UnauthorizedException).
// =========================================================================

namespace RdvMedecins.Auth;

public class InvalidSessionException() : Exception("the account of the connected user no longer exists");
  • Line 13: A token may be valid (properly signed, not expired) even though the account it refers to has been deleted since the login. The patient’s pages detect this by searching for the client associated with the account (method [ConnectedPatientAsync] in file [Web/Utils.cs]) and then throw this exception; the exception filter [HtmlExceptionFilter] (see Example 24 for filters) intercepts it, deletes the cookie, and redirects to the login page with the message “session expired.” The class has a main constructor with no parameters, which passes its message to the constructor of the parent class [Exception].

11.13.5. Auth/CaptchaService.cs

// =========================================================================
// Auth/CaptchaService.cs
// -------------------------------------------------------------------------
// A CAPTCHA ("test to tell a human from a robot") made by the
// server itself, without any external service: a few distorted characters,
// scrambled by strokes, in an SVG image. A human reads them without
// difficulty; a robot that merely sends forms in a loop
// cannot do it.
//
// The NestJS project used the svg-captcha library. Here, no
// library: the image is drawn by the CaptchaDrawing class (below)
// from a small hand-written vector "font". An important
// point: each character is drawn with STROKES (<path>), never
// written as text (<text>) - otherwise a robot would only have to read the source
// code of the image.
//
// How it works:
//   1. Generate() draws a random text, turns it into an image, and stores the
//      right answer IN THE SERVER'S MEMORY, under a random identifier;
//   2. the page displays the image and puts the identifier in a hidden field of the
//      form (the answer itself never leaves the server);
//   3. Verify(id, input) compares the input with the expected answer, then
//      FORGETS this captcha: each image is used only once, a robot cannot
//      replay an answer already found.
//
// SINGLETON service (a single instance for the whole server, see
// Program.cs), used by several requests IN PARALLEL: hence the
// ConcurrentDictionary, a dictionary that several threads can read
// and modify at the same time without corrupting it.
//
// Accepted limit: the memory of a single process. With several servers
// behind a load balancer, a shared storage would be needed.
// =========================================================================

using System.Collections.Concurrent;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;

namespace RdvMedecins.Auth;

// Id : to put in the hidden "captchaId" field of the form
// Svg : the image, to insert as is in the page
public record Captcha(string Id, string Svg);

public class CaptchaService
{
    // validity duration of an image: you have 10 minutes to fill in the form
    private static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(10);

    // memory safeguard: beyond this, we forget the oldest captchas
    private const int MaxCaptchas = 10_000;

    // id -> (expected answer, expiry date)
    private readonly ConcurrentDictionary<string, (string Text, DateTime Expires)> pending = new();

    public Captcha Generate()
    {
        Purge();
        var text = CaptchaDrawing.RandomText(5); // 5 characters
        var id = Guid.NewGuid().ToString();
        pending[id] = (text, DateTime.UtcNow + Lifetime);
        return new Captcha(id, CaptchaDrawing.Svg(text));
    }

    // true if [input] is the right answer to captcha [id] (regardless
    // of case). The captcha is consumed in all cases.
    // TryRemove is ATOMIC: if two requests present the same captcha at the
    // same instant, only one removes it - the other fails.
    public bool Verify(string? id, string? input)
    {
        if (id is null || input is null || !pending.TryRemove(id, out var expected))
        {
            return false;
        }
        return expected.Expires > DateTime.UtcNow
            && string.Equals(expected.Text, input.Trim(), StringComparison.OrdinalIgnoreCase);
    }

    private void Purge()
    {
        var now = DateTime.UtcNow;
        foreach (var (id, captcha) in pending)
        {
            if (captcha.Expires <= now || pending.Count >= MaxCaptchas)
            {
                pending.TryRemove(id, out _);
            }
        }
    }
}

// -------------------------------------------------------------------------
// The drawing of the SVG image (150 x 50 pixels).
// -------------------------------------------------------------------------
internal static class CaptchaDrawing
{
    private const int Width = 150;
    private const int Height = 50;

    // The "font": each character is a list of strokes, each stroke a
    // series of points (x, y) in a 4 x 6 grid (y pointing down).
    // Characters too easy to confuse with one another: excluded (0 O, 1 I L).
    private static readonly Dictionary<char, double[][]> Font = new()
    {
        ['A'] = [[0, 6, 2, 0, 4, 6], [0.8, 3.8, 3.2, 3.8]],
        ['B'] = [[0, 0, 0, 6, 3, 6, 4, 5, 4, 4, 3, 3, 0, 3], [0, 0, 3, 0, 4, 1, 4, 2, 3, 3]],
        ['C'] = [[4, 1, 3, 0, 1, 0, 0, 1, 0, 5, 1, 6, 3, 6, 4, 5]],
        ['D'] = [[0, 0, 0, 6, 2.5, 6, 4, 4.5, 4, 1.5, 2.5, 0, 0, 0]],
        ['E'] = [[4, 0, 0, 0, 0, 6, 4, 6], [0, 3, 3, 3]],
        ['F'] = [[4, 0, 0, 0, 0, 6], [0, 3, 3, 3]],
        ['G'] = [[4, 1, 3, 0, 1, 0, 0, 1, 0, 5, 1, 6, 3, 6, 4, 5, 4, 3.5, 2.5, 3.5]],
        ['H'] = [[0, 0, 0, 6], [4, 0, 4, 6], [0, 3, 4, 3]],
        ['J'] = [[4, 0, 4, 5, 3, 6, 1, 6, 0, 5]],
        ['K'] = [[0, 0, 0, 6], [4, 0, 0, 3.5], [1.3, 2.6, 4, 6]],
        ['M'] = [[0, 6, 0, 0, 2, 3.5, 4, 0, 4, 6]],
        ['N'] = [[0, 6, 0, 0, 4, 6, 4, 0]],
        ['P'] = [[0, 6, 0, 0, 3, 0, 4, 1, 4, 2, 3, 3, 0, 3]],
        ['Q'] = [[1, 0, 3, 0, 4, 1, 4, 5, 3, 6, 1, 6, 0, 5, 0, 1, 1, 0], [2.5, 4.5, 4, 6.3]],
        ['R'] = [[0, 6, 0, 0, 3, 0, 4, 1, 4, 2, 3, 3, 0, 3], [2, 3, 4, 6]],
        ['S'] = [[4, 1, 3, 0, 1, 0, 0, 1, 0, 2, 1, 3, 3, 3, 4, 4, 4, 5, 3, 6, 1, 6, 0, 5]],
        ['T'] = [[0, 0, 4, 0], [2, 0, 2, 6]],
        ['U'] = [[0, 0, 0, 5, 1, 6, 3, 6, 4, 5, 4, 0]],
        ['V'] = [[0, 0, 2, 6, 4, 0]],
        ['W'] = [[0, 0, 1, 6, 2, 2.5, 3, 6, 4, 0]],
        ['X'] = [[0, 0, 4, 6], [4, 0, 0, 6]],
        ['Y'] = [[0, 0, 2, 3, 4, 0], [2, 3, 2, 6]],
        ['Z'] = [[0, 0, 4, 0, 0, 6, 4, 6]],
        ['2'] = [[0, 1, 1, 0, 3, 0, 4, 1, 4, 2, 0, 6, 4, 6]],
        ['3'] = [[0, 1, 1, 0, 3, 0, 4, 1, 4, 2, 3, 3, 1.5, 3], [3, 3, 4, 4, 4, 5, 3, 6, 1, 6, 0, 5]],
        ['4'] = [[3, 6, 3, 0, 0, 4, 4, 4]],
        ['5'] = [[4, 0, 0, 0, 0, 3, 3, 3, 4, 4, 4, 5, 3, 6, 1, 6, 0, 5]],
        ['6'] = [[4, 1, 3, 0, 1, 0, 0, 1, 0, 5, 1, 6, 3, 6, 4, 5, 4, 4, 3, 3, 0, 3]],
        ['7'] = [[0, 0, 4, 0, 1.5, 6]],
        ['8'] = [[1, 3, 0, 2, 0, 1, 1, 0, 3, 0, 4, 1, 4, 2, 3, 3, 1, 3, 0, 4, 0, 5, 1, 6, 3, 6, 4, 5, 4, 4, 3, 3]],
        ['9'] = [[4, 3, 1, 3, 0, 2, 0, 1, 1, 0, 3, 0, 4, 1, 4, 5, 3, 6, 1, 6, 0, 5]],
    };

    private static readonly char[] Alphabet = [.. Font.Keys];

    // a few dark colors, easy to read on the light background
    private static readonly string[] Colors = ["#1f4e79", "#7b2d26", "#2e6b30", "#5b2c83", "#8a5a00", "#23395d", "#9c2a5b"];

    // RandomNumberGenerator: a "cryptographic" random number generator,
    // unpredictable (unlike Random, whose sequence could be guessed)
    public static string RandomText(int length) =>
        new(Enumerable.Range(0, length).Select(_ => Alphabet[RandomNumberGenerator.GetInt32(Alphabet.Length)]).ToArray());

    private static double RandomBetween(double min, double max) =>
        min + (max - min) * RandomNumberGenerator.GetInt32(1_000_001) / 1_000_000.0;

    public static string Svg(string text)
    {
        var svg = new StringBuilder();
        svg.Append($"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{Width}\" height=\"{Height}\" viewBox=\"0 0 {Width} {Height}\">");
        svg.Append($"<rect width=\"100%\" height=\"100%\" fill=\"#f5f7fa\"/>");

        // the characters: each one enlarged, tilted, shifted, and each point
        // slightly moved at random
        var step = (Width - 20) / (double)text.Length;
        for (var i = 0; i < text.Length; i++)
        {
            var scale = RandomBetween(4.6, 5.6); // size of a grid unit, in pixels
            var angle = RandomBetween(-0.35, 0.35); // rotation, in radians (about ±20°)
            var cx = 10 + step * (i + 0.5) + RandomBetween(-3, 3); // center of the character
            var cy = Height / 2.0 + RandomBetween(-4, 4);
            var color = Colors[RandomNumberGenerator.GetInt32(Colors.Length)];
            var d = new StringBuilder();
            foreach (var stroke in Font[text[i]])
            {
                for (var k = 0; k < stroke.Length; k += 2)
                {
                    // grid point, centered on (2, 3), distorted, rotated, placed
                    var x = (stroke[k] - 2 + RandomBetween(-0.2, 0.2)) * scale;
                    var y = (stroke[k + 1] - 3 + RandomBetween(-0.2, 0.2)) * scale;
                    var px = cx + x * Math.Cos(angle) - y * Math.Sin(angle);
                    var py = cy + x * Math.Sin(angle) + y * Math.Cos(angle);
                    d.Append(k == 0 ? 'M' : 'L').Append(Number(px)).Append(' ').Append(Number(py)).Append(' ');
                }
            }
            svg.Append($"<path d=\"{d.ToString().TrimEnd()}\" fill=\"none\" stroke=\"{color}\" stroke-width=\"{Number(RandomBetween(2.2, 3))}\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>");
        }

        // 3 scrambling strokes: curves that cross the image
        for (var n = 0; n < 3; n++)
        {
            var color = Colors[RandomNumberGenerator.GetInt32(Colors.Length)];
            svg.Append($"<path d=\"M{Number(RandomBetween(0, 20))} {Number(RandomBetween(5, 45))} " +
                       $"C{Number(RandomBetween(30, 60))} {Number(RandomBetween(0, 50))} {Number(RandomBetween(90, 120))} {Number(RandomBetween(0, 50))} " +
                       $"{Number(RandomBetween(130, 150))} {Number(RandomBetween(5, 45))}\" fill=\"none\" stroke=\"{color}\" stroke-width=\"1.3\"/>");
        }

        svg.Append("</svg>");
        return svg.ToString();
    }

    // a number with a decimal point, whatever the language of the server
    // (in French, ToString() would write "12,5" - unreadable for SVG)
    private static string Number(double value) => value.ToString("0.#", CultureInfo.InvariantCulture);
}

The file contains two classes: the service class, which is identical to the one in Example 27 except for one safeguard, and the [CaptchaDrawing] class that generates the image, which was already discussed in Example 27 (file [Auth/CaptchaDrawing.cs]). Let’s go over this code:

  • line 44: a CAPTCHA, as received by the page: the identifier, to be placed in a hidden form field, and the image [SVG];
  • lines 49–52: An image is valid for ten minutes, and there is a cap on the number of pending captchas. Each time the login page is displayed, a captcha is added to memory; without a cap, a bot that requested this page millions of times without ever responding would eventually exhaust the server’s memory;
  • line 55: id → (expected answer, expiry date). The pair is a tuple whose elements are named ([Text], [Expires]). The service is a singleton, used by concurrent requests: hence the [ConcurrentDictionary] (see Example 27);
  • lines 57–63: the cleanup, followed by a randomly generated five-character text string, stored under a random identifier ([Guid.NewGuid()], impossible to guess), and displayed;
  • lines 70–77: verification, identical to that in Example 27: the CAPTCHA is consumed in all cases ([TryRemove]), then the system verifies that it has not expired and that the entered text, with spaces removed, is correct, regardless of case. [TryRemove] is atomic: if two requests present the same CAPTCHA at the same time, only one removes it;
  • lines 80–87: Expired captchas are discarded; if the limit is reached, valid captchas are also removed until the count falls below the limit. A [ConcurrentDictionary] can be modified while it is being processed, which is not allowed with a standard [Dictionary] (see [AttemptLimiterService], below). However, it does not preserve the insertion order: the CAPTCHAs removed in this way are not necessarily the oldest ones, whereas the [Map] from the [NestJS] project allowed the oldest ones to be removed first. This situation only occurs in the event of an attack: the approximation is sufficient;
  • line 96: the image drawing. [internal]: the class is only visible within the application itself;
  • lines 104–136: the “font”: for each character, a list of strokes, each stroke being a sequence of (x, y) coordinates in a 4 × 6 grid. The square brackets [[...]] are collection expressions ([C#] 12), a compact notation for arrays;
  • line 139: the usable characters, i.e., the font keys. [..] spreads one collection into another;
  • lines 146–147 and lines 149–150: the randomization uses [RandomNumberGenerator], a cryptographic generator whose outputs are unpredictable;
  • lines 152–194: the image is a [SVG] text, assembled into a [StringBuilder] (a string that is extended without being rewritten each time an element is added). Each character (lines 160–181) becomes a [<path>] path: each point on the grid is slightly shifted, enlarged, rotated, and placed in its position within the image, with a randomly selected color and line thickness. The characters are lines, never text: a bot cannot read them in the image’s code. Three scrambling curves then cross the image (lines 185–190);
  • line 199: the numbers in [SVG] are written with a decimal point. [CultureInfo.InvariantCulture] enforces this notation regardless of the server’s language: in French, [ToString()] would use a comma (see Chapter 5).

11.13.6. Auth/AttemptLimiterService.cs

// =========================================================================
// Auth/AttemptLimiterService.cs
// -------------------------------------------------------------------------
// Second line of defense against robots (the first is the captcha): we COUNT
// the attempts, and beyond a threshold we BLOCK for a while.
//
//   - login: after LoginMaxFailures failures (wrong captcha or wrong
//     password) for the same login from the same IP address, this pair
//     is blocked LoginBlockMinutes minutes. The same IP address is also
//     blocked if it accumulates 5 times more failures, all logins combined (a
//     robot that tries thousands of different logins);
//   - account creation: at most RegistrationMaxPerHour accounts created per
//     hour from the same IP address.
//
// Beware in a classroom: all the computers often go out to the Internet
// through the SAME IP address. That is why the login block applies
// first to the (IP, login) pair - a student who mistypes their
// password does not block their neighbors - and why the thresholds are adjustable
// in appsettings.json ("Protection" section).
//
// Singleton service, called by parallel requests: all the
// operations on the counters are done under a lock, which guarantees
// that only one thread at a time reads AND modifies a counter.
// =========================================================================

using Microsoft.Extensions.Options;
using RdvMedecins.Config;

namespace RdvMedecins.Auth;

public class AttemptLimiterService(IOptions<ProtectionOptions> options)
{
    private class Counter
    {
        public int Count; // attempts counted in the current window
        public DateTime Start; // start of the counting window
        public DateTime BlockedUntil; // DateTime.MinValue if not blocked
    }

    private static readonly TimeSpan OneHour = TimeSpan.FromHours(1);

    private readonly Dictionary<string, Counter> counters = [];
    private readonly Lock syncLock = new(); // the Lock type of .NET 9+, made for "lock"

    private readonly int maxLoginFailures = options.Value.LoginMaxFailures;
    private readonly TimeSpan loginBlockDuration = TimeSpan.FromMinutes(options.Value.LoginBlockMinutes);
    private readonly int maxRegistrations = options.Value.RegistrationMaxPerHour;

    // ---------------------------------------------------------------- login

    // number of remaining block minutes (0 = the attempt is allowed)
    public int LoginBlockMinutes(string ip, string login)
    {
        lock (syncLock)
        {
            return Math.Max(RemainingMinutes(LoginKey(ip, login)), RemainingMinutes(IpKey(ip)));
        }
    }

    public void LoginFailure(string ip, string login)
    {
        lock (syncLock)
        {
            Count(LoginKey(ip, login), maxLoginFailures, loginBlockDuration, loginBlockDuration);
            Count(IpKey(ip), maxLoginFailures * 5, loginBlockDuration, loginBlockDuration);
        }
    }

    // a successful login resets the counter of THIS login
    public void LoginSuccess(string ip, string login)
    {
        lock (syncLock)
        {
            counters.Remove(LoginKey(ip, login));
        }
    }

    // ---------------------------------------------------------------- registration

    public int RegistrationBlockMinutes(string ip)
    {
        lock (syncLock)
        {
            return RemainingMinutes($"registration|{ip}");
        }
    }

    // one-hour window; once the quota is reached, blocked until the end of the hour
    public void RegistrationSuccess(string ip)
    {
        lock (syncLock)
        {
            Count($"registration|{ip}", maxRegistrations, OneHour, OneHour);
        }
    }

    // ---------------------------------------------------------------- shared mechanics
    // (the private methods are always called under the lock)

    private int RemainingMinutes(string key)
    {
        if (!counters.TryGetValue(key, out var c))
        {
            return 0;
        }
        var remaining = c.BlockedUntil - DateTime.UtcNow;
        return remaining > TimeSpan.Zero ? (int)Math.Ceiling(remaining.TotalMinutes) : 0;
    }

    // counts an attempt; if the threshold is reached within the window, blocks
    private void Count(string key, int max, TimeSpan window, TimeSpan block)
    {
        var now = DateTime.UtcNow;
        Purge(now);
        if (!counters.TryGetValue(key, out var c) || now - c.Start > window)
        {
            c = new Counter { Count = 0, Start = now, BlockedUntil = DateTime.MinValue };
            counters[key] = c;
        }
        c.Count++;
        if (c.Count >= max)
        {
            c.BlockedUntil = now + block;
            c.Count = 0; // after the block, we start again from zero
            c.Start = c.BlockedUntil;
        }
    }

    // we forget the counters that are no longer useful (neither blocked, nor recent)
    private void Purge(DateTime now)
    {
        if (counters.Count < 1000)
        {
            return;
        }
        foreach (var (key, c) in counters.ToList())
        {
            if (c.BlockedUntil < now && now - c.Start > OneHour)
            {
                counters.Remove(key);
            }
        }
    }

    private static string LoginKey(string ip, string login) => $"login|{ip}|{login.Trim().ToLowerInvariant()}";

    private static string IpKey(string ip) => $"ip|{ip}";
}

Compared to Example 27, the limiter protects two operations (the connection and account creation); the connection block applies to the pair (address IP, login), and the thresholds are configurable in [appsettings.json]. Let’s break down this code:

  • line 31: [IOptions<ProtectionOptions>] specifies the section [Protection] of [appsettings.json] (folder [Config]): the number of allowed failed login attempts (5 by default), the lockout duration (15 minutes), and the number of accounts created per hour (10);
  • lines 33–37: a counter: the number of attempts, the start of the counting window, and the end of the lockout ([DateTime.MinValue], the earliest possible date, if there is no lockout). This is a nested, private class: it exists only for this service. It is a class, not a [record] or a structure: a counter read from the dictionary is modified in place;
  • Lines 42–43: an ordinary [Dictionary], protected by a lock (see Example 27). A [ConcurrentDictionary] would not be sufficient: counting an attempt involves reading a counter, modifying it, and possibly replacing it, and this entire sequence of operations must be executed without another request interrupting it. Each public method therefore performs its task within a [lock (syncLock)] block;
  • lines 45–47: the thresholds, read once and for all. The main constructor’s parameter can be used to initialize the fields;
  • lines 52–56: the remaining lockout time for a connection attempt: the longer of the two possible lockout periods, that of the pair (address IP, login) and that of the address IP alone;
  • lines 60–65: A failed attempt is counted twice. For the pair (address IP, login): five failed attempts with the same login block that login, from that address only. For the address IP alone, with a threshold five times higher: a bot that tries a large number of different logins is also blocked. In a classroom, all computers often connect to the Internet via the same address, IP: a student who enters the wrong password should not block their neighbors, hence the blocking by pair;
  • lines 70–74: a successful login resets the counter for that specific login (not the one for the address IP);
  • lines 80–84 and lines 89–93: For account creation, only successful attempts are counted: a maximum of ten accounts per hour from the same address IP. Once the quota is reached, the address is blocked for one hour;
  • lines 100–107: the remaining minutes of the block, rounded up to the next whole number ([Math.Ceiling]): it shows “1 minute” rather than “0 minutes” when there are 20 seconds remaining. These private methods do not acquire the lock: they are always called by a public method that already holds it;
  • lines 111–125: counting. An unknown key, or one whose counting window has expired, starts with a new counter. When the threshold is reached, the key is locked, and counting will resume from zero at the end of the lock;
  • lines 130–140: cleaning up counters that are no longer needed (neither locked nor recent), performed only when the dictionary exceeds 1,000 entries. We iterate through a copy of the entries ([ToList()]): a [Dictionary] cannot be modified while it is being iterated over;
  • lines 145–147: generating the dictionary keys. Spaces are removed from the login and it is converted to lowercase: [Admin] and [admin] share the same counter; a bot cannot bypass the lock by changing the case.

11.13.7. Auth/RegistrationPassService.cs

// =========================================================================
// Auth/RegistrationPassService.cs
// -------------------------------------------------------------------------
// The "pass" of account creation.
//
// To open the "Create a patient account" page, you must first have
// copied the captcha of the authentication page (see
// RegistrationController, POST /register/access). The server then issues
// a pass:
//   - a random identifier, stored in an HttpOnly cookie;
//   - valid for 15 minutes (the time to fill in the form, even if it takes
//     several attempts after input errors);
//   - consumed as soon as an account is created: ONE solved captcha = ONE account at
//     most. A robot cannot create accounts in series.
//
// Without a valid pass, GET and POST /register send back to the
// authentication page: typing the /register URL directly does not allow
// bypassing the captcha.
//
// Like the captcha, the passes live in the memory of the process.
// =========================================================================

using System.Collections.Concurrent;

namespace RdvMedecins.Auth;

public class RegistrationPassService
{
    public const string Cookie = "rdvmedecins_registration";

    private static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(15);
    private const int MaxPasses = 10_000;

    // id -> expiry date
    private readonly ConcurrentDictionary<string, DateTime> passes = new();

    // issues a new pass; returns its identifier
    public string Issue()
    {
        Purge();
        var id = Guid.NewGuid().ToString();
        passes[id] = DateTime.UtcNow + Lifetime;
        return id;
    }

    // true if [id] is an existing, non-expired pass
    public bool IsValid(string? id) =>
        id is not null && passes.TryGetValue(id, out var expires) && expires > DateTime.UtcNow;

    // the pass has been used: we forget it
    public void Consume(string? id)
    {
        if (id is not null)
        {
            passes.TryRemove(id, out _);
        }
    }

    // the cookie is sent by the browser only to the /register... addresses
    public static CookieOptions GetCookieOptions() => new()
    {
        HttpOnly = true,
        SameSite = SameSiteMode.Lax,
        MaxAge = Lifetime,
        Path = "/register",
    };

    private void Purge()
    {
        var now = DateTime.UtcNow;
        foreach (var (id, expires) in passes)
        {
            if (expires <= now || passes.Count >= MaxPasses)
            {
                passes.TryRemove(id, out _);
            }
        }
    }
}

You can create a customer account from the login page, provided you have solved the CAPTCHA. The registration form is displayed and submitted via subsequent requests: these requests must know that a previous request successfully solved the CAPTCHA. This is the role of the pass, issued and verified by [RegistrationController] (documented in the controllers):

  • line 29: the name of the cookie that carries the pass;
  • lines 31–35: a pass is valid for fifteen minutes; pending passes are kept in memory, like captchas, with the same time limit;
  • lines 38–43: A pass is a random identifier, stored along with its expiration date; the controller places it in a cookie;
  • lines 47–48: the registration form is displayed—and accepted—only with a valid pass. Verification does not consume the pass ([TryGetValue], not [TryRemove]): the user can resubmit the form multiple times if they made input errors;
  • lines 51–55: Creating an account consumes the pass: solving a CAPTCHA entitles the user to one account, and no more;
  • lines 60–65: cookie options. [new()] creates an object of the type declared by the method ([CookieOptions]). The option [Path = "/register"] limits the sending of the cookie to URL values that begin with [/register]: other requests do not carry it unnecessarily;
  • lines 68–75: the cleanup process, identical to that of the CAPTCHA.

11.13.8. Controllers/AuthController.cs

// =========================================================================
// Controllers/AuthController.cs
// -------------------------------------------------------------------------
// The NON-protected routes of the login:
//   GET  /        redirects to the home page of the role (or to /login)
//   GET  /login   displays the login form (with its captcha)
//   POST /login   checks attempt limit, captcha, login / password
//   POST /logout  clears the token cookie
//
// An MVC controller inherits from Controller and returns an IActionResult:
// View(...) to display a page, Redirect(...) to redirect...
// [HttpGet("login")] / [HttpPost("login")]: "attribute routing",
// the equivalent of @Get('login') / @Post('login').
//
// Protection against robots, in this order:
//   1. too many recent failures for this login from this IP address?
//      -> immediate refusal, without even looking at the password;
//   2. wrong answer to the captcha? -> refusal (counted as a failure);
//   3. only then, verification of the password.
// We NEVER check the password of an attempt refused at steps
// 1 or 2: a robot can therefore learn nothing by insisting.
// =========================================================================

using Microsoft.AspNetCore.Mvc;
using RdvMedecins.Auth;
using RdvMedecins.Entities;
using RdvMedecins.Models;

namespace RdvMedecins.Controllers;

public class AuthController(
    AuthService authService,
    CaptchaService captchaService,
    AttemptLimiterService limiter) : Controller
{
    // the home page of each role, right after logging in
    public static string HomePage(Role role) => role == Role.USER ? "/my-area" : "/schedule";

    [HttpGet("/")]
    public IActionResult Home()
    {
        // User: the user of the request, rebuilt from the token by
        // the authentication middleware (see Auth/ConnectedUser.cs)
        var user = User.CurrentUser();
        return Redirect(user is not null ? HomePage(user.Role) : "/login");
    }

    // GET /login[?login=xxx]: the optional parameter pre-fills the login
    // field (used right after the creation of an account, see RegistrationController)
    [HttpGet("login")]
    public IActionResult ShowLogin(string? login)
    {
        // already logged in (token still valid): straight to the home page
        var user = User.CurrentUser();
        if (user is not null)
        {
            return Redirect(HomePage(user.Role));
        }
        return Form(login is null ? "" : login[..Math.Min(login.Length, 30)]);
    }

    // the parameters of the action are filled with the form fields of the
    // same name (model binding); [FromForm] specifies that they are looked for in
    // the body of the POST request
    [HttpPost("login")]
    public async Task<IActionResult> LogIn(
        [FromForm] string? login,
        [FromForm] string? password,
        [FromForm] string? captchaId,
        [FromForm] string? captcha)
    {
        login = (login ?? "").Trim();
        password ??= "";
        var ip = IpAddress(HttpContext);

        // 1. too many recent failures?
        var minutes = limiter.LoginBlockMinutes(ip, login);
        if (minutes > 0)
        {
            return Form(login, "ERRORS.TOO_MANY_ATTEMPTS", new Dictionary<string, string> { ["minutes"] = minutes.ToString() });
        }

        // 2. the captcha
        if (!captchaService.Verify(captchaId, captcha))
        {
            limiter.LoginFailure(ip, login);
            return Form(login, "ERRORS.WRONG_CAPTCHA");
        }

        // 3. the password
        var user = login != "" && password != "" ? await authService.ValidateUserAsync(login, password) : null;
        if (user is null)
        {
            limiter.LoginFailure(ip, login);
            return Form(login, "LOGIN.ERROR");
        }

        limiter.LoginSuccess(ip, login);
        authService.SetToken(Response, user);
        return Redirect(HomePage(user.Role));
    }

    // logout with a POST (and not with a simple GET link): a third-party site cannot
    // log the user out without their knowledge (see SameSite, AuthService)
    [HttpPost("logout")]
    public IActionResult LogOut()
    {
        AuthService.ClearToken(Response);
        return Redirect("/login");
    }

    // the IP address of the visitor. Behind a reverse proxy (nginx...), the
    // UseForwardedHeaders middleware would have to be enabled to get the one of the
    // visitor and not the one of the proxy.
    public static string IpAddress(HttpContext http) => http.Connection.RemoteIpAddress?.ToString() ?? "unknown";

    // the form, always with a NEW captcha (each image is used only once)
    private ViewResult Form(string login, string? error = null, IReadOnlyDictionary<string, string>? errorParams = null) =>
        View("Login", new LoginModel
        {
            PageTitle = "LOGIN.TITLE",
            Login = login, // we redisplay the typed login, never the password
            Captcha = captchaService.Generate(),
            Error = error,
            ErrorParams = errorParams,
        });
}

Let’s comment on this code:

  • lines 31–34: the controller receives the three services it needs. No attributes [[Authorize]]: its pages are accessible without being logged in;
  • line 37: The home page depends on the role: “My area” for a patient, the calendar for others;
  • lines 39–45: The site root redirects to the role-specific home page or to the login page. The authentication middleware runs for all requests, whether protected or not: if the cookie contains a valid token, [User] is populated, even here. The [NestJS] project, on the other hand, was supposed to verify the token itself (method [readToken]), since the token check is not applied to this route;
  • lines 50–59: a user who is already logged in is redirected to their home page. The optional parameter [login], which comes from URL (see Example 07), pre-fills the field after an account is created. [login[..n]] (range operator) preserves the first [n] characters: its length is limited to 30, because data from the request is never used without taking precautions;
  • Lines 65–70: the fields of the login form (see Example 11), including the CAPTCHA ID, transmitted via a hidden field, and the user’s input. All are declared as [string?]: a missing field yields [null], and it is the action that determines what to do with it;
  • lines 72–74: A missing login becomes an empty string; [password ??= ""] only affects the empty string if [password] is [null];
  • lines 77–80: 1. A blocked pair isn’t even processed: neither the CAPTCHA nor the password is checked. The error is a translation key with one parameter: the number of minutes to wait;
  • lines 84–87: 2. the CAPTCHA. An incorrect CAPTCHA counts as a failure (see example 27);
  • lines 91–95: 3. Only then is the password checked. An empty username or password is not subjected to the computationally expensive [bcrypt] calculation. Unknown username or incorrect password: the same deliberately vague message (see Example 25), and another failure;
  • lines 98–100: success: counter reset to zero, token stored in the cookie, redirection to the role’s home page;
  • lines 105–109: logout, using POST (see Example 25);
  • line 115: the client’s address IP, provided by the network connection ([RemoteIpAddress], which may be [null], hence [?.] and [??]). The method is public and static: [RegistrationController] and [PatientAreaController] also use it for the limiter. Behind an intermediary server (a reverse proxy such as [nginx]), this address would be that of the proxy: the comment specifies the middleware to enable in this case;
  • lines 118–125: the login page, always with a new CAPTCHA: each image is used only once. Its template, [LoginModel] (folder [Models]), contains the page title (a translation key), the entered username (never the password), the CAPTCHA, and any error messages, along with the translation key and parameters. [error] and [errorParams] are optional parameters: they default to [null] when the call does not provide them.

11.14. The Web Layer: Shared Services

Before we get to the controllers and views, here’s what they have in common: translation, the parent class for views, flash messages, the exception filter, the cache for lists of doctors and clients, the preparation of forms to be re-displayed, and a few utility functions. All these files are in the [Web] folder. It brings together most of the techniques from Examples 16 through 24.

11.14.1. Web/Translator.cs

// =========================================================================
// Web/Translator.cs
// -------------------------------------------------------------------------
// French / English translation of the interface.
//
// The dictionaries are the SAME files as those of the NestJS project
// (i18n/fr.json, i18n/en.json), read only once at startup. A Razor
// view translates a key with @T("KEY") (see Web/ViewPage.cs).
//
// ASP.NET Core also offers its own system (IStringLocalizer and
// .resx files); keeping the JSON files allows sharing the
// dictionaries with the other versions of the application.
//
// THE CURRENT LANGUAGE: it is the localization middleware of ASP.NET Core
// (UseRequestLocalization, see Program.cs) that determines it on each request,
// from a cookie, and stores it in CultureInfo.CurrentUICulture.
// =========================================================================

using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;

namespace RdvMedecins.Web;

public partial class Translator
{
    public static readonly string[] Languages = ["fr", "en"];
    public const string DefaultLanguage = "en";

    // language -> (full key "LOGIN.TITLE" -> text)
    private readonly Dictionary<string, Dictionary<string, string>> dictionaries = [];

    public Translator(IWebHostEnvironment environment)
    {
        foreach (var language in Languages)
        {
            var path = Path.Combine(environment.ContentRootPath, "i18n", $"{language}.json");
            using var document = JsonDocument.Parse(File.ReadAllText(path));
            var dictionary = new Dictionary<string, string>();
            Flatten(document.RootElement, "", dictionary);
            dictionaries[language] = dictionary;
        }
    }

    // the language of the current request: "fr" or "en"
    public static string CurrentLanguage()
    {
        var language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
        return Languages.Contains(language) ? language : DefaultLanguage;
    }

    // -----------------------------------------------------------------------
    // Translate("DOCTORS.ADDED", { ["name"] = "Marie PELISSIER" })
    //   -> looks up "DOCTORS.ADDED" in the dictionary of the current language,
    //      and replaces {name} with its value. A key missing from the requested
    //      language is looked up in the default language; missing everywhere, the
    //      key itself is displayed (an omission is thus spotted immediately).
    // -----------------------------------------------------------------------
    public string Translate(string? key, IReadOnlyDictionary<string, string>? messageParams = null)
    {
        if (string.IsNullOrEmpty(key))
        {
            return "";
        }
        var text = dictionaries[CurrentLanguage()].GetValueOrDefault(key)
                    ?? dictionaries[DefaultLanguage].GetValueOrDefault(key)
                    ?? key;
        if (messageParams is null || messageParams.Count == 0)
        {
            return text;
        }
        return Parameter().Replace(text, m => messageParams.TryGetValue(m.Groups[1].Value, out var value) ? value : m.Value);
    }

    // { "LOGIN": { "TITLE": "Login" } } -> { "LOGIN.TITLE": "Login" }
    private static void Flatten(JsonElement node, string prefix, Dictionary<string, string> result)
    {
        foreach (var property in node.EnumerateObject())
        {
            var key = prefix == "" ? property.Name : $"{prefix}.{property.Name}";
            if (property.Value.ValueKind == JsonValueKind.Object)
            {
                Flatten(property.Value, key, result);
            }
            else
            {
                result[key] = property.Value.GetString() ?? "";
            }
        }
    }

    // the {name} regular expression, compiled once and for all by the
    // C# compiler ([GeneratedRegex] "source generator")
    [GeneratedRegex(@"\{(\w+)\}")]
    private static partial Regex Parameter();
}

This is the translation service from Example 22, saved as a singleton in [Program.cs]: The dictionaries [i18n/fr.json] and [i18n/en.json] are the same files as those in version [NestJS], read only once at startup. There is only one difference:

  • lines 65–67: a key missing from the French dictionary is looked up in the English dictionary ([DefaultLanguage]); if it is missing everywhere, the key itself is displayed. The two [??] operators are chained together: the first value that is not [null] is taken.

11.14.2. Web/ViewPage.cs

// =========================================================================
// Web/ViewPage.cs
// -------------------------------------------------------------------------
// The parent class of ALL the Razor views of the application (see
// Views/_ViewImports.cshtml: @inherits ViewPage<TModel>).
//
// A .cshtml view is compiled into a C# class, child of RazorPage<TModel>.
// By inserting our own class, we give all the views what they
// need, whatever the page:
//   - @T("KEY")          : the translation of a key (the equivalent of the
//                          Handlebars helper {{t 'KEY'}});
//   - Language           : "fr" or "en";
//   - CurrentUser        : the connected user, for the navigation bar;
//   - FlashMessage       : the flash message of the request;
//   - CurrentPath        : the requested URL (active menu of the navigation bar).
// In NestJS, this information went through res.locals.
// =========================================================================

using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.Razor.Internal;
using RdvMedecins.Auth;

namespace RdvMedecins.Web;

public abstract class ViewPage<TModel> : RazorPage<TModel>
{
    // [RazorInject]: the property is filled by dependency injection,
    // as the @inject directive would do in a view
    [RazorInject]
    public Translator Translator { get; set; } = null!;

    // -----------------------------------------------------------------------
    // @T("BOOKING_DIALOG.SLOT", ("time", "8:00 - 8:20"))
    // The parameters are "tuples" (name, value): a compact syntax
    // to write pairs of values.
    // The returned text is then ESCAPED by Razor (< becomes &lt;): a name
    // typed by a user cannot therefore inject HTML into the page.
    // -----------------------------------------------------------------------
    public string T(string? key, params (string Name, object? Value)[] messageParams) =>
        Translator.Translate(key, messageParams.ToDictionary(p => p.Name, p => p.Value?.ToString() ?? ""));

    // the same, with the parameters of a flash message or of a BusinessException
    public string T(string? key, IReadOnlyDictionary<string, string>? messageParams) => Translator.Translate(key, messageParams);

    public string Language => Translator.CurrentLanguage();

    public ViewUser? CurrentUser => ViewUser.Of(Context.User.CurrentUser());

    public FlashMessage? FlashMessage => Flash.Read(Context);

    public string CurrentPath => Context.Request.Path.Value ?? "/";

    // "active" if the current page is under this path (menu of the navigation bar)
    public string Active(string prefix) => CurrentPath.StartsWith(prefix, StringComparison.Ordinal) ? "active" : "";
}

// -------------------------------------------------------------------------
// the connected user, as the views see them
// -------------------------------------------------------------------------
public record ViewUser(string Login, string Name, string Role, bool IsAdmin, bool IsPatient)
{
    public static ViewUser? Of(ConnectedUser? u) =>
        u is null
            ? null
            : new ViewUser(
                u.Login,
                u.Name,
                u.Role.ToString(),
                IsAdmin: u.Role == Entities.Role.ADMIN, // ADMIN: Doctors / Patients menus
                IsPatient: u.Role == Entities.Role.USER); // USER: My area menu
}

Let’s break down this code:

  • line 25: the parent class of all views, designated by the [@inherits] directive of [Views/_ViewImports.cshtml], as in Example 22. It serves the same purpose as [res.locals] in version [NestJS]: it provides each view with what all pages need;
  • lines 29–30: the translation service, injected into the property (see Example 22);
  • lines 39–40: the [T] method from Example 22: [@T("KEY", ("name", value))]. The rendered text is escaped by [Razor]: a name entered by a user cannot inject HTML into the page;
  • line 43: a second version of [T], which directly accepts a dictionary: this is the form in which the parameters of a flash message or a [BusinessException] are passed. Two methods with the same name, which differ in their parameters, are overloaded methods: the compiler selects the correct one based on the arguments;
  • line 45: the current language ([fr] or [en]), for the page’s [lang] attribute and the FR / EN;
  • Line 47: the logged-in user, for the navigation bar, or [null] if there is none. [Context.User] is the request user, reconstructed from the token JWT by the authentication middleware (see Chapter 8); the method [CurrentUser()] (file [Auth/ConnectedUser.cs]) extracts the account from it. In version [NestJS], an interceptor was required to copy [req.user] into [res.locals] after the guards; here, the property is calculated when the view reads it, well after authentication;
  • line 49: the request’s flash message (see [Web/Flash.cs], below);
  • lines 51–54: the path of the requested URL, and the method that returns [active] if the current page is under a given path: this is how the navigation bar highlights the current menu item, for example [@Active("/my-")] (see Example 16). [StringComparison.Ordinal] compares characters one by one, regardless of language;
  • lines 60–70: the logged-in user as seen by the views: login, name, role, and two Booleans, [IsAdmin] and [IsPatient], which prevent the views from comparing roles. The static method [Of] creates this object from the logged-in account, or returns [null]; the operator [? :] chooses between the two. The arguments [IsAdmin:] and [IsPatient:] are named: the parameter name is written before the value, which makes the call more readable.

11.14.3. Web/Flash.cs

// =========================================================================
// Web/Flash.cs
// -------------------------------------------------------------------------
// The "flash" messages: a message to display ONCE, on the
// NEXT page. It is the natural complement of the "Post / Redirect / Get" pattern
// used by all the forms of the application: a successful POST does not
// build a page, it redirects (302) to a GET page - if
// the user then reloads this page (F5), the browser does not send
// the form a second time. The message, however, must survive the
// redirection.
//
// In NestJS, we had programmed it ourselves (a short-lived cookie).
// ASP.NET Core MVC offers it as standard: it is TempData. A value stored
// in TempData is kept (by default in an encrypted cookie) until
// a following request READS it; it is then erased.
// =========================================================================

using System.Text.Json;
using Microsoft.AspNetCore.Mvc.ViewFeatures;

namespace RdvMedecins.Web;

public enum FlashType
{
    Success, // Bootstrap alert-success class: at the top of the page
    Danger, // an error: at the bottom of the page (see Views/Shared/_Errors.cshtml)
}

// Key: translation key; Params: its parameters ({name}, {login}...)
public record FlashMessage(FlashType Type, string Key, Dictionary<string, string>? Params = null);

public static class Flash
{
    private const string Key = "flash";

    // -----------------------------------------------------------------------
    // puts a flash message, which will be displayed by the NEXT page.
    // We go through ITempDataDictionaryFactory (and not through the TempData property
    // of a controller) to be able to put a message from ANYWHERE:
    // a controller, but also the JWT authentication (expired session, see
    // Auth/JwtAuthentication.cs), which runs outside MVC.
    // -----------------------------------------------------------------------
    public static void Put(HttpContext http, FlashType type, string key, Dictionary<string, string>? messageParams = null)
    {
        var tempData = TempDataOf(http);
        // TempData can only keep simple values: we store the
        // message as JSON text
        tempData[Key] = JsonSerializer.Serialize(new FlashMessage(type, key, messageParams));
        // Save(): writes the cookie right away (outside MVC, nobody
        // would do it for us)
        tempData.Save();
    }

    // -----------------------------------------------------------------------
    // reads (and therefore erases) the flash message of the request. The page may
    // ask for it several times (the success message is displayed at the top, the error
    // one at the bottom): we keep it in HttpContext.Items, a dictionary
    // that lives for the time of the request.
    // -----------------------------------------------------------------------
    public static FlashMessage? Read(HttpContext http)
    {
        if (http.Items.TryGetValue(Key, out var alreadyRead))
        {
            return alreadyRead as FlashMessage;
        }
        FlashMessage? message = null;
        if (TempDataOf(http)[Key] is string json)
        {
            try
            {
                message = JsonSerializer.Deserialize<FlashMessage>(json);
            }
            catch (JsonException)
            {
                // unreadable message: we ignore it
            }
        }
        http.Items[Key] = message;
        return message;
    }

    private static ITempDataDictionary TempDataOf(HttpContext http) =>
        http.RequestServices.GetRequiredService<ITempDataDictionaryFactory>().GetTempData(http);
}

Let’s comment on this code:

  • lines 23–26: the two types of messages: a success message, displayed at the top of the page (partial view [_Flash.cshtml]), and an error message, displayed at the bottom (partial view [_Errors.cshtml]);
  • line 30: a flash message consists of a translation key and its parameters, not pre-translated text; it will be translated on the next page, in that page’s language;
  • line 32: a static class that contains only functions (see [Web/Utils.cs]). [TempData] (see Example 20) does most of the work: it stores the value in an encrypted cookie until the next request and deletes it once it has been read. In the [NestJS] version, this short-lived cookie had been programmed manually;
  • lines 43–51: sets a message. [TempData] can only store simple values (text, numbers): the message is stored as text JSON ([JsonSerializer.Serialize]). The method receives [HttpContext] and not the [TempData] property of a controller: it can therefore be called from outside MVC, for example by the authentication JWT, when a session has expired (see [Auth/JwtAuthentication.cs]). Outside of MVC, no one would save [TempData] at the end of the request: [Save()] writes the cookie immediately;
  • Lines 60–79: reads the request message. Reading [TempData] marks it as consumed; however, the page requests it twice (at the top for a success, at the bottom for an error). The first call therefore stores the result in [HttpContext.Items], the request-scope dictionary (see Chapter 6), where the second call retrieves it (line 62);
  • lines 71–73: [Deserialize] reconstructs the object [FlashMessage] from JSON. An unreadable text should not cause the page to fail: the exception is caught and the message is ignored;
  • lines 82–83: the [TempData] from the request, obtained from the [ITempDataDictionaryFactory] service. [RequestServices] provides access to dependency injection from a static class, which cannot receive services via its constructor.

11.14.4. Web/HtmlExceptionFilter.cs

// =========================================================================
// Web/HtmlExceptionFilter.cs
// -------------------------------------------------------------------------
// How the errors become HTML pages. Three mechanisms
// of ASP.NET Core share the work (see Program.cs):
//
//   1. this MVC exception filter: an InvalidSessionException (account
//      deleted since the login) -> back to the login page;
//   2. UseStatusCodePagesWithReExecute("/error/{0}"): any 400,
//      403 or 404 response WITHOUT content (return BadRequest(), insufficient role, unknown
//      page...) is replaced by the corresponding error page;
//   3. UseExceptionHandler("/error/500"): any other exception (a real
//      anomaly) is written to the server log, and the user
//      sees the 500 error page - never the call stack.
//
// In NestJS, a single filter (HtmlExceptionFilter) did all this.
// =========================================================================

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using RdvMedecins.Auth;

namespace RdvMedecins.Web;

public class HtmlExceptionFilter : IExceptionFilter
{
    public void OnException(ExceptionContext context)
    {
        if (context.Exception is InvalidSessionException)
        {
            AuthService.ClearToken(context.HttpContext.Response);
            Flash.Put(context.HttpContext, FlashType.Danger, "ERRORS.SESSION_EXPIRED");
            context.Result = new RedirectResult("/login");
            context.ExceptionHandled = true;
        }
        // the other exceptions are not handled here: they go up
        // to UseExceptionHandler (500 page)
    }
}

Let’s comment on this code:

  • Line 25: An exception filter, as in Example 24, added as a global filter in [Program.cs]. In version [NestJS], a single filter converted all errors into HTML pages. Here, the work is shared between two middleware components of the framework, configured in [Program.cs]: [UseStatusCodePagesWithReExecute] replaces 400, 403, and 404 responses with no content with the corresponding error page (see Example 24), and [UseExceptionHandler("/error/500")] intercepts all other exceptions: the error is logged in the server log, along with its call stack, but the user sees only the 500 error page, as these details could assist an attacker;
  • lines 29–34: the filter handles only one scenario: the logged-in user’s account has been deleted since they logged in, even though their token is still valid (see [Web/Utils.cs], below). The token cookie is cleared, a “session expired” flash message is displayed, and the user is redirected to the login page. The other exceptions are not marked as handled: they continue on to [UseExceptionHandler].

11.14.5. Web/ApplicationModel.cs

// =========================================================================
// Web/ApplicationModel.cs
// -------------------------------------------------------------------------
// The cache / facade already present in the JSON server (equivalent of the
// [ApplicationModel] class of the Spring project): the lists of doctors and
// clients are read once at startup, then served from memory.
//
// These lists can CHANGE (the ADMIN adds, edits, deletes
// doctors and clients): each write therefore goes through this facade,
// which reloads the list concerned right after. That is the whole point of a
// SINGLE entry point to the business layer: the rule "write =>
// cache reload" is written only here.
//
// Important difference with NestJS: the LIFETIME of the objects.
//   - In NestJS, all the services were singletons.
//   - Here, the DbContext - hence the repositories and the business layer - lives
//     for the time of ONE HTTP request ("scoped" lifetime, see Program.cs).
//   - But the cache must survive from one request to the next.
// We therefore separate the two roles:
//   - ListCache (singleton): the two lists, and nothing else;
//   - ApplicationModel (scoped): the facade, which uses the business layer of
//     the current request and updates the cache.
// =========================================================================

using RdvMedecins.Domain;
using RdvMedecins.Entities;
using RdvMedecins.Business;

namespace RdvMedecins.Web;

// -------------------------------------------------------------------------
// The cache itself, shared by all the requests (singleton).
//
// ASP.NET Core processes the requests IN PARALLEL, on several threads (Node,
// for its part, has only one). We therefore never modify a list in place: we
// REPLACE it entirely with a new one. Swapping a reference is atomic;
// "volatile" guarantees that all the threads immediately see the new list.
// -------------------------------------------------------------------------
public class ListCache
{
    private volatile IReadOnlyList<Doctor> doctors = [];
    private volatile IReadOnlyList<Client> clients = [];

    public IReadOnlyList<Doctor> Doctors
    {
        get => doctors;
        set => doctors = value;
    }

    public IReadOnlyList<Client> Clients
    {
        get => clients;
        set => clients = value;
    }
}

// -------------------------------------------------------------------------
// The facade (scoped): same contract as the business layer (IBusiness).
// -------------------------------------------------------------------------
public class ApplicationModel(IBusiness business, ListCache cache) : IBusiness
{
    // initial loading of the two lists, called once at startup
    // (see Program.cs). If the database is unreachable, the exception stops the
    // server with an explicit message - clearer than starting "empty".
    public async Task LoadAsync()
    {
        await ReloadDoctorsAsync();
        await ReloadClientsAsync();
    }

    private async Task ReloadDoctorsAsync() => cache.Doctors = await business.GetAllDoctorsAsync();

    private async Task ReloadClientsAsync() => cache.Clients = await business.GetAllClientsAsync();

    // ------------------------------------------------ reads from the cache

    public Task<List<Doctor>> GetAllDoctorsAsync() => Task.FromResult(cache.Doctors.ToList());

    public Task<List<Client>> GetAllClientsAsync() => Task.FromResult(cache.Clients.ToList());

    // the doctor whose user account is [userId] - used to preselect,
    // for a logged-in doctor, their own schedule
    public Doctor? GetDoctorByUserId(int userId) => cache.Doctors.FirstOrDefault(d => d.UserId == userId);

    // ------------------------------------------------ simple delegations

    public Task<List<Slot>> GetAllSlotsAsync(int doctorId) => business.GetAllSlotsAsync(doctorId);

    public Task<List<Appointment>> GetDoctorDayAppointmentsAsync(int doctorId, DateOnly day) => business.GetDoctorDayAppointmentsAsync(doctorId, day);

    public Task<Client?> GetClientByIdAsync(int id) => business.GetClientByIdAsync(id);

    public Task<Doctor?> GetDoctorByIdAsync(int id) => business.GetDoctorByIdAsync(id);

    public Task<Appointment?> GetAppointmentByIdAsync(int id) => business.GetAppointmentByIdAsync(id);

    public Task<Slot?> GetSlotByIdAsync(int id) => business.GetSlotByIdAsync(id);

    public Task<DoctorDaySchedule?> GetDoctorDayScheduleAsync(int doctorId, DateOnly day) =>
        business.GetDoctorDayScheduleAsync(doctorId, day);

    // no cache here: it is the most recent data that matters (a patient
    // who has just edited or deleted their account)
    public Task<Client?> GetClientByUserIdAsync(int userId) => business.GetClientByUserIdAsync(userId);

    public Task<List<Appointment>> GetClientUpcomingAppointmentsAsync(int clientId) => business.GetClientUpcomingAppointmentsAsync(clientId);

    public Task<Appointment> AddAppointmentAsync(DateOnly day, Slot slot, Client client) => business.AddAppointmentAsync(day, slot, client);

    public Task DeleteAppointmentAsync(Appointment appointment) => business.DeleteAppointmentAsync(appointment);

    // ------------------------------------------------ writes => reload

    public async Task<Doctor> AddDoctorAsync(PersonData data)
    {
        var doctor = await business.AddDoctorAsync(data);
        await ReloadDoctorsAsync();
        return doctor;
    }

    public async Task<Doctor> EditDoctorAsync(int id, PersonData data)
    {
        var doctor = await business.EditDoctorAsync(id, data);
        await ReloadDoctorsAsync();
        return doctor;
    }

    public async Task DeleteDoctorAsync(int id)
    {
        await business.DeleteDoctorAsync(id);
        await ReloadDoctorsAsync();
    }

    public async Task<Client> AddClientAsync(PersonData data)
    {
        var client = await business.AddClientAsync(data);
        await ReloadClientsAsync();
        return client;
    }

    public async Task<Client> EditClientAsync(int id, PersonData data)
    {
        var client = await business.EditClientAsync(id, data);
        await ReloadClientsAsync();
        return client;
    }

    public async Task DeleteClientAsync(int id)
    {
        await business.DeleteClientAsync(id);
        await ReloadClientsAsync();
    }
}

Lists of doctors and clients are displayed on almost every page (calendar drop-down lists, administration pages). Rather than retrieving them from the database with every request, they are kept in memory: this is application-scope data (see Chapter 6). Version [NestJS] assigned this role to a single service, which was a singleton like all [NestJS] services. In [ASP.NET Core], this is impossible: the business layer uses [DbContext], which exists only for the duration of a request ([Scoped] service; see Chapter 9), and a singleton cannot accept a [Scoped] service. We therefore separate the two roles into two classes.

Let’s comment on this code:

  • line 39: the cache itself, registered as a singleton ([AddSingleton<ListCache>()] within [Program.cs]). It contains only the two lists;
  • Lines 41–42: [ASP.NET Core] processes requests in parallel across multiple threads ([threads]), whereas [Node.js] has only one. A shared list must therefore never be modified while another request is iterating through it. The rule we’ve chosen is simple: we never modify a list; we replace it with a new one. Replacing a reference is done in a single operation, and the keyword [volatile] ensures that all threads immediately see the new list. [IReadOnlyList] allows only reading: the compiler prohibits adding or removing an element;
  • Line 60: the facade, registered as service [Scoped] ([AddScoped<ApplicationModel>()]). It receives the business layer for the current request and the shared cache. It implements the same [IBusiness] interface as the business layer: for the controllers, it is the business layer, and it is the only one they receive;
  • Lines 65–68: the initial loading of the two lists. It is called once, by [Program.cs], before the server starts. A [Scoped] service exists only within a scope: [Program.cs] creates one for this purpose ([CreateScope]). If the database is inaccessible, the exception terminates the application with an explicit message, which is better than starting a server with empty lists;
  • lines 71–73: reloading a list: the entire list is read from the database and replaces the one in the cache;
  • lines 77–79: the lists are served from memory. The [IBusiness] interface declares an asynchronous method: [Task.FromResult] creates a task that has already completed, which carries the result. [ToList()] returns a copy of the list: the caller can modify it without affecting the cache;
  • line 83: the doctor associated with an account, retrieved from the cache: a logged-in doctor immediately sees their own schedule;
  • lines 87–110: the other requests are forwarded as-is to the business layer;
  • line 104: a client associated with an account is intentionally not looked up in the cache; a patient who has just modified or deleted their account must be displayed as they appear in the database;
  • lines 114–151: Any write operation involving doctors or clients is followed by a refresh of the relevant list. Since this service is the only entry point for controllers to the business layer, the rule “write = cache refresh” is implemented only here, and the cache cannot become stale. If the business layer rejects the operation ([BusinessException]), the exception terminates the method before the cache is reloaded, as this step is no longer necessary.

11.14.6. Web/Validation.cs

// =========================================================================
// Web/Validation.cs
// -------------------------------------------------------------------------
// What is needed to REDISPLAY a refused form: the input errors
// field by field, and the fields to outline in red.
// =========================================================================

using Microsoft.AspNetCore.Mvc.ModelBinding;
using RdvMedecins.Models;

namespace RdvMedecins.Web;

public static class Validation
{
    // -----------------------------------------------------------------------
    // ModelState (filled by MVC when binding the form) -> the
    // field -> message key dictionary, keeping only the first
    // message of each field: { "LastName": "ERRORS.LASTNAME_REQUIRED", ... }
    // -----------------------------------------------------------------------
    public static Dictionary<string, string> ErrorsOf(ModelStateDictionary modelState)
    {
        var errors = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        foreach (var (field, entry) in modelState)
        {
            if (entry.Errors.Count > 0)
            {
                var message = entry.Errors[0].ErrorMessage;
                errors[field] = string.IsNullOrEmpty(message) ? "ERRORS.INVALID_FORM" : message;
            }
        }
        return errors;
    }

    // -----------------------------------------------------------------------
    // the form fields concerned by each "input" business error
    // (a duplicate): they are outlined in red, the message being at the bottom of the page
    // -----------------------------------------------------------------------
    public static readonly Dictionary<string, string[]> BusinessErrorFields = new()
    {
        ["ERRORS.LOGIN_TAKEN"] = ["login"],
        ["ERRORS.DOCTOR_ALREADY_EXISTS"] = ["firstName", "lastName"],
        ["ERRORS.PATIENT_ALREADY_EXISTS"] = ["firstName", "lastName"],
    };

    // -----------------------------------------------------------------------
    // prepares a form model for its (re)display:
    //   errors       : message under each field refused by the validation
    //   error        : THE error message of the page, displayed at the bottom of the page;
    //                  by default "The form contains errors." if there
    //                  are field errors
    //   otherFields  : fields to outline in red WITHOUT their own message
    // -----------------------------------------------------------------------
    public static T Prepare<T>(
        T model,
        Dictionary<string, string>? errors = null,
        string? error = null,
        IEnumerable<string>? otherFields = null,
        IReadOnlyDictionary<string, string>? errorParams = null) where T : FormView
    {
        model.Errors = errors ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        model.InvalidFields = new HashSet<string>(model.Errors.Keys.Concat(otherFields ?? []), StringComparer.OrdinalIgnoreCase);
        model.Error = error ?? (model.Errors.Count > 0 ? "ERRORS.INVALID_FORM" : null);
        model.ErrorParams = errorParams;
        return model;
    }
}

Let’s comment on this code:

  • Lines 20–31: Convert [ModelState] (see examples 07 and 19) into a simple “field → message key” dictionary. Only the first message for each field is retained: a single message is displayed under the field. The dictionary ignores uppercase letters (line 22): [ModelState] names the fields based on the properties ([LastName], [Login]), whereas the views request them based on the HTML fields ([lastName], [login]);
  • lines 27–28: As a precaution, an error without a message (such as one caused by an exception) is assigned the generic message [ERRORS.INVALID_FORM];
  • lines 38–42: certain errors in the business layer relate to specific fields: a username that is already taken, or a doctor or client with the same name. These fields will be highlighted in red, with the message displayed at the bottom of the page. The syntax [["key"] = value] populates the dictionary as soon as it is created;
  • lines 53–64: prepares a form template for display or redisply: the messages below the fields ([Errors]), the fields to be framed in red ([InvalidFields]: those with a message, plus the [otherFields] fields, line 61), the message at the bottom of the page, and its parameters. In the absence of an explicit message, field errors result in the message “The form contains errors” (line 62). The method is generic: the constraint [where T : FormView] (line 58) accepts any model derived from [FormView] (see [Models/ViewModels.cs]), and it returns the model with its exact type, ready to be passed to [View]. The parameters have default values ([= null]): the caller provides only those it needs.

11.14.7. Web/Utils.cs

// =========================================================================
// Web/Utils.cs
// -------------------------------------------------------------------------
// Small utility functions of the web layer:
//   - Labels   : "Mme Alice DURAND";
//   - WebDates : dates "yyyy-MM-dd", times "8:05", days "Wednesday 23 September 2026";
//   - Input    : reading of a positive integer sent by the browser;
//   - Patient  : the patient (Client entity) of the connected user.
//
// "static" classes: they only contain functions, we never create
// an object of these classes.
// =========================================================================

using System.Globalization;
using System.Security.Claims;
using RdvMedecins.Auth;
using RdvMedecins.Entities;

namespace RdvMedecins.Web;

public static class Labels
{
    // "Mme Alice DURAND"
    public static string Label(Person p) => $"{p.Title} {p.FirstName} {p.LastName}";
}

public static class WebDates
{
    // the format of the dates in the URLs and in the <input type="date"> field
    public const string IsoFormat = "yyyy-MM-dd";

    // true if [day] is a date that exists in the yyyy-MM-dd format
    // ("2026-02-30" is refused)
    public static bool IsValidDate(string? text, out DateOnly day) =>
        DateOnly.TryParseExact(text, IsoFormat, CultureInfo.InvariantCulture, DateTimeStyles.None, out day);

    public static string Iso(DateOnly day) => day.ToString(IsoFormat, CultureInfo.InvariantCulture);

    // "8:05" rather than "8:5"
    public static string FormatTime(int h, int m) => $"{h}:{m:00}";

    public static string Time(Slot s) => $"{FormatTime(s.StartHour, s.StartMinute)} - {FormatTime(s.EndHour, s.EndMinute)}";

    // "mercredi 23 septembre 2026" / "Wednesday 23 September 2026", depending on the
    // language of the request (CultureInfo.CurrentCulture, set by the
    // localization middleware - see Program.cs)
    public static string FormatDay(DateOnly day) =>
        day.ToString("dddd d MMMM yyyy", CultureInfo.CurrentCulture);
}

public static class Input
{
    // "3" -> 3 ; "", "abc", "-2", null -> null
    public static int? PositiveInteger(string? value) =>
        int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var n) && n > 0 ? n : null;
}

public static class Patient
{
    // -----------------------------------------------------------------------
    // The patient (Client entity) that corresponds to the connected user.
    //
    // The JWT token only contains the id of the ACCOUNT (users.id). For a
    // USER role, we find the corresponding client thanks to the clients.user column.
    //
    // If there is none, the account has been deleted since the
    // login while its token is still valid: we treat it as an
    // expired session (see InvalidSessionException).
    // -----------------------------------------------------------------------
    public static async Task<Client> ConnectedPatientAsync(ApplicationModel application, ClaimsPrincipal user)
    {
        var currentUser = user.CurrentUser() ?? throw new InvalidSessionException();
        return await application.GetClientByUserIdAsync(currentUser.UserId) ?? throw new InvalidSessionException();
    }
}

Let’s break down this code:

  • line 24: the name of a doctor or a client: “Mme Alice DURAND.” The parameter is of type [Person], the parent class of entities [Doctor] and [Client]: the same function is used for both;
  • line 30: the date format in the URL entities and in the [<input type="date">] field;
  • Lines 34–35: true if the text is a date in the [yyyy-MM-dd] format that exists; [2026-02-30] is rejected (see Example 12). The result is returned in the parameter [out day]. [CultureInfo.InvariantCulture] makes the analysis independent of the query language;
  • line 37: the reverse operation: a date to the text [yyyy-MM-dd];
  • lines 40–42: a time in the [8:05] format (the [00] format requires two digits for the minutes), and the time of a time slot: [8:00 - 8:20];
  • lines 47–48: a day written out in full, according to the request’s locale, as determined by the localization middleware (see Example 22): “mercredi 23 septembre 2026” or “Wednesday 23 September 2026”;
  • lines 54–55: an identifier sent by the browser (in the URL or a form field): a strictly positive integer, or [null]. [NumberStyles.None] accepts only digits: no signs, spaces, or thousands separators. The controller simply needs to test [null];
  • lines 70–73: the patient (entity [Client]) associated with the logged-in user. The token JWT contains only the account ID; we look for the customer associated with that account. If it no longer exists, it means the account has been deleted since the user logged in: [InvalidSessionException] will be processed by the [HtmlExceptionFilter] filter described above, which redirects to the login page. [?? throw] throws an exception when the value on the left is [null] (see Chapter 9).

11.15. Form and View Templates

Two types of classes transport data between the browser, controllers, and views: forms, populated by the model binding based on what the browser sends, and view models, prepared by controllers for the views. The former correspond to the DTO objects in version [NestJS], while the latter correspond to the objects passed to [res.render].

11.15.1. Models/Forms.cs

// =========================================================================
// Models/Forms.cs
// -------------------------------------------------------------------------
// The fields of the "person" forms and their constraints. Three
// forms share the same identity fields (title, first name, last name):
//
//   PersonFields                  title, first name, last name
//    ├─ PersonForm                + OPTIONAL login / password, version
//    │                              (the ADMIN adds or edits a doctor / client)
//    ├─ RegistrationForm          + REQUIRED login / password / confirmation
//    │                              (a patient creates their account)
//    └─ MyAccountForm             + login, optional new password,
//                                   REQUIRED CURRENT password, version
//                                   (a patient edits their account)
//
// MODEL BINDING: when a controller action receives
// a parameter of one of these classes, ASP.NET Core MVC creates the object and
// fills each property with the form field of the same name (regardless
// of case: the "lastName" field fills the LastName property).
//
// VALIDATION: right after, MVC checks the [Required],
// [MaxLength]... attributes (standard namespace System.ComponentModel.DataAnnotations,
// the equivalent of class-validator) and stores the errors in ModelState.
// The controller only has to test ModelState.IsValid.
//
// The error messages are translation KEYS (see i18n/*.json).
//
// INPUT CLEANING (the equivalent of the @Transform of class-transformer):
//   - MVC already turns an empty field into null;
//   - the setters below remove the superfluous spaces and put the
//     login in lower case. A password, however, is not "cleaned" (the
//     spaces may be part of it).
// All the properties are "nullable" (string?): a field may always be
// missing from what the browser sends; it is [Required] that requires it.
// =========================================================================

using System.ComponentModel.DataAnnotations;

namespace RdvMedecins.Models;

public static class InputRules
{
    public static readonly string[] Titles = ["Mr", "Mme", "Melle"];

    // login: lower-case letters, digits, dot, hyphen, underscore - 30 characters at most
    public const string LoginFormat = "^[a-z0-9._-]{1,30}$";

    // minimum length of a password CHOSEN by a patient
    public const int PasswordMin = 6;

    // "  dupont " -> "dupont" ; "   " -> null
    public static string? Clean(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

// -------------------------------------------------------------------------
// A "home-made" validation attribute: the value must belong to a list.
// It is enough to inherit from ValidationAttribute and write IsValid.
// -------------------------------------------------------------------------
public class OneOfAttribute(params string[] values) : ValidationAttribute
{
    // null is accepted here: it is the role of [Required] to require it
    public override bool IsValid(object? value) => value is null || values.Contains(value as string);
}

// ------------------------------------------------------------------ common base
public class PersonFields
{
    [Required(ErrorMessage = "ERRORS.INVALID_TITLE")]
    [OneOf("Mr", "Mme", "Melle", ErrorMessage = "ERRORS.INVALID_TITLE")]
    public string? Title { get; set; }

    // maximum lengths = those of the SQL columns (VARCHAR(20))
    private string? firstName;

    [Required(ErrorMessage = "ERRORS.FIRSTNAME_REQUIRED")]
    [MaxLength(20, ErrorMessage = "ERRORS.FIRSTNAME_TOO_LONG")]
    public string? FirstName
    {
        get => firstName;
        set => firstName = InputRules.Clean(value);
    }

    private string? lastName;

    [Required(ErrorMessage = "ERRORS.LASTNAME_REQUIRED")]
    [MaxLength(20, ErrorMessage = "ERRORS.LASTNAME_TOO_LONG")]
    public string? LastName
    {
        get => lastName;
        set => lastName = InputRules.Clean(value);
    }
}

// ------------------------------------------------------------------ ADMIN
public class PersonForm : PersonFields
{
    private string? login;

    // optional (see PersonData, Business/IBusiness.cs)
    [RegularExpression(InputRules.LoginFormat, ErrorMessage = "ERRORS.INVALID_LOGIN")]
    public string? Login
    {
        get => login;
        set => login = InputRules.Clean(value)?.ToLowerInvariant();
    }

    // optional; 72 characters at most (beyond that, bcrypt ignores the rest)
    [MaxLength(72, ErrorMessage = "ERRORS.PASSWORD_TOO_LONG")]
    public string? Password { get; set; }

    // hidden field of the edit form (optimistic lock).
    // int?: a missing or empty field gives null; a text that is not a
    // number gives a binding error (see Program.cs, ModelBindingMessageProvider)
    public int? Version { get; set; }
}

// ------------------------------------------------------------------ registration of a patient
public class RegistrationForm : PersonFields
{
    private string? login;

    [Required(ErrorMessage = "ERRORS.LOGIN_REQUIRED")]
    [RegularExpression(InputRules.LoginFormat, ErrorMessage = "ERRORS.INVALID_LOGIN")]
    public string? Login
    {
        get => login;
        set => login = InputRules.Clean(value)?.ToLowerInvariant();
    }

    [Required(ErrorMessage = "ERRORS.PASSWORD_REQUIRED")]
    [MinLength(InputRules.PasswordMin, ErrorMessage = "ERRORS.PASSWORD_TOO_SHORT")]
    [MaxLength(72, ErrorMessage = "ERRORS.PASSWORD_TOO_LONG")]
    public string? Password { get; set; }

    [Required(ErrorMessage = "ERRORS.CONFIRMATION_REQUIRED")]
    public string? Confirmation { get; set; }
}

// ------------------------------------------------------------------ "Manage my account"
public class MyAccountForm : PersonFields
{
    private string? login;

    [Required(ErrorMessage = "ERRORS.LOGIN_REQUIRED")]
    [RegularExpression(InputRules.LoginFormat, ErrorMessage = "ERRORS.INVALID_LOGIN")]
    public string? Login
    {
        get => login;
        set => login = InputRules.Clean(value)?.ToLowerInvariant();
    }

    // optional: empty = password unchanged
    [MinLength(InputRules.PasswordMin, ErrorMessage = "ERRORS.PASSWORD_TOO_SHORT")]
    [MaxLength(72, ErrorMessage = "ERRORS.PASSWORD_TOO_LONG")]
    public string? Password { get; set; }

    public string? Confirmation { get; set; }

    // the CURRENT password, required for any modification of the account
    [Required(ErrorMessage = "ERRORS.CURRENT_PASSWORD_REQUIRED")]
    public string? CurrentPassword { get; set; }

    public int? Version { get; set; }
}

Let's discuss this code:

  • line 43: possible titles;
  • line 46: the format of a username, described by a regular expression: 1 to 30 characters consisting of lowercase letters, digits, a period, a hyphen, and an underscore;
  • line 49: the minimum length of a password chosen by a patient. This is a constant ([const]), so it can be used as an argument for an attribute, such as [[MinLength(InputRules.PasswordMin)]] below;
  • line 52: cleaning up an entry: leading and trailing spaces are removed, and a field consisting solely of spaces becomes [null], meaning it is invalid;
  • lines 59–62: a validation attribute we wrote: the value must appear in a list. All we need to do is derive it from [ValidationAttribute] and redefine [IsValid]. [null] is accepted: it is the role of [[Required]] to reject a missing field. We use it under the name [[OneOf]]: by convention, the suffix [Attribute] can be omitted;
  • Line 66: the identity fields, common to all three forms. All properties are of type [string?]: a field may always be missing in the data sent by the browser, and it is [[Required]] that requires it. Error messages are translation keys (see Example 22);
  • Lines 68–70: the title, which is required and selected from the list. The values are repeated in [[OneOf(...)]]: an attribute’s argument must be known at compile time, which is not the case for the array [InputRules.Titles] (a [static readonly] field, created at runtime);
  • lines 73–80 and lines 83–90: the first name and last name, which are required and may be up to 20 characters long (the width of the SQL columns). These properties have a body: their value is stored in a private field, and [set] cleans it up as it passes through. Since the model binding affects the properties before validation, it is the cleaned-up value that is validated. This is equivalent to the [@Transform] in version [NestJS]; [ASP.NET Core MVC] already automatically converts an empty field to [null];
  • line 95: the ADMIN form, for adding or editing a doctor or a client. The class inherits the identity fields and their validation attributes;
  • lines 97–104: the login, which is optional here but must be in the correct format if present (line 100); it is sanitized and converted to lowercase. [?.] calls [ToLowerInvariant] only if the value is not [null];
  • lines 108–109: the password, also optional, with a maximum of 72 characters: the [bcrypt] algorithm, which encrypts it, ignores any characters following it. A password is not sanitized: spaces are included as part of it;
  • line 114: the record’s version, in a hidden field of the update form (optimistic lock; see Example 28). A missing or empty field results in [null]; text that is not a number produces a binding error, whose message is configured in [Program.cs] (see Example 19);
  • Line 118: A patient creates an account: username, password (6 to 72 characters, line 131), and confirmation (lines 135–136) are required. Verifying that the password matches its confirmation involves two fields: this is checked by the validator ([Controllers/RegistrationController.cs]);
  • Line 140: A patient modifying their account. The new password is optional (line 153): if left blank, the password remains unchanged; its confirmation (line 157) is therefore not required here. The current password, however, is required for any changes (lines 160–161), and the record version is stored in a hidden field (line 163).

11.15.2. Models/ViewModels.cs

// =========================================================================
// Models/ViewModels.cs
// -------------------------------------------------------------------------
// The "view models": what each controller passes to
// its view (return View("...", model)), in a TYPED C# class. In a
// Razor view, @model ScheduleModel declares the type of the model: the compiler
// then checks each @Model.Xxx of the view - a typo is a
// compilation error, and not an empty area in the page.
//
// Rule already followed with Handlebars: no logic in the views. The
// controllers prepare data ALREADY formatted ("Mme Marie
// PELISSIER", "8:00 - 8:20", Selected = true...) and already filtered according to
// the role of the user; the views merely place them in the HTML.
// =========================================================================

namespace RdvMedecins.Models;

// -------------------------------------------------------------------------
// what ALL the pages have in common, read by the layout
// (Views/Shared/_Layout.cshtml)
// -------------------------------------------------------------------------
public abstract class ViewModel
{
    // translation key of the page title (<title>)
    public string PageTitle { get; set; } = "APP.TITLE";

    // THE error message of the page, displayed at the bottom of the page
    // (see Views/Shared/_Errors.cshtml); null = no error
    public string? Error { get; set; }

    public IReadOnlyDictionary<string, string>? ErrorParams { get; set; }
}

// an option of a drop-down list (doctor, patient)
public record OptionView(int Id, string Label, bool Selected);

// ------------------------------------------------------------------ login
public class LoginModel : ViewModel
{
    public string Login { get; set; } = "";
    public required Auth.Captcha Captcha { get; set; }
}

// ------------------------------------------------------------------ schedule
public class ScheduleRowView
{
    public int SlotId { get; init; }
    public string Time { get; init; } = ""; // "8:00 - 8:20"
    public bool Free { get; init; }
    public string? Patient { get; init; } // name displayed for a booked slot; null = "Booked"
    public bool Mine { get; init; } // the appointment of the connected patient
    public bool Bookable { get; init; } // display the "Book" button?
    public string BookUrl { get; init; } = "";
    public int? AppointmentId { get; init; } // filled only if the "Cancel" button is displayed
}

public record ScheduleView(string Doctor, string Day, List<ScheduleRowView> Rows);

public class BookingView
{
    public int SlotId { get; init; }
    public string Time { get; init; } = "";
    // ADMIN / DOCTOR: the list of the patients to choose from
    public List<OptionView> Clients { get; init; } = [];
    // USER: the patient is themselves
    public string? Patient { get; init; }
}

public class ScheduleModel : ViewModel
{
    public List<OptionView> Doctors { get; set; } = [];
    public int? DoctorId { get; set; }
    public string Day { get; set; } = ""; // "yyyy-MM-dd"
    public ScheduleView? Schedule { get; set; }
    public BookingView? Booking { get; set; }
    public string BackUrl { get; set; } = "/schedule"; // the displayed schedule, without booking dialog
    // patient who already has an upcoming appointment: the message that explains
    // why there is no "Book" button
    public string? Info { get; set; }
}

// ------------------------------------------------------------------ "person" forms

// the values displayed in the form fields (never the password)
public class PersonValues
{
    public string Title { get; set; } = "Mr";
    public string FirstName { get; set; } = "";
    public string LastName { get; set; } = "";
    public string Login { get; set; } = "";
    public int? Version { get; set; }
}

// what the common partial _PersonFields.cshtml uses (title, first name, last name)
public abstract class FormView : ViewModel
{
    public PersonValues Values { get; set; } = new();

    // field -> key of the message displayed UNDER this field (input error)
    public Dictionary<string, string> Errors { get; set; } = new(StringComparer.OrdinalIgnoreCase);

    // fields to outline in red (Bootstrap is-invalid class): those that have
    // a message, plus possibly others (duplicate first + last name, login taken...)
    public HashSet<string> InvalidFields { get; set; } = new(StringComparer.OrdinalIgnoreCase);

    public string? ErrorOf(string field) => Errors.GetValueOrDefault(field);

    public string InvalidClass(string field) => InvalidFields.Contains(field) ? "is-invalid" : "";

    public IEnumerable<(string Value, bool Selected)> Titles =>
        InputRules.Titles.Select(t => (t, t == Values.Title));
}

public class PersonFormModel : FormView
{
    public bool Creating { get; set; } // true: add; false: edit
    public string Action { get; set; } = ""; // URL of the POST
    public string ListUrl { get; set; } = "";
}

public class RegistrationModel : FormView
{
    public int PasswordMin => InputRules.PasswordMin;
}

public class MyAccountModel : FormView
{
    public int PasswordMin => InputRules.PasswordMin;
}

// ------------------------------------------------------------------ doctor / client lists

// the translation keys specific to each type of person
public record PersonKeys(
    string Title, // "Doctors"
    string Add, // "Add a doctor"
    string Edit, // "Edit a doctor"
    string Empty, // "No doctors."
    string ConfirmDelete, // "Delete this doctor, their time slots and their user account?"
    string Added, // "Doctor {name} has been added (login: {login})."
    string Edited,
    string Deleted);

public record PersonView(string Title, string FirstName, string LastName, string Login, string EditUrl, string DeleteUrl);

public class PersonListModel : ViewModel
{
    public required PersonKeys Keys { get; set; }
    public string AddUrl { get; set; } = "";
    public List<PersonView> Persons { get; set; } = [];
}

// ------------------------------------------------------------------ patient area
public record AppointmentView(int Id, string Day, string Time, string Doctor, string ScheduleUrl);

public class AreaModel : ViewModel
{
    public string Patient { get; set; } = "";
    public List<AppointmentView> Appointments { get; set; } = [];
}

// ------------------------------------------------------------------ error page
public class ErrorModel : ViewModel
{
    public int Code { get; set; }
    public string Message { get; set; } = "ERRORS.SERVER_ERROR";
}

The view templates follow the rule already applied with [Handlebars]: no logic in the views. The controllers prepare data that is already formatted (“Mme Marie PELISSIER,” “8:00 – 8:20”) and already filtered according to the user’s role; the views simply place this data into the HTML. Each view declares the type of its template ([@model ScheduleModel], see Example 13): the compiler checks each [@Model.Xxx], and a typo results in a compilation error.

Let’s comment on this code:

  • lines 22–31: what all pages have in common, as read by the [_Layout.cshtml] template: the translation key for the page title (line 25), and the page’s error message, displayed at the bottom, along with its parameters. The class is abstract: each page has its own template, which derives from it;
  • line 35: an option from a drop-down list (a doctor, a patient), already marked as selected or unselected (see Example 18);
  • lines 38–41: the login page: the username already entered and the CAPTCHA to be displayed. The keyword [required] requires the person creating the object to assign a value to the property; failing to do so results in a compilation error;
  • lines 45–54: a calendar entry, i.e., a time slot. The controller has already determined everything: whether the patient’s name is displayed (a patient cannot see the names of others), whether the “Book” button is displayed, and whether the “Cancel” button is displayed ([AppointmentId] is set). The properties [init] can only be assigned a value when the object is created;
  • Line 57: A doctor's schedule for a single day: the doctor's name, the day spelled out in full, and the lines;
  • Lines 59–66: The appointment window: the list of patients for ADMIN and the doctors, or just the name of the logged-in patient;
  • lines 69–79: the calendar page: the list of doctors, the selected doctor and day, the calendar ([null] as long as no doctor is selected), the booking window (if present), the calendar URL without this window (line 76), and the message explaining to a patient why they cannot book an appointment;
  • lines 85–91: the values displayed in the fields of a “person” form, never the password: a password is not sent back to the browser;
  • line 95: the parent class of the templates for the three “person” forms, the one required by [Validation.Prepare]: the field values, the messages below the fields (line 100), and the fields to be highlighted in red (line 104). A [HashSet] is a set: each element appears only once, and the membership test is fast;
  • Lines 106–108: two methods for views: a field’s message (or [null]), and the class [Bootstrap] [is-invalid], which frames a field in red. Views are named, for example, [@Model.InvalidClass("login")];
  • lines 110–111: the options in the title list, with the selected option marked (see Example 18). Each option is a tuple [(Value, Selected)];
  • lines 114–118: the ADMIN form, used for adding and modifying entries (see Example 28): the mode, the form submission URL, and the list URL for the back button;
  • lines 121–128: the registration and account management forms; they pass the minimum password length to the view, which is used as the [{min}] parameter for the field’s error message and, for registration, for the help text;
  • lines 134–142: the doctor and client management pages are the same views; only their text differs. This [record] contains the translation keys specific to each type of person;
  • lines 144–150: a row in the list of doctors or clients, with the addresses of its Edit and Delete buttons, and the template for the list page;
  • lines 154–159: the patient’s area: their name and upcoming appointments, each with a link to the daily calendar;
  • lines 163–166: the error page: the code HTTP and the message key, which by default is that of a server error.

11.16. The Web Layer: Controllers

Controllers are the visible part of the web layer: each one receives requests from a group of pages, reads their parameters, queries the business layer (always through the [ApplicationModel] service), then selects a view and passes it a typed model, or redirects the request. All POST forms follow the Post/Redirect/Get pattern (see Example 20); all messages are translation keys, translated by the view. The login controller, [AuthController], was discussed with authentication.

11.16.1. Controllers/LanguageController.cs

// =========================================================================
// Controllers/LanguageController.cs
// -------------------------------------------------------------------------
// GET /language/fr, GET /language/en: the two FR / EN buttons of the navigation
// bar. Non-protected route: it must be possible to change language before
// logging in.
//
// The choice is stored in THE cookie of the localization middleware
// of ASP.NET Core (CookieRequestCultureProvider, see Program.cs): on each
// following request, this middleware reads it back and sets the language of the request
// (CultureInfo.CurrentCulture / CurrentUICulture). Then we go back to the
// page we came from.
// =========================================================================

using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc;
using RdvMedecins.Web;

namespace RdvMedecins.Controllers;

public class LanguageController : Controller
{
    [HttpGet("language/{language}")]
    public IActionResult Change(string language)
    {
        if (!Translator.Languages.Contains(language))
        {
            return NotFound(); // -> 404 error page (see Program.cs)
        }
        Response.Cookies.Append(
            CookieRequestCultureProvider.DefaultCookieName,
            // cookie value: "c=en|uic=en" (language of the formats, language of the texts)
            CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(language)),
            new CookieOptions { MaxAge = TimeSpan.FromDays(365), SameSite = SameSiteMode.Lax, HttpOnly = true, Path = "/" });
        return Redirect(OriginPage());
    }

    // the previous page, according to the HTTP "Referer" header sent by the
    // browser. We only keep its path (/schedule?...): redirecting to a
    // full address supplied by the request would allow sending
    // the user to another site (so-called "open redirect" flaw).
    private string OriginPage()
    {
        var referer = Request.Headers.Referer.ToString();
        if (!Uri.TryCreate(referer, UriKind.Absolute, out var url))
        {
            return "/";
        }
        return url.AbsolutePath.StartsWith("/language/", StringComparison.Ordinal) ? "/" : url.PathAndQuery;
    }
}

This is, except for one line, the controller from Example 22: same route [/language/{language}], same localization middleware cookie, same return to the original page via the header [Referer]. Two clarifications:

  • line 34: the option [Path = "/"] makes the cookie valid for all addresses on the site, regardless of the page from which the language was changed;
  • line 49: if the original page is itself a [/language/...] page, the user is redirected to the site’s root rather than back to that page, which would cause the browser to go in a loop.

11.16.2. Controllers/ScheduleController.cs

The application’s main page and the most feature-rich controller. Let’s break it down section by section.

11.16.2.1. The URL files on the page

// =========================================================================
// Controllers/ScheduleController.cs
// -------------------------------------------------------------------------
// The schedule page: choice of a doctor and a day, schedule of this
// doctor on that day, booking and cancellation of appointments.
//
//   GET  /schedule?doctorId=1&day=2026-09-23               the page
//   GET  /schedule?doctorId=1&day=2026-09-23&book=7        the same page, with
//                                                          the booking dialog
//                                                          of slot 7 open
//   POST /schedule/book                                    creates an appointment
//   POST /schedule/cancel                                  cancels an appointment
//
// The whole page is described by its URL: it can be reloaded, bookmarked,
// and the browser's "Back" button used - without a single line
// of JavaScript.
//
// What each one sees and can do:
//
//   | role          | booked slot displayed             | Book               | Cancel  |
//   |---------------|-----------------------------------|--------------------|---------|
//   | ADMIN, DOCTOR | name of the patient               | for a patient      | yes     |
//   |               |                                   | chosen in a list   |         |
//   | USER (patient)| "Booked", or THEIR name if it is  | for themselves, if | no (*)  |
//   |               | their own appointment             | they have no       |         |
//   |               |                                   | upcoming appt.     |         |
//
//   (*) the patient cancels their appointment from their "My area" page.
//
// CONFIDENTIALITY: for a patient, the name of the OTHER patients is not
// simply hidden by the view - it is never placed in the model
// passed to the view. The HTML sent to the browser therefore does not contain it,
// not even hidden (a simple "View page source" would otherwise reveal it).
//
// No booking in the past: the "Book" button only appears
// for today and the following days (and the business layer would refuse it
// anyway).
//
// [Authorize] on the class: all the actions require a logged-in
// user (the equivalent of @UseGuards(JwtAuthGuard)). [Authorize(Roles =
// "...")] on an action additionally requires one of the listed roles (RolesGuard +
// @Roles). Without a valid token: back to /login; insufficient role: 403 page.
// =========================================================================

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using RdvMedecins.Auth;
using RdvMedecins.Entities;
using RdvMedecins.Business;
using RdvMedecins.Models;
using RdvMedecins.Web;

namespace RdvMedecins.Controllers;

[Authorize]
[Route("schedule")]
public class ScheduleController(ApplicationModel application) : Controller
{
    // what the controller knows about the user, to decide what to display
    private record Rights(
        bool Manager, // ADMIN or DOCTOR
        Client? Patient, // the connected client (USER role), otherwise null
        bool PatientCanBook); // USER without an upcoming appointment
  • Lines 7–12: the page’s routes. Note that the booking window is not opened by [JavaScript]: it is a parameter of URL ([book=7]) that instructs the server to render the page with the window open. The “Book” button is therefore simply a link. The entire page is described by its URL: you can reload it, bookmark it, or use the browser’s “Back” button;
  • lines 18–28: what each role sees and can do. This table is the page’s “specification”: the rest of the controller implements it;
  • lines 30–33: the privacy rule, which we’ll come back to: the names of other patients are not hidden by the view; they are never sent to the browser;
  • lines 55–57: [[Authorize]] on the class: all actions require a logged-in user, regardless of their role (see Example 25). [[Route("schedule")]] is the prefix for all routes in the controller (see Example 02). The controller receives the [ApplicationModel] service via injection (see Example 06) through its main constructor: the parameters written in parentheses after the class name are accessible in all of its methods;
  • lines 60–63: what the controller knows about the logged-in user, calculated once per request (see [RightsAsync], below). A private [record], declared inside the class: it is used only by that class. [Manager] is true for an ADMIN or a DOCTOR; [Patient] is populated only for a client (role USER).

11.16.2.2. GET /schedule

    // -----------------------------------------------------------------------
    // GET /schedule
    // The URL parameters are read raw (Request.Query), to distinguish
    // an ABSENT parameter from an EMPTY one (?doctorId=).
    // -----------------------------------------------------------------------
    [HttpGet("")]
    public async Task<IActionResult> Show()
    {
        string? error = null;
        var user = User.CurrentUser()!;
        var rights = await RightsAsync(user);

        // the day: the one of the URL if it is valid, otherwise today
        var day = Dates.Today();
        if (Request.Query.TryGetValue("day", out var rawDay))
        {
            if (WebDates.IsValidDate(rawDay, out var requestedDay))
            {
                day = requestedDay;
            }
            else
            {
                error = "ERRORS.INVALID_DATE";
            }
        }
        var dayBookable = day >= Dates.Today();

        // the doctor: the one of the URL; failing that, for a logged-in doctor, themselves
        var doctorId = Input.PositiveInteger(Request.Query["doctorId"]);
        if (!Request.Query.ContainsKey("doctorId") && user.Role == Role.DOCTOR)
        {
            doctorId = application.GetDoctorByUserId(user.UserId)?.Id;
        }

        var doctors = (await application.GetAllDoctorsAsync())
            .Select(d => Option(d, d.Id == doctorId))
            .ToList();

        // the schedule, if a doctor is chosen (and the date valid)
        ScheduleView? schedule = null;
        if (doctorId is int id && error is null)
        {
            var doctorSchedule = await application.GetDoctorDayScheduleAsync(id, day);
            if (doctorSchedule is null)
            {
                error = "ERRORS.DOCTOR_NOT_FOUND";
            }
            else
            {
                var rows = doctorSchedule.DoctorDaySlots.Select(dds =>
                {
                    var (slot, appointment) = (dds.Slot, dds.Appointment);
                    var free = appointment is null;
                    // the appointment of the connected patient?
                    var mine = appointment is not null && rights.Patient is not null && appointment.ClientId == rights.Patient.Id;
                    return new ScheduleRowView
                    {
                        SlotId = slot.Id,
                        Time = WebDates.Time(slot),
                        Free = free,
                        // THE confidentiality point: a patient only receives the name of
                        // THEIR appointment; for the others, null (the view displays "Booked")
                        Patient = appointment is not null && (rights.Manager || mine) ? Labels.Label(appointment.Client) : null,
                        Mine = mine,
                        Bookable = free && dayBookable && (rights.Manager || rights.PatientCanBook),
                        BookUrl = ScheduleUrl(id, day, slot.Id),
                        AppointmentId = appointment is not null && rights.Manager ? appointment.Id : null,
                    };
                }).ToList();
                schedule = new ScheduleView(Labels.Label(doctorSchedule.Doctor), WebDates.Iso(day), rows);
            }
        }
  • Lines 6–7: the route [GET /schedule] (controller prefix, followed by an empty string). The action has no parameters: it automatically reads URL as [Request.Query] (see Example 05). The model binding would convert a [int? doctorId] parameter to [null] whether it is missing or empty; however, these two cases must be distinguished (see below);
  • Lines 10–11: the logged-in user and their permissions. [CurrentUser()] is an extension method (see Example 06) defined in [Auth/ConnectedUser.cs]: it reads the token’s claims and converts them into a typed object. [!] informs the compiler that the result is not [null]: this is certain, since [[Authorize]] has filtered out unauthenticated requests;
  • lines 14–26: the requested day, if valid; today otherwise. [TryGetValue] returns [false] if the parameter [day] is missing: in that case, it returns today, without a message. If present but invalid ([day=2026-02-30]), it generates an error message. Reservations can only be made starting today ([dayBookable]);
  • lines 29–32: the requested doctor. [Input.PositiveInteger] (folder [Web]) returns a strictly positive integer, or [null] if the value is missing, empty, or invalid. If the [doctorId] parameter is missing from URL, a logged-in doctor sees their own schedule: this is their home page. If it is empty ([doctorId=], the “-- choose a doctor --” option in the list), no schedule is displayed;
  • lines 35–37: the options in the doctors’ drop-down list, read from the cache of [ApplicationModel]; the option for the requested doctor is selected (see the [Option] function at the end of the file and Example 18);
  • lines 40–46: The schedule is calculated only if a doctor is selected and the date is valid. [doctorId is int id] checks that the value (of type [int?]) is not [null], and stores its value in a new variable [id], of type [int]. An unknown doctor ID triggers a message, not an error page;
  • lines 50–56: The schedule calculated by the business layer (a [DoctorDaySchedule] object from the [Domain] folder) is converted into array elements, one per time slot. [var (slot, appointment) = ...] breaks down a value pair into two variables. [mine] determines whether the appointment belongs to the logged-in patient;
  • Lines 58–59: The view receives only data that has already been formatted ([8:00 - 8:20]); it no longer has anything to calculate;
  • line 63: the application’s privacy control. The patient’s name is included in the model only for an administrator or for the patient whose appointment it is. For everyone else, the model contains [null], and the view displays “Booked.” The name is not simply hidden by the view: it is never sent to the browser, and “View Page Source” would not reveal it;
  • lines 65–67: what the view must display, as determined by the controller: the “Book” button (available time slot, date not past, and user authorized to reserve), its link (the page’s URL, with [book]), and the “Cancel” button ([AppointmentId] is populated only for an administrator). The view does not make any decisions: it follows these instructions;
  • line 70: the complete schedule: the doctor, the day, the rows.

11.16.2.3. The appointment window and the view template

        // the booking dialog: only if the requested slot is
        // bookable by this user
        BookingView? booking = null;
        var slotId = Input.PositiveInteger(Request.Query["book"]);
        var row = schedule?.Rows.FirstOrDefault(r => r.SlotId == slotId && r.Bookable);
        if (row is not null)
        {
            booking = new BookingView
            {
                SlotId = row.SlotId,
                Time = row.Time,
                Clients = rights.Manager
                    ? (await application.GetAllClientsAsync()).Select(c => Option(c, false)).ToList()
                    : [],
                Patient = rights.Patient is not null ? Labels.Label(rights.Patient) : null,
            };
        }

        return View("Schedule", new ScheduleModel
        {
            PageTitle = "SCHEDULE.PAGE_TITLE",
            Doctors = doctors,
            DoctorId = doctorId,
            Day = WebDates.Iso(day),
            Schedule = schedule,
            Booking = booking,
            BackUrl = doctorId is int dId ? ScheduleUrl(dId, day) : "/schedule",
            Info = rights.Patient is not null && !rights.PatientCanBook ? "SCHEDULE.ALREADY_BOOKED_INFO" : null,
            Error = error,
        });
    }
  • lines 3–5: the time slot for which URL is requesting a reservation ([book=7]). It must appear in the displayed schedule and be bookable by this user: a manually created URL (a past time slot, a booked time slot, or a slot for another doctor) does not open the window. [schedule?.Rows] is equivalent to [null] if [schedule] is [null] (operator [?.]);
  • lines 6–15: the window template. An administrator selects a patient from a list (all clients, read from the cache); a patient makes a reservation for themselves: the window simply displays their name. [[]] is an empty list;
  • lines 19–29: the page template. Its classes ([ScheduleModel], [ScheduleView], [ScheduleRowView], [BookingView], [OptionView]) are defined in [Models/ViewModels.cs]; [PageTitle] and [Error] are derived from their common parent class [ViewModel]. [PageTitle] is, like all texts, a translation key;
  • line 27: the address of the displayed calendar, without the booking window—this is the address of the button that closes the window;
  • line 28: an informational message for patients who already have an upcoming appointment: it explains why they do not see a “Book” button.

11.16.2.4. POST /schedule/book

    // -----------------------------------------------------------------------
    // POST /schedule/book   fields: doctorId, day, slotId, clientId
    //
    // For a patient (USER), the clientId field is IGNORED: they always book
    // for themselves. Trusting a field sent by the
    // browser would allow a crafty patient to book in someone else's name.
    //
    // "Post / Redirect / Get" pattern: whatever the result, we do not
    // build a page here - we put a flash message and redirect
    // to the schedule (see Web/Flash.cs).
    // -----------------------------------------------------------------------
    [HttpPost("book")]
    [Authorize(Roles = "ADMIN,DOCTOR,USER")]
    public async Task<IActionResult> Book(
        [FromForm] string? doctorId,
        [FromForm] string? day,
        [FromForm] string? slotId,
        [FromForm] string? clientId)
    {
        if (!ReadScheduleRequest(doctorId, day, out var docId, out var requestedDay)
            || Input.PositiveInteger(slotId) is not int sltId)
        {
            return BadRequest(); // -> "invalid request" page (see Program.cs)
        }

        Client? client;
        if (User.IsInRole(nameof(Role.USER)))
        {
            client = await Patient.ConnectedPatientAsync(application, User);
        }
        else
        {
            if (Input.PositiveInteger(clientId) is not int cliId)
            {
                // no patient chosen: we reopen the booking dialog
                Flash.Put(HttpContext, FlashType.Danger, "ERRORS.CHOOSE_PATIENT");
                return Redirect(ScheduleUrl(docId, requestedDay, sltId));
            }
            client = await application.GetClientByIdAsync(cliId);
        }

        var slot = await application.GetSlotByIdAsync(sltId);
        if (client is null)
        {
            Flash.Put(HttpContext, FlashType.Danger, "ERRORS.PATIENT_NOT_FOUND");
        }
        else if (slot is null || slot.DoctorId != docId)
        {
            Flash.Put(HttpContext, FlashType.Danger, "ERRORS.SLOT_NOT_FOUND");
        }
        else
        {
            try
            {
                // the rules (not in the past, a single upcoming appointment per
                // patient, free slot) are checked by the business layer
                await application.AddAppointmentAsync(requestedDay, slot, client);
                Flash.Put(HttpContext, FlashType.Success, "SCHEDULE.BOOKED");
            }
            // "when": an exception filter. Same rule, but a message in the 2nd
            // person when it is the patient themselves who books ("You
            // already have..." rather than "This patient...")
            catch (BusinessException e) when (e.Key == "ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT" && User.IsInRole(nameof(Role.USER)))
            {
                Flash.Put(HttpContext, FlashType.Danger, "SCHEDULE.ALREADY_BOOKED_INFO");
            }
            // any other BusinessException becomes a flash message; the other
            // exceptions (real anomalies) are not caught: 500 page
            catch (BusinessException e)
            {
                Flash.Put(HttpContext, FlashType.Danger, e.Key, new Dictionary<string, string>(e.Params));
            }
        }
        return Redirect(ScheduleUrl(docId, requestedDay));
    }
  • Lines 12–13: [[Authorize(Roles = ...)]] on the action is added to the class’s [[Authorize]]: all three roles can make reservations (see Example 26). Here, this attribute imposes no further restrictions; it documents the rule and would protect it if a fourth role were to appear;
  • lines 15–18: the fields in the reservation window form (see Example 11). They are received as strings and then carefully converted;
  • lines 20–23: the doctor and the day are hidden fields in the form; they allow the user to return to the displayed calendar. If they are invalid, the request did not originate from our page: it was manually crafted. [BadRequest()] returns a 400 response with no content, which the [UseStatusCodePagesWithReExecute] middleware transforms into an error page (see Example 24). [is not int sltId] is the opposite of [is int]: true if the value is [null]; otherwise, [sltId] receives the value;
  • Lines 27–29: For a patient, the [clientId] field is ignored—the system always makes the reservation for the patient itself. Relying on this field, which is sent by the browser, would allow a malicious patient to make a reservation in someone else’s name. [Patient.ConnectedPatientAsync] (folder [Web]) identifies the client using the token; [nameof(Role.USER)] is the string ["USER"], verified by the compiler;
  • Lines 33–39: A manager who has not selected a patient is redirected to the booking window (URL with [book]), accompanied by a pop-up error message. [Flash.Put] (folder [Web]) stores the message in [TempData] (see Example 20);
  • lines 42–49: the patient must exist, and the time slot must exist and belong to the doctor in the calendar: you cannot book a time slot belonging to one doctor for another;
  • lines 57–58: The business rules (not in the past, slot available, only one upcoming appointment per patient) are verified by the business logic layer, which raises a [BusinessException] in case of rejection;
  • Lines 63–65: [when] adds a condition to [catch]: it is an exception filter [C#]. This block only catches the [BusinessException] if the condition is true: for a patient, the message “this patient already has an appointment” is replaced by a second-person message. Otherwise, the exception proceeds to the next [catch];
  • lines 69–71: any other [BusinessException] becomes a flash message, along with its key and parameters ([e.Params] is a read-only dictionary: a copy is made of it, of the type expected by [Flash.Put]). The other exceptions are not caught: these are true errors, which result in a 500 error page;
  • Line 74: the PRG schema: regardless of the result, a flash message is displayed and the user is redirected to the calendar, which will display the message. In version [NestJS], a utility function handled this business error conversion; here, the two [catch] blocks are sufficient.

11.16.2.5. POST /schedule/cancel

    // -----------------------------------------------------------------------
    // POST /schedule/cancel   fields: appointmentId, doctorId, day
    // (a patient cancels THEIR appointment from "My area", not from here)
    // -----------------------------------------------------------------------
    [HttpPost("cancel")]
    [Authorize(Roles = "ADMIN,DOCTOR")]
    public async Task<IActionResult> Cancel([FromForm] string? appointmentId, [FromForm] string? doctorId, [FromForm] string? day)
    {
        if (!ReadScheduleRequest(doctorId, day, out var docId, out var requestedDay)
            || Input.PositiveInteger(appointmentId) is not int id)
        {
            return BadRequest();
        }
        var appointment = await application.GetAppointmentByIdAsync(id);
        if (appointment is null)
        {
            // already cancelled (by someone else, or double click)
            Flash.Put(HttpContext, FlashType.Danger, "ERRORS.APPOINTMENT_NOT_FOUND");
        }
        else
        {
            await application.DeleteAppointmentAsync(appointment);
            Flash.Put(HttpContext, FlashType.Success, "SCHEDULE.CANCELLED");
        }
        return Redirect(ScheduleUrl(docId, requestedDay));
    }
  • lines 5–7: Cancellation is restricted to administrators; a patient cancels their appointment from “My area” (see [PatientAreaController]). A patient who sends this request would receive a 403 page;
  • Lines 9–12: The same checks as for the reservation;
  • lines 14–18: An appointment that has already been canceled (by double-clicking or by another administrator) triggers an error message, not an error page;
  • lines 22–25: deletion, followed by schema PRG.

11.16.2.6. Permissions and Utility Functions

    // -----------------------------------------------------------------------
    // who is logged in, and what are they allowed to do on this page?
    // -----------------------------------------------------------------------
    private async Task<Rights> RightsAsync(ConnectedUser user)
    {
        if (user.Role != Role.USER)
        {
            return new Rights(Manager: true, Patient: null, PatientCanBook: false);
        }
        var patient = await Patient.ConnectedPatientAsync(application, User);
        var upcoming = await application.GetClientUpcomingAppointmentsAsync(patient.Id);
        return new Rights(Manager: false, patient, PatientCanBook: upcoming.Count == 0);
    }

    // ----------------------------------------------------------------- utility functions

    // the hidden doctorId / day fields, common to the two POST forms
    private static bool ReadScheduleRequest(string? rawDoctorId, string? rawDay, out int doctorId, out DateOnly day)
    {
        doctorId = Input.PositiveInteger(rawDoctorId) ?? 0;
        return WebDates.IsValidDate(rawDay, out day) && doctorId > 0;
    }

    private static string ScheduleUrl(int doctorId, DateOnly day, int? book = null)
    {
        var url = $"/schedule?doctorId={doctorId}&day={WebDates.Iso(day)}";
        return book is null ? url : $"{url}&book={book}";
    }

    private static OptionView Option(Person p, bool selected) => new(p.Id, Labels.Label(p), selected);
}
  • lines 4–12: An administrator has full permissions; a patient can make a reservation if they have no upcoming appointments. The named arguments ([Manager: true]) make the call to the [record] constructor readable;
  • lines 18–21: reading the two hidden fields common to the POST forms. The [out] parameters are additional results from the method: it returns a Boolean (are the fields valid?) and, via its [out] parameters, the converted values. This is the process of [int.TryParse];
  • lines 24–27: the URL for a doctor’s schedule on a given day, possibly with the booking window open. All controller redirects generate this via this function: the URL for the page are written in a single location;
  • line 30: a drop-down list option (ID, label, selection), for both a doctor and a client: the parameter is of type [Person], the parent class of both entities.

11.16.3. Controllers/PersonsControllerBase.cs

// =========================================================================
// Controllers/PersonsControllerBase.cs
// -------------------------------------------------------------------------
// The management of the doctors and that of the clients (reserved for the ADMIN) are
// IDENTICAL: same list, same form, same rules. Rather than
// writing the same actions twice, we write them once in this
// ABSTRACT and GENERIC class, from which DoctorsController
// ([Route("doctors")]) and ClientsController ([Route("clients")]) inherit. Each
// subclass only has to provide its "configuration": which methods
// to call, which labels to display.
//
// The routes placed HERE ([HttpGet("add")]...) are inherited, and
// complete the route of the subclass. We therefore get, for each one:
//
//   GET  /doctors                    list
//   GET  /doctors/add                empty form
//   POST /doctors/add                creation
//   GET  /doctors/{id}/edit          pre-filled form
//   POST /doctors/{id}/edit          update
//   POST /doctors/{id}/delete        deletion
//
// (and the same URLs under /clients). The views are common too:
// Views/Persons/List.cshtml and Views/Persons/Form.cshtml.
//
// An abstract class is never taken for a controller by MVC: only
// its concrete subclasses are.
// =========================================================================

using Microsoft.AspNetCore.Mvc;
using RdvMedecins.Entities;
using RdvMedecins.Business;
using RdvMedecins.Models;
using RdvMedecins.Web;

namespace RdvMedecins.Controllers;

// what each subclass must provide
public record PersonsConfig<T>(
    string Url, // "/doctors" or "/clients"
    PersonKeys Keys,
    Func<Task<List<T>>> List,
    Func<int, Task<T?>> Find,
    Func<PersonData, Task<T>> Add,
    Func<int, PersonData, Task<T>> Edit,
    Func<int, Task> Delete) where T : Person;

public abstract class PersonsControllerBase<T>(ApplicationModel application) : Controller where T : Person
{
    protected ApplicationModel Application { get; } = application;

    protected abstract PersonsConfig<T> Config { get; }

    // -----------------------------------------------------------------------
    // GET /doctors (or /clients)
    // -----------------------------------------------------------------------
    [HttpGet("")]
    public async Task<IActionResult> List()
    {
        var persons = await Config.List();
        return View("~/Views/Persons/List.cshtml", new PersonListModel
        {
            PageTitle = Config.Keys.Title,
            Keys = Config.Keys,
            AddUrl = $"{Config.Url}/add",
            Persons = persons
                .Select(p => new PersonView(p.Title, p.FirstName, p.LastName, p.User?.Login ?? "",
                    $"{Config.Url}/{p.Id}/edit", $"{Config.Url}/{p.Id}/delete"))
                .ToList(),
        });
    }

    // -----------------------------------------------------------------------
    // GET /doctors/add
    // -----------------------------------------------------------------------
    [HttpGet("add")]
    public IActionResult AddForm() => ShowForm(null, new PersonValues());

    // -----------------------------------------------------------------------
    // POST /doctors/add
    // [form] has been filled AND validated by MVC: ModelState says whether the
    // input is correct.
    // -----------------------------------------------------------------------
    [HttpPost("add")]
    public async Task<IActionResult> Add(PersonForm form)
    {
        if (!ModelState.IsValid)
        {
            return ShowForm(null, EnteredValues(form), Validation.ErrorsOf(ModelState));
        }
        try
        {
            var person = await Config.Add(Data(form));
            Flash.Put(HttpContext, FlashType.Success, Config.Keys.Added, MessageParams(person));
            return Redirect(Config.Url);
        }
        catch (BusinessException e)
        {
            return ShowBusinessError(null, EnteredValues(form), e);
        }
    }

    // -----------------------------------------------------------------------
    // GET /doctors/{id}/edit
    // {id:int}: a "route constraint" - /doctors/abc/edit does not
    // match any route (404 page)
    // -----------------------------------------------------------------------
    [HttpGet("{id:int}/edit")]
    public async Task<IActionResult> EditForm(int id)
    {
        var person = await Config.Find(id);
        if (person is null)
        {
            return NotFound();
        }
        return ShowForm(id, new PersonValues
        {
            Title = person.Title,
            FirstName = person.FirstName,
            LastName = person.LastName,
            Login = person.User?.Login ?? "",
            Version = person.Version,
        });
    }

    // -----------------------------------------------------------------------
    // POST /doctors/{id}/edit
    // -----------------------------------------------------------------------
    [HttpPost("{id:int}/edit")]
    public async Task<IActionResult> Edit(int id, PersonForm form)
    {
        if (!ModelState.IsValid)
        {
            return ShowForm(id, EnteredValues(form), Validation.ErrorsOf(ModelState));
        }
        try
        {
            var person = await Config.Edit(id, Data(form));
            Flash.Put(HttpContext, FlashType.Success, Config.Keys.Edited, MessageParams(person));
            return Redirect(Config.Url);
        }
        catch (BusinessException e)
        {
            return ShowBusinessError(id, EnteredValues(form), e);
        }
    }

    // -----------------------------------------------------------------------
    // POST /doctors/{id}/delete
    // (the "Are you sure?" confirmation is asked by the browser before
    // sending the form, see wwwroot/js/app.js)
    // -----------------------------------------------------------------------
    [HttpPost("{id:int}/delete")]
    public async Task<IActionResult> Delete(int id)
    {
        var person = await Config.Find(id);
        try
        {
            await Config.Delete(id);
            Flash.Put(HttpContext, FlashType.Success, Config.Keys.Deleted, MessageParams(person));
        }
        catch (BusinessException e)
        {
            Flash.Put(HttpContext, FlashType.Danger, e.Key, new Dictionary<string, string>(e.Params));
        }
        return Redirect(Config.Url);
    }

    // ----------------------------------------------------------------- private methods

    // null id: creation form; otherwise: edit form
    private ViewResult ShowForm(
        int? id,
        PersonValues values,
        Dictionary<string, string>? errors = null,
        string? error = null,
        IEnumerable<string>? otherFields = null)
    {
        var creating = id is null;
        return View("~/Views/Persons/Form.cshtml", Validation.Prepare(
            new PersonFormModel
            {
                PageTitle = creating ? Config.Keys.Add : Config.Keys.Edit,
                Creating = creating,
                Action = creating ? $"{Config.Url}/add" : $"{Config.Url}/{id}/edit",
                ListUrl = Config.Url,
                Values = values,
            },
            errors, error, otherFields));
    }

    // a business rule refused the write: we redisplay the form,
    // with the user's input and the message of the business layer
    private IActionResult ShowBusinessError(int? id, PersonValues values, BusinessException e)
    {
        if (e.Key is "ERRORS.DOCTOR_NOT_FOUND" or "ERRORS.PATIENT_NOT_FOUND")
        {
            // deleted in the meantime by another ADMIN: back to the list
            Flash.Put(HttpContext, FlashType.Danger, e.Key);
            return Redirect(Config.Url);
        }
        // the message goes at the bottom of the page; the fields concerned, if any, are
        // outlined in red so that the user immediately sees what to correct
        return ShowForm(id, values, null, e.Key, Validation.BusinessErrorFields.GetValueOrDefault(e.Key));
    }

    // what the user typed (password excluded: we never send it back to the browser)
    private static PersonValues EnteredValues(PersonForm f) => new()
    {
        Title = f.Title ?? "",
        FirstName = f.FirstName ?? "",
        LastName = f.LastName ?? "",
        Login = f.Login ?? "",
        Version = f.Version,
    };

    // the validated form -> the data expected by the business layer
    private static PersonData Data(PersonForm f) => new()
    {
        Title = f.Title!,
        LastName = f.LastName!,
        FirstName = f.FirstName!,
        Login = f.Login,
        Password = f.Password,
        Version = f.Version,
    };

    // {name} and {login} of the "added / updated / deleted" messages
    private static Dictionary<string, string> MessageParams(Person? person) => person is null
        ? new() { ["name"] = "", ["login"] = "" }
        : new() { ["name"] = $"{person.FirstName} {person.LastName}", ["login"] = person.User?.Login ?? "" };
}

The management of doctors and clients is identical. Rather than writing two similar controllers, we write their actions once, in an abstract and generic parent class. Let’s comment on this code:

  • lines 15–20: the routes for doctors; the same ones exist in [/clients];
  • lines 22–23: the views are also shared (folder [Views/Persons]);
  • lines 25–26: MVC accepts only concrete classes as controllers: only [DoctorsController] and [ClientsController] receive requests;
  • lines 38–45: the configuration provided by each subclass: the base URL, the texts (translation keys, grouped in [record] and [PersonKeys] from [Models/ViewModels.cs]), and the functions to be called for listing, searching, adding, modifying, and deleting. [Func<int, Task<T?>>] is the type of a function that takes a [int] and returns a [Task<T?>]: this is how methods are stored in properties, to be called later. [where T : Person] is a constraint on the generic type: [T] will be either [Doctor] or [Client], two classes derived from [Person]; the code can therefore use [p.FirstName] or [p.User], which are common to all people;
  • Line 47: the parent class, which is generic (see the [ViewPage<TModel>] class in Example 22) and abstract. It receives [ApplicationModel] via its main constructor and passes it to its subclasses via the protected property [Application] ([protected]: visible only to subclasses);
  • line 51: an abstract property: each subclass must define it;
  • lines 56–69: the list. The route attributes set on a parent class’s methods are inherited: combined with the subclass’s [[Route("doctors")]], they yield [GET /doctors]. For each person, the model contains the URL for their Edit and Delete buttons. [p.User?.Login ?? ""]: the login for the associated account, or an empty string if the person does not have an account;
  • line 60: the view is specified by its full path. If only its name were used, MVC would search for it in the controller’s folder ([Views/Doctors] or [Views/Clients], see Example 13), whereas it is shared by both;
  • lines 75–76: the empty form;
  • lines 83–98: the addition, in three steps, as in Example 28. The parameter [form] was populated and validated by MVC prior to the call (see Example 19): an invalid entry redisplays the form, showing the entered values and a message below each incorrect field; a valid entry is passed to the business layer, then the browser is redirected to the list, with a flash message (PRG); a rejection by the business layer (a homonym, a login already in use) re-displays the form;
  • Lines 107–121: the pre-filled modification form. The route constraint [:int] (see Example 02) only accepts an integer identifier: [/doctors/abc/edit] does not correspond to any route, resulting in a 404 page ([ParseIntPipe] derived from [NestJS] resulted in a 400 error). A nonexistent person also results in a 404. The version number, a hidden field in the form, will be used for the optimistic lock (see example 28);
  • lines 128–143: the update, following the same pattern as the insertion;
  • lines 152–165: deletion. The person is retrieved beforehand to display their name in the confirmation message. A rejection from the business layer (e.g., a doctor who has appointments) results in a flash error message on the list page;
  • lines 171–188: the form for creation ([id] is equivalent to [null]), as for modification: only the title and the submission address ([Action]) change. [Validation.Prepare] (folder [Web]) populates the template with field-specific error messages, the footer message, and the fields to be highlighted in red;
  • lines 193–203: a person who has since been deleted (by another administrator) is listed again; other business errors re-display the form, with the footer message and the affected fields highlighted in red ([Validation.BusinessErrorFields] associates each error code with its corresponding fields: [firstName] and [lastName] for a homonym). [is "A" or "B"] compares a value to several constants;
  • lines 207–213: the values to be redisplayed—never the password;
  • lines 217–224: the data transmitted to the business layer. The [!] fields pose no risk: validation has verified the presence of these fields;
  • lines 228–230: the [{name}] and [{login}] parameters in the success messages: the person’s first and last name, and their login.

11.16.4. Controllers/DoctorsController.cs

// =========================================================================
// Controllers/DoctorsController.cs
// -------------------------------------------------------------------------
// Management of the doctors (ADMIN role only). All the actions are
// inherited from PersonsControllerBase: all that is left here is to say WHAT
// to call and WHICH labels to display.
//
// [Authorize(Roles = "ADMIN")] placed on the CLASS applies to all its
// actions, including the inherited ones.
// =========================================================================

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using RdvMedecins.Entities;
using RdvMedecins.Models;
using RdvMedecins.Web;

namespace RdvMedecins.Controllers;

[Authorize(Roles = "ADMIN")]
[Route("doctors")]
public class DoctorsController(ApplicationModel application) : PersonsControllerBase<Doctor>(application)
{
    protected override PersonsConfig<Doctor> Config => new(
        Url: "/doctors",
        Keys: new PersonKeys(
            Title: "DOCTORS.TITLE",
            Add: "DOCTORS.ADD",
            Edit: "DOCTORS.EDIT",
            Empty: "DOCTORS.EMPTY",
            ConfirmDelete: "DOCTORS.DELETE_CONFIRM",
            Added: "DOCTORS.ADDED",
            Edited: "DOCTORS.UPDATED",
            Deleted: "DOCTORS.DELETED"),
        List: Application.GetAllDoctorsAsync,
        Find: Application.GetDoctorByIdAsync,
        Add: Application.AddDoctorAsync,
        Edit: Application.EditDoctorAsync,
        Delete: Application.DeleteDoctorAsync);
}
  • Lines 20–22: Reserved for ADMIN. The attribute set on the class applies to all its actions, including inherited ones. The controller inherits from [PersonsControllerBase<Doctor>]: the parent class’s type, [T], becomes [Doctor]. Its main constructor passes [application] to the parent class’s constructor. (In [TypeScript], an explicit constructor had to be written so that [NestJS] would know what to inject; there is no need for that here);
  • lines 24–34: the configuration. [override] redefines the parent class’s abstract property; [=>] turns it into a computed property, which creates the object each time it is read. The named arguments ([Url:], [Keys:]...) make the list readable;
  • lines 35–39: the functions to be called, which are methods of [ApplicationModel]. [Application.GetAllDoctorsAsync], written without parentheses, does not call the method: it refers to it, and the compiler converts it to a value of the expected type [Func].

11.16.5. Controllers/ClientsController.cs

// =========================================================================
// Controllers/ClientsController.cs
// -------------------------------------------------------------------------
// Management of the clients (the PATIENTS of the practice), ADMIN role only.
// Same principle as DoctorsController (see its comments).
// =========================================================================

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using RdvMedecins.Entities;
using RdvMedecins.Models;
using RdvMedecins.Web;

namespace RdvMedecins.Controllers;

[Authorize(Roles = "ADMIN")]
[Route("clients")]
public class ClientsController(ApplicationModel application) : PersonsControllerBase<Client>(application)
{
    protected override PersonsConfig<Client> Config => new(
        Url: "/clients",
        Keys: new PersonKeys(
            Title: "CLIENTS.TITLE",
            Add: "CLIENTS.ADD",
            Edit: "CLIENTS.EDIT",
            Empty: "CLIENTS.EMPTY",
            ConfirmDelete: "CLIENTS.DELETE_CONFIRM",
            Added: "CLIENTS.ADDED",
            Edited: "CLIENTS.UPDATED",
            Deleted: "CLIENTS.DELETED"),
        List: Application.GetAllClientsAsync,
        Find: Application.GetClientByIdAsync,
        Add: Application.AddClientAsync,
        Edit: Application.EditClientAsync,
        Delete: Application.DeleteClientAsync);
}

The same controller, for clients: different route, different translation keys, different methods of [ApplicationModel].

11.16.6. Controllers/RegistrationController.cs

// =========================================================================
// Controllers/RegistrationController.cs
// -------------------------------------------------------------------------
// "Create a patient account": a patient who does not have an account yet
// creates it themselves. NON-protected routes (we do not have an account yet, by
// definition):
//
//   POST /register/access     "Create a patient account" button of the
//                             authentication page: checks the captcha of
//                             THIS page, then opens the form
//   GET  /register            the form
//   POST /register            the creation, then back to the login page
//
// The flow is protected against robots by ONE captcha, the one of the
// authentication page:
//   1. the "Create a patient account" button sends the login form
//      to /register/access; if the captcha is copied correctly, the server
//      issues a pass (see Auth/RegistrationPassService.cs);
//   2. without a valid pass, /register sends back to the
//      authentication page - typing the URL directly is useless;
//   3. the pass is consumed at creation: one solved captcha
//      allows creating ONE account;
//   4. in addition, the number of accounts created per hour and per IP address is
//      limited (see Auth/AttemptLimiterService.cs).
//
// The account created is exactly the one the ADMIN would create from the
// "Patients" page (same method of the business layer: AddClientAsync), except
// that the patient CHOOSES their login and password (required,
// with confirmation).
// =========================================================================

using Microsoft.AspNetCore.Mvc;
using RdvMedecins.Auth;
using RdvMedecins.Business;
using RdvMedecins.Models;
using RdvMedecins.Web;

namespace RdvMedecins.Controllers;

[Route("register")]
public class RegistrationController(
    ApplicationModel application,
    CaptchaService captchaService,
    AttemptLimiterService limiter,
    RegistrationPassService passes) : Controller
{
    // -----------------------------------------------------------------------
    // POST /register/access   fields: captchaId, captcha (those of the
    // authentication page; login and password, also sent, are ignored)
    // -----------------------------------------------------------------------
    [HttpPost("access")]
    public IActionResult Access([FromForm] string? captchaId, [FromForm] string? captcha)
    {
        var ip = AuthController.IpAddress(HttpContext);

        // too many accounts created recently from this address: no need to go further
        var minutes = limiter.RegistrationBlockMinutes(ip);
        if (minutes > 0)
        {
            Flash.Put(HttpContext, FlashType.Danger, "ERRORS.TOO_MANY_ACCOUNTS", new() { ["minutes"] = minutes.ToString() });
            return Redirect("/login");
        }

        // missing or wrong captcha: back to the authentication page, with
        // a new image (a failure counts as a login failure)
        if (!captchaService.Verify(captchaId, captcha))
        {
            limiter.LoginFailure(ip, "");
            Flash.Put(HttpContext, FlashType.Danger, "ERRORS.CAPTCHA_BEFORE_ACCOUNT");
            return Redirect("/login");
        }

        // correct captcha: we issue the pass and open the form
        Response.Cookies.Append(RegistrationPassService.Cookie, passes.Issue(), RegistrationPassService.GetCookieOptions());
        return Redirect("/register");
    }

    // -----------------------------------------------------------------------
    // GET /register
    // -----------------------------------------------------------------------
    [HttpGet("")]
    public IActionResult Form()
    {
        if (!HasPass())
        {
            return BackToLogin();
        }
        return Show(new PersonValues());
    }

    // -----------------------------------------------------------------------
    // POST /register
    // The [form] parameter is filled and VALIDATED by MVC before the call:
    // the result of the validation is in ModelState.
    // -----------------------------------------------------------------------
    [HttpPost("")]
    public async Task<IActionResult> Create(RegistrationForm form)
    {
        if (!HasPass())
        {
            return BackToLogin();
        }
        var ip = AuthController.IpAddress(HttpContext);
        var values = new PersonValues
        {
            Title = form.Title ?? "Mr",
            FirstName = form.FirstName ?? "",
            LastName = form.LastName ?? "",
            Login = form.Login ?? "",
        };

        // 1. too many accounts created recently from this address?
        var minutes = limiter.RegistrationBlockMinutes(ip);
        if (minutes > 0)
        {
            return Show(values, null, "ERRORS.TOO_MANY_ACCOUNTS", null, new Dictionary<string, string> { ["minutes"] = minutes.ToString() });
        }

        // 2. all the input errors at once (fields + confirmation):
        //    the user corrects them all in one go
        var errors = Validation.ErrorsOf(ModelState);
        if (!errors.ContainsKey("password") && !errors.ContainsKey("confirmation") && form.Password != form.Confirmation)
        {
            errors["confirmation"] = "ERRORS.PASSWORDS_DIFFER";
        }
        if (errors.Count > 0)
        {
            return Show(values, errors);
        }

        // 3. the creation (client + USER account, in a transaction)
        try
        {
            // "!": we assert to the compiler that these values are not null -
            // which is true, since the validation ([Required]) succeeded
            await application.AddClientAsync(new PersonData
            {
                Title = form.Title!,
                LastName = form.LastName!,
                FirstName = form.FirstName!,
                Login = form.Login,
                Password = form.Password,
            });
        }
        catch (BusinessException e)
        {
            // login or first name + last name already taken: the pass remains valid
            return Show(values, null, e.Key, Validation.BusinessErrorFields.GetValueOrDefault(e.Key));
        }

        // 4. the pass has been used: it will not allow creating a 2nd account
        passes.Consume(Request.Cookies[RegistrationPassService.Cookie]);
        Response.Cookies.Delete(RegistrationPassService.Cookie, new CookieOptions { Path = "/register" });
        limiter.RegistrationSuccess(ip);

        // 5. back to the login page, login pre-filled
        Flash.Put(HttpContext, FlashType.Success, "REGISTRATION.DONE", new() { ["login"] = form.Login! });
        return Redirect($"/login?login={Uri.EscapeDataString(form.Login!)}");
    }

    // ----------------------------------------------------------------- private methods

    private bool HasPass() => passes.IsValid(Request.Cookies[RegistrationPassService.Cookie]);

    // no valid pass: back to the authentication page
    private RedirectResult BackToLogin()
    {
        Flash.Put(HttpContext, FlashType.Danger, "ERRORS.CAPTCHA_BEFORE_ACCOUNT");
        return Redirect("/login");
    }

    // the form; never any password sent back to the browser
    private ViewResult Show(
        PersonValues values,
        Dictionary<string, string>? errors = null,
        string? error = null,
        IEnumerable<string>? otherFields = null,
        IReadOnlyDictionary<string, string>? errorParams = null) =>
        View("Registration", Validation.Prepare(
            new RegistrationModel { PageTitle = "REGISTRATION.TITLE", Values = values },
            errors, error, otherFields, errorParams));
}

Let’s comment on this code:

  • lines 14–24: protection against bots, in four steps. The CAPTCHA and attempt limiter were introduced in Example 27;
  • lines 40–45: The controller does not have the [[Authorize]] attribute; its pages are open to everyone. It receives four services via injection;
  • lines 51–52: The “Create a patient account” button on the login page sends the login form to this address (attribute [formaction]; see Example 20). Only the CAPTCHA fields are read;
  • line 54: the browser’s address IP, via a static method from [AuthController];
  • lines 57–61: Too many accounts have been created from this address within the last hour: the user is redirected to the login page, with a pop-up message indicating the number of minutes to wait. [new() { ["minutes"] = ... }] creates the message parameter dictionary: its type is inferred from the parameter of [Flash.Put];
  • lines 66–70: a missing or incorrect CAPTCHA counts as a failed login attempt (with an empty username): a bot cannot keep trying indefinitely. Returning to the login page displays a new image;
  • lines 74–75: the CAPTCHA is correct: a pass (see [Auth/RegistrationPassService.cs]) is stored in a cookie, and the browser is redirected to the form;
  • Lines 81–88: the form, only with a valid pass. Typing the address [/register] directly is pointless;
  • lines 96–97: creation. The form [RegistrationForm] (folder [Models]) was linked and validated by MVC;
  • lines 99–101: The pass is verified again: the request POST might have been sent without going through the form;
  • lines 104–109: the values to be redisplayed in case of an error, without the password. The default title is [Mr];
  • lines 113–116: the time limit, checked again at the time of creation;
  • lines 121–128: input errors, field by field (see Example 19). The match between the password and its confirmation—which spans two fields—is verified here manually; it is tested only if these two fields are otherwise correct. All errors are reported together: the user corrects them all at once;
  • lines 136–148: creation of the customer and their account USER, using the business layer method employed by ADMIN. If a username or first-name-last-name combination is already taken, the form is re-displayed with the relevant fields highlighted in red; the pass remains valid;
  • Lines 152–154: The pass has been used: it is consumed on the server side, and its cookie is deleted. To delete a cookie, you must specify the same path ([Path]) as when it was created: the pass cookie is sent only to addresses [/register...]. The creation is finally counted toward the hourly limit;
  • lines 157–158: return to the login page, with the username pre-filled, and a success message. [Uri.EscapeDataString] encodes the username so it can be included in a URL;
  • line 163: the pass is read from the request cookie;
  • lines 166–169: if no pass is present, return to the login page with a message;
  • lines 173–181: the form, prepared by [Validation.Prepare], is the same as those for doctors and clients. [errorParams] is used for the parameters of the footer message (the number of minutes).

11.16.7. Controllers/PatientAreaController.cs

// =========================================================================
// Controllers/PatientAreaController.cs
// -------------------------------------------------------------------------
// The PERSONAL pages of the patient (USER role), the one they see right
// after logging in:
//
//   GET  /my-area                  home page: [Manage my account],
//                                  [View a schedule], and their upcoming appointment
//   POST /my-area/cancel           cancels THEIR appointment
//   GET  /my-account               form to edit their account
//   POST /my-account               update
//   POST /my-account/delete        deletion of the account (and of its appointments)
//
// Golden rule of this controller: the patient concerned is ALWAYS the one of the
// token (Patient.ConnectedPatientAsync), never an identifier sent by the
// browser. A patient can therefore neither see, nor modify, nor cancel what
// belongs to another patient, even by forging their requests themselves.
//
// Any modification or deletion of the account requires the CURRENT
// password: someone who found a session left open could
// neither change the password, nor delete the account. The current password
// errors are counted as login failures (same limit).
// =========================================================================

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using RdvMedecins.Auth;
using RdvMedecins.Entities;
using RdvMedecins.Business;
using RdvMedecins.Models;
using RdvMedecins.Web;

namespace RdvMedecins.Controllers;

[Authorize(Roles = "USER")]
public class PatientAreaController(
    ApplicationModel application,
    AuthService authService,
    AttemptLimiterService limiter) : Controller
{
    // -----------------------------------------------------------------------
    // GET /my-area
    // -----------------------------------------------------------------------
    [HttpGet("my-area")]
    public async Task<IActionResult> Area()
    {
        var patient = await Patient.ConnectedPatientAsync(application, User);
        var appointments = await application.GetClientUpcomingAppointmentsAsync(patient.Id);
        return View("Area", new AreaModel
        {
            PageTitle = "AREA.TITLE",
            Patient = Labels.Label(patient),
            Appointments = appointments.Select(a => new AppointmentView(
                a.Id,
                WebDates.FormatDay(a.Day),
                WebDates.Time(a.Slot),
                Labels.Label(a.Slot.Doctor),
                // link to the doctor's schedule on that day: the patient sees their name there
                $"/schedule?doctorId={a.Slot.Doctor.Id}&day={WebDates.Iso(a.Day)}")).ToList(),
        });
    }

    // -----------------------------------------------------------------------
    // POST /my-area/cancel   field: appointmentId
    // -----------------------------------------------------------------------
    [HttpPost("my-area/cancel")]
    public async Task<IActionResult> CancelAppointment([FromForm] string? appointmentId)
    {
        var patient = await Patient.ConnectedPatientAsync(application, User);
        var appointment = Input.PositiveInteger(appointmentId) is int id ? await application.GetAppointmentByIdAsync(id) : null;
        // an appointment that does not exist, and the one of ANOTHER patient, get
        // the same answer: we reveal nothing about the appointments of the others
        if (appointment is null || appointment.ClientId != patient.Id)
        {
            Flash.Put(HttpContext, FlashType.Danger, "ERRORS.APPOINTMENT_NOT_FOUND");
        }
        else
        {
            await application.DeleteAppointmentAsync(appointment);
            Flash.Put(HttpContext, FlashType.Success, "SCHEDULE.CANCELLED");
        }
        return Redirect("/my-area");
    }

    // -----------------------------------------------------------------------
    // GET /my-account
    // -----------------------------------------------------------------------
    [HttpGet("my-account")]
    public async Task<IActionResult> AccountForm()
    {
        var patient = await Patient.ConnectedPatientAsync(application, User);
        return Show(new PersonValues
        {
            Title = patient.Title,
            FirstName = patient.FirstName,
            LastName = patient.LastName,
            Login = patient.User.Login,
            Version = patient.Version,
        });
    }

    // -----------------------------------------------------------------------
    // POST /my-account
    // -----------------------------------------------------------------------
    [HttpPost("my-account")]
    public async Task<IActionResult> EditAccount(MyAccountForm form)
    {
        var patient = await Patient.ConnectedPatientAsync(application, User);
        var values = new PersonValues
        {
            Title = form.Title ?? patient.Title,
            FirstName = form.FirstName ?? "",
            LastName = form.LastName ?? "",
            Login = form.Login ?? "",
            Version = form.Version,
        };

        var errors = Validation.ErrorsOf(ModelState);
        if (!errors.ContainsKey("password") && form.Password is not null && form.Password != form.Confirmation)
        {
            errors["confirmation"] = "ERRORS.PASSWORDS_DIFFER";
        }
        if (errors.Count > 0)
        {
            return Show(values, errors);
        }

        // the current password
        var passwordError = await CheckCurrentPasswordAsync(patient, form.CurrentPassword);
        if (passwordError is not null)
        {
            var (key, messageParams) = passwordError.Value;
            return Show(
                values,
                key == "ERRORS.WRONG_CURRENT_PASSWORD" ? new(StringComparer.OrdinalIgnoreCase) { ["currentPassword"] = key } : null,
                key,
                null,
                messageParams);
        }

        try
        {
            var client = await application.EditClientAsync(patient.Id, new PersonData
            {
                Title = form.Title!,
                LastName = form.LastName!,
                FirstName = form.FirstName!,
                Login = form.Login,
                Password = form.Password, // null = unchanged
                Version = form.Version,
            });
            // the login and the display name are IN the token: we issue a
            // new one, otherwise the navigation bar would keep the old name
            authService.SetToken(Response, client.User);
            Flash.Put(HttpContext, FlashType.Success, "MY_ACCOUNT.UPDATED");
            return Redirect("/my-area");
        }
        catch (BusinessException e)
        {
            return Show(values, null, e.Key, Validation.BusinessErrorFields.GetValueOrDefault(e.Key));
        }
    }

    // -----------------------------------------------------------------------
    // POST /my-account/delete   field: deletePassword
    // The account, the client and ALL their appointments disappear together,
    // then the patient is logged out.
    // -----------------------------------------------------------------------
    [HttpPost("my-account/delete")]
    public async Task<IActionResult> DeleteAccount([FromForm] string? deletePassword)
    {
        var patient = await Patient.ConnectedPatientAsync(application, User);
        var passwordError = await CheckCurrentPasswordAsync(patient, deletePassword);
        if (passwordError is not null)
        {
            var (key, messageParams) = passwordError.Value;
            Flash.Put(HttpContext, FlashType.Danger, key, messageParams);
            return Redirect("/my-account");
        }
        await application.DeleteClientAsync(patient.Id);
        AuthService.ClearToken(Response);
        Flash.Put(HttpContext, FlashType.Success, "MY_ACCOUNT.DELETED");
        return Redirect("/login");
    }

    // ----------------------------------------------------------------- private methods

    // null if the current password is correct; otherwise the key of the message
    // (and its parameters). The return type is a nullable "tuple".
    private async Task<(string Key, Dictionary<string, string>? Params)?> CheckCurrentPasswordAsync(
        Client patient, string? password)
    {
        var ip = AuthController.IpAddress(HttpContext);
        var login = patient.User.Login;
        var minutes = limiter.LoginBlockMinutes(ip, login);
        if (minutes > 0)
        {
            return ("ERRORS.TOO_MANY_ATTEMPTS", new Dictionary<string, string> { ["minutes"] = minutes.ToString() });
        }
        var ok = !string.IsNullOrEmpty(password) && await authService.ValidateUserAsync(login, password) is not null;
        if (!ok)
        {
            limiter.LoginFailure(ip, login);
            return ("ERRORS.WRONG_CURRENT_PASSWORD", null);
        }
        return null;
    }

    private ViewResult Show(
        PersonValues values,
        Dictionary<string, string>? errors = null,
        string? error = null,
        IEnumerable<string>? otherFields = null,
        IReadOnlyDictionary<string, string>? errorParams = null) =>
        View("Account", Validation.Prepare(
            new MyAccountModel { PageTitle = "MY_ACCOUNT.TITLE", Values = values },
            errors, error, otherFields, errorParams));
}

Let’s break down this code:

  • lines 14–17: the golden rule of this controller: the patient in question is always the one associated with the token, never an identifier sent by the browser;
  • lines 35–39: reserved for patients. The controller has no [[Route]] attribute: each action provides its full address, since they do not share a prefix ([/my-area], [/my-account]);
  • lines 44–59: the “My area” page. [Patient.ConnectedPatientAsync] identifies the user based on the token; if their account has been deleted since they logged in, it triggers a [InvalidSessionException], which the [Web/HtmlExceptionFilter.cs] filter redirects back to the login page. The date is formatted by [WebDates.FormatDay] according to the language of the request, set by the localization middleware (see Example 22); each appointment includes a link to the doctor’s schedule for that day;
  • lines 66–82: cancellation of the patient’s appointment. [condition ? value1 : value2] returns the appointment here, or [null] if the ID is invalid. A nonexistent appointment and one belonging to another patient (line 73) receive the same response: no information is disclosed about other patients’ appointments;
  • lines 88–98: the account form, pre-filled, with the version number (optimistic lock);
  • lines 105–106: the modification process, in three steps: validation, verification of the current password, business logic;
  • lines 109–115: the values to be redisplayed in case of an error, never passwords;
  • lines 118–125: input errors. The new password is optional (blank: unchanged); its confirmation is compared only if it was entered;
  • lines 129–138: the current password. [var (key, messageParams) = ...] parses the pair returned by the verification. An incorrect password is displayed below its field and at the bottom of the page; a lockout (too many attempts) is shown only at the bottom of the page, along with the number of minutes;
  • lines 143–150: modification by the business layer, which applies the same rules as for ADMIN (uniqueness, optimistic lock);
  • line 154: the token contains the login and the display name: after a change, a new token is issued; otherwise, the navigation bar would retain the old name;
  • lines 158–160: a rejection by the business layer re-displays the form, with the relevant fields highlighted in red;
  • lines 169–183: Deleting the account, which also requires the current password. An error is indicated by a pop-up message on the account page (PRG);
  • lines 180–181: Deletion of the record, the account, and all appointments (the business layer method used by ADMIN), followed by logout: the token cookie is cleared;
  • lines 190–191: verification of the current password. This returns [null] if the password is correct; otherwise, it returns a tuple: a pair of named values (the message key and its parameters). The final [?] makes this tuple “nullable”;
  • lines 195–198: The current password is subject to the same limiter as the connection (see Example 27). Someone who found a session that remained open would be unable to change the password, delete the account, or attempt passwords indefinitely;
  • lines 200–204: the authentication process itself, handled by [AuthService]; a failed authentication attempt is counted as a failed login attempt;
  • lines 209–217: the account form.

11.16.8. Controllers/ErrorController.cs

// =========================================================================
// Controllers/ErrorController.cs
// -------------------------------------------------------------------------
// The error page (400, 403, 404, 500).
//
// It is never reached through a link: it is the middlewares
// UseStatusCodePagesWithReExecute("/error/{0}") and
// UseExceptionHandler("/error/500") (see Program.cs) that RE-EXECUTE the
// failed request with this address, to build the page to send back
// instead of an empty response or an exception.
//
// [Route] without [HttpGet]: the action accepts all HTTP methods - the
// re-executed request keeps its original method (a refused POST stays a POST).
// =========================================================================

using Microsoft.AspNetCore.Mvc;
using RdvMedecins.Models;

namespace RdvMedecins.Controllers;

public class ErrorController : Controller
{
    private static readonly Dictionary<int, string> Messages = new()
    {
        [StatusCodes.Status400BadRequest] = "ERRORS.BAD_REQUEST",
        [StatusCodes.Status403Forbidden] = "ERRORS.FORBIDDEN",
        [StatusCodes.Status404NotFound] = "ERRORS.NOT_FOUND",
    };

    [Route("error/{code:int}")]
    public IActionResult Show(int code)
    {
        // the page keeps the original error code (404 stays 404...); an
        // unexpected code becomes a 500 error
        if (code < 400 || code > 599)
        {
            code = StatusCodes.Status500InternalServerError;
        }
        Response.StatusCode = code;
        return View("Error", new ErrorModel
        {
            PageTitle = "ERROR_PAGE.TITLE",
            Code = code,
            Message = Messages.GetValueOrDefault(code, "ERRORS.SERVER_ERROR"),
        });
    }
}

This is the controller from Example 24, with translated messages:

  • lines 23–28: the message key for each error code. [static readonly]: a single dictionary, created when the class is loaded and never replaced;
  • lines 30–31: [[Route]], which lacks the HTTP method, accepts all methods: a re-executed POST request (for example, a patient attempting a cancellation reserved for administrators) remains a POST request;
  • lines 35–37: a non-error code HTTP (a manually entered [/error/200] address) becomes a 500 error;
  • line 39: the page retains the original error code;
  • lines 40–44: the page template. [GetValueOrDefault] returns the key associated with the code, or [ERRORS.SERVER_ERROR] for any other code (500 in particular).

11.17. The Views

The views have almost nothing to decide: their template has been prepared by the controllers in the classes of the [Models/ViewModels.cs] file (which buttons to display, which URL elements, which translation keys). Each view declares the type of its template ([@model], see Example 13): a typo in [@Model.Xxx] will cause a compilation error. Each view also begins with a comment [Razor] [@* ... *@] that describes its role; these comments do not appear in the pages sent to the browser.

One difference from the examples in Chapter 4: the case study does not use Tag Helpers. Partial views are inserted using [@await Html.PartialAsync("_Name")], the equivalent of the [<partial name="_Name">] tag from Example 15. A second, optional argument provides the partial view’s model; without it, the partial view receives the page’s model. The method is asynchronous (the partial view may need to be compiled or read from disk): [await] waits for its result.

11.17.1. The Template and Partial Views

11.17.1.1. Views/_ViewImports.cshtml

@* =========================================================================
   Views/_ViewImports.cshtml
   -------------------------------------------------------------------------
   Read before EACH view of this folder and its subfolders: the common
   "using"s, and the parent class of all the views (Web/ViewPage.cs), which
   brings them @T("KEY"), CurrentUser, FlashMessage...
   TModel designates the type of the model of each view (@model ...).
   ========================================================================= *@
@using RdvMedecins
@using RdvMedecins.Models
@using RdvMedecins.Web
@inherits RdvMedecins.Web.ViewPage<TModel>
  • lines 9–11: the namespaces whose types are used by the views: the view templates ([RdvMedecins.Models]), the flash messages ([RdvMedecins.Web]);
  • line 12: the parent class of all views, [Web/ViewPage.cs], as in Example 22. It provides them with the translation [@T("KEY")], the language [Language], the logged-in user [CurrentUser], the flash message [FlashMessage], the requested path [CurrentPath], and the method [Active] (active menu item in the navigation bar). In version [NestJS], this information was passed through [res.locals]. There is no [@addTagHelper] directive: the application does not use Tag Helpers.

11.17.1.2. Views/_ViewStart.cshtml

@* =========================================================================
   Views/_ViewStart.cshtml
   -------------------------------------------------------------------------
   Executed before each view: all the pages use the common layout
   Views/Shared/_Layout.cshtml (hbs did it automatically for
   views/layout.hbs; here, we say it explicitly).
   ========================================================================= *@
@{
    Layout = "_Layout";
}
  • Line 9: All pages use the [Views/Shared/_Layout.cshtml] template (see Example 15). [Handlebars] automatically used [views/layout.hbs]; [Razor] requires this to be specified.

11.17.1.3. Views/Shared/_Layout.cshtml

@* =========================================================================
   Views/Shared/_Layout.cshtml
   -------------------------------------------------------------------------
   The layout COMMON to all the pages. The page requested by the controller
   (Schedule.cshtml, Login.cshtml...) is inserted at the location
   @RenderBody().

   Razor ESCAPES everything it displays with @ (the characters < > & " '
   become &lt; &gt;...): protection against HTML injection through a
   typed name, for example. To insert HTML as is, it must be requested
   explicitly (@Html.Raw, see _Captcha.cshtml).

   Messages: a success is displayed at the TOP of the page (_Flash), an error at the
   BOTTOM, on a light pink background (_Errors).

   The layout shares the model of the page: if it is a ViewModel (see
   Models/ViewModels.cs), we read the title and the error of the page from it.
   ========================================================================= *@
@{
    var page = ViewData.Model as ViewModel;
}
<!doctype html>
<html lang="@Language">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>@T(page?.PageTitle ?? "APP.TITLE") - @T("APP.TITLE")</title>
  <link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css">
  <link rel="stylesheet" href="/css/styles.css">
</head>
<body>
  <div class="container py-4">
    @await Html.PartialAsync("_Navbar")
    @await Html.PartialAsync("_Flash")
    @RenderBody()
    @await Html.PartialAsync("_Errors")
  </div>
  @* the "Are you sure?" dialog, hidden as long as it is not needed *@
  @await Html.PartialAsync("_Confirmation")
  <script src="/js/app.js"></script>
</body>
</html>
  • Line 20: The template shares the page model, accessible via [ViewData.Model] (of type [object]). All page templates derive from the abstract class [ViewModel], which contains the title key ([PageTitle]) and the page error ([Error]). [as ViewModel] returns [null] if the template is not a [ViewModel]: the template works with any page;
  • line 23: the page's language (see example 22);
  • Line 27: the title, translated, for example, “Schedule - RdvMedecins.” Unlike Example 22, the key is not set by the view in [ViewData]: it is provided by the controller in the typed model. [page?.PageTitle] is equal to [null] if [page] is [null], and the operator [??] then replaces the key with [APP.TITLE];
  • lines 28–29: the style sheets, served by [UseStaticFiles] from the [wwwroot] folder: [Bootstrap] ([wwwroot/lib/bootstrap]) and the application-specific style sheet;
  • lines 33–36: the navigation bar, the success message (at the top), the requested page ([@RenderBody()]), and the error area (at the bottom);
  • lines 39–40: the confirmation window (hidden) and its script (see Example 21), loaded at the bottom of the page.

11.17.1.4. Views/Shared/_Navbar.cshtml

@* =========================================================================
   Views/Shared/_Navbar.cshtml - the navigation bar.

   Title, FR/EN, role, name, logout - and a menu whose entries
   depend on the role: "Doctors" and "Patients" appear only for
   the ADMIN. It is only a display convenience: the real protection is
   the [Authorize(Roles = ...)] of the controllers (a USER who types /doctors
   by hand gets a 403 page). A patient (USER) also has their
   "My area" menu.
   ========================================================================= *@
@{
    var user = CurrentUser;
}
<nav class="navbar navbar-expand-sm navbar-dark bg-primary rounded mb-4 px-3">
  <span class="navbar-brand mb-0">@T("APP.TITLE")</span>

  @if (user is not null)
  {
    <ul class="navbar-nav me-auto">
      @if (user.IsPatient)
      {
        <li class="nav-item"><a class="nav-link @Active("/my-")" href="/my-area">@T("APP.MENU_MY_AREA")</a></li>
      }
      <li class="nav-item"><a class="nav-link @Active("/schedule")" href="/schedule">@T("APP.MENU_SCHEDULE")</a></li>
      @if (user.IsAdmin)
      {
        <li class="nav-item"><a class="nav-link @Active("/doctors")" href="/doctors">@T("APP.MENU_DOCTORS")</a></li>
        <li class="nav-item"><a class="nav-link @Active("/clients")" href="/clients">@T("APP.MENU_PATIENTS")</a></li>
      }
    </ul>
  }
  else
  {
    <div class="me-auto"></div>
  }

  <div class="d-flex align-items-center gap-2">
    @* FR / EN: simple links (GET /language/fr), the active language highlighted *@
    <div class="btn-group btn-group-sm" role="group" aria-label="FR / EN">
      <a class="btn @(Language == "fr" ? "btn-light" : "btn-outline-light")" href="/language/fr">FR</a>
      <a class="btn @(Language == "en" ? "btn-light" : "btn-outline-light")" href="/language/en">EN</a>
    </div>

    @if (user is not null)
    {
      <span class="badge text-bg-light text-primary">@user.Role</span>
      <span class="text-white">@user.Name</span>
      @* logout is a POST: a form reduced to its button *@
      <form method="post" action="/logout" class="m-0">
        <button type="submit" class="btn btn-sm btn-outline-light">@T("APP.LOGOUT")</button>
      </form>
    }
  </div>
</nav>
  • line 12: the logged-in user, as described by the [ViewUser] class of [Web/ViewPage.cs] (login, name, role, [IsAdmin], [IsPatient]), derived from the token JWT. It is set to [null] on public pages (login, account creation);
  • lines 17–31: The menus depend on the role: “My area” for a patient, “Doctors” and “Patients” for the ADMIN. This is merely for convenience: security is ensured by the [[Authorize(Roles = ...)]] attributes of the controllers (a patient who manually enters [/doctors] will receive a 403 page). [Active("/doctors")] returns [active] if the requested path begins with [/doctors]: [Bootstrap] then highlights this menu. The prefix [/my-] covers both patient pages, [/my-area] and [/my-account];
  • line 34: without menus, an empty block takes its place ([me-auto]: automatic right margin), so that the rest of the content remains to the right of the bar;
  • lines 40–41: the links FR / EN from Example 22. The expression [@( ... )] is enclosed in parentheses because it contains spaces (see Example 14);
  • lines 44–52: the role, name, and logout button in a POST form (see Example 21).

11.17.1.5. Views/Shared/_Flash.cshtml

@* =========================================================================
   Views/Shared/_Flash.cshtml - the SUCCESS message put by the previous
   action (appointment saved, doctor added...), displayed only
   once, at the top of the page (see Web/Flash.cs).
   An ERROR flash message, for its part, is displayed at the bottom of the page, by
   _Errors.cshtml.
   ========================================================================= *@
@{
    var flash = FlashMessage;
}
@if (flash is { Type: FlashType.Success })
{
  <div class="alert alert-success" role="status">
    @T(flash.Key, flash.Params)
  </div>
}
  • line 9: the flash message posted by the previous action (see example 20 and [Web/Flash.cs]). Reading a flash message clears it; since the page reads it twice (here and in [_Errors.cshtml]), the [Flash] class stores it, once read, in [HttpContext.Items] (see Chapter 6);
  • line 11: [flash is { Type: FlashType.Success }] is a property pattern: the condition is true if [flash] is not [null] and if its property [Type] is equal to [FlashType.Success]. Only a success message is displayed here;
  • line 14: the message is a key; its possible parameters ([name], [login], [minutes]) are a dictionary, passed to the second form of the [T] method. [role="status"]: A screen reader announces the message without interrupting the user.

11.17.1.6. Views/Shared/_Errors.cshtml

@* =========================================================================
   Views/Shared/_Errors.cshtml - the error zone, at the BOTTOM of each page,
   on a light pink background (.error-zone class, see wwwroot/css/styles.css).

   It gathers the two possible sources of error:
     - Error: the error of the displayed page (refused form, login or
       first + last name already used, invalid date, incorrect password...);
     - an error flash message, put by the previous action before a
       redirection (slot already booked, deletion refused...).

   role="alert": a screen reader announces the message as soon as the
   page is displayed, even if it is at the bottom.
   ========================================================================= *@
@{
    var page = ViewData.Model as ViewModel;
    var error = page?.Error;
    var flash = FlashMessage;
    var flashError = flash is { Type: FlashType.Danger } ? flash : null;
}
@if (error is not null || flashError is not null)
{
  <div class="error-zone" role="alert">
    @if (error is not null)
    {
      <div>@T(error, page!.ErrorParams)</div>
    }
    @if (flashError is not null)
    {
      <div>@T(flashError.Key, flashError.Params)</div>
    }
  </div>
}
  • Lines 15–18: the two sources of error: the error on the displayed page (property [Error] of the template: form rejected, incorrect password, etc.), and a flash error message displayed before a redirect (slot already reserved, deletion rejected, etc.);
  • Lines 20–22: The field is displayed if either one is present. [role="alert"]: A screen reader announces the message as soon as the page is displayed, even though it is at the bottom. The class [error-zone] is defined in [wwwroot/css/styles.css];
  • line 25: the operator [!] tells the compiler that [page] is not [null]: it cannot deduce this on its own, even though it is certain because [error], read from [page], is not.

11.17.1.7. Views/Shared/_Confirmation.cshtml

@* =========================================================================
   Views/Shared/_Confirmation.cshtml - the "Are you sure?" modal dialog.

   It replaces the window.confirm() box of the browser, which can neither be
   styled, nor translated (its buttons stay in the language of the
   browser, not in that of the page).

   A SINGLE dialog for the whole page, hidden (d-none) at first. Its
   labels are translated here, by the server, like the rest of the page;
   the small wwwroot/js/app.js script merely displays it, puts in it
   the message specific to the form (data-confirmation attribute), and sends
   the form if the user confirms.

   Same Bootstrap classes as the booking dialog (Schedule/_Booking.cshtml).
   ========================================================================= *@
<div id="confirmation-modal" class="d-none">
  <div class="modal-backdrop fade show"></div>
  <div class="modal fade show d-block" tabindex="-1" role="alertdialog" aria-modal="true"
       aria-labelledby="confirmation-modal-title" aria-describedby="confirmation-modal-message">
    <div class="modal-dialog modal-dialog-centered">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title" id="confirmation-modal-title">@T("CONFIRMATION.TITLE")</h5>
          <button type="button" class="btn-close" data-action="no" aria-label="@T("BOOKING_DIALOG.CLOSE_ARIA")"></button>
        </div>
        <div class="modal-body">
          <p class="mb-0" id="confirmation-modal-message"></p>
        </div>
        <div class="modal-footer">
          @* "No, go back" rather than "Cancel": the question is sometimes
             "Cancel this appointment?", and a "Cancel" button would be ambiguous there *@
          <button type="button" class="btn btn-secondary" data-action="no">@T("CONFIRMATION.NO")</button>
          <button type="button" class="btn btn-danger" data-action="yes">@T("CONFIRMATION.YES")</button>
        </div>
      </div>
    </div>
  </div>
</div>

The window from Example 21, with labels translated by the server—like the rest of the page—and accessibility attributes:

  • line 16: a single window for the entire page, initially hidden ([d-none]);
  • lines 18–19: [role="alertdialog"] indicates a window waiting for a response; [aria-modal="true"] indicates that the rest of the page is inactive; [aria-labelledby] and [aria-describedby] designate the title and the message read by a screen reader;
  • line 24: the close button, which means “No” ([data-action="no"]). It has no text: [aria-label] provides a name for screen readers;
  • Lines 32–33: “No, go back” rather than “Cancel”: the question is sometimes “Cancel this appointment?”, and a “Cancel” button would be ambiguous in that context.

11.17.1.8. Views/Auth/_Captcha.cshtml

@* =========================================================================
   Views/Auth/_Captcha.cshtml - the captcha of the authentication page
   (see Auth/CaptchaService.cs). It protects both the login and
   the access to account creation ("Create a patient account" button).

   @Html.Raw(...): the SVG image is inserted AS IS in the page, without
   escaping. It is harmless here because this SVG is built by the
   server itself and contains nothing that comes from the user - we would
   NEVER do this with user input.

   The hidden captchaId field tells the server WHICH captcha to check; the right
   answer, for its part, is never sent to the browser.
   "Another image" simply reloads the page (path = the current URL).
   ========================================================================= *@
@model RdvMedecins.Auth.Captcha
<div class="mb-3">
  <label class="form-label" for="captcha">@T("CAPTCHA.LABEL")</label>
  <div class="d-flex align-items-center gap-3 mb-2">
    <div class="captcha-image" role="img" aria-label="@T("CAPTCHA.IMAGE_ALT")">@Html.Raw(Model.Svg)</div>
    <a class="small" href="@CurrentPath">@T("CAPTCHA.NEW_IMAGE")</a>
  </div>
  <input type="hidden" name="captchaId" value="@Model.Id">
  <input id="captcha" name="captcha" type="text" maxlength="10" autocomplete="off" autocapitalize="off" spellcheck="false" required
         class="form-control">
  <div class="form-text">@T("CAPTCHA.HELP")</div>
</div>
  • line 15: the partial view has its own template, the CAPTCHA generated by [Auth/CaptchaService.cs] (see example 27): its ID and image;
  • line 19: the image SVG is inserted as-is (see example 14): it is generated by the server, with no input from the user. [role="img"] and [aria-label] generate an image for screen readers;
  • Line 20: “Another image” simply reloads the current page: a new CAPTCHA is generated;
  • line 22: the captcha ID, in a hidden field: it tells the server which captcha to verify. The correct answer, however, never leaves the server;
  • lines 23–24: the input. [autocomplete="off"], [autocapitalize="off"], and [spellcheck="false"] prevent the browser from suggesting, capitalizing, or correcting the copied characters.

11.17.1.9. Views/Shared/_PersonFields.cshtml

@* =========================================================================
   Views/Shared/_PersonFields.cshtml - the identity fields (title,
   first name, last name), common to three forms: the ADMIN's doctor/client form,
   "Create a patient account" and "Manage my account".
   The partial receives the model of the page that inserts it (a FormView):
   values, message under each field, fields outlined in red.

   selected="@selected": a boolean attribute. Razor writes selected="selected"
   if the value is true, and OMITS the attribute if it is false.
   ========================================================================= *@
@model FormView
<div class="mb-3">
  <label class="form-label" for="title">@T("PERSONS.TITLE_LABEL")</label>
  <select id="title" name="title" class="form-select @Model.InvalidClass("title")">
    @foreach (var (value, selected) in Model.Titles)
    {
      <option value="@value" selected="@selected">@value</option>
    }
  </select>
  @if (Model.ErrorOf("title") is { } titleError)
  {
    <div class="invalid-feedback">@T(titleError)</div>
  }
</div>

<div class="row g-3 mb-3">
  <div class="col-sm-6">
    <label class="form-label" for="firstName">@T("PERSONS.FIRSTNAME_LABEL")</label>
    <input id="firstName" name="firstName" type="text" maxlength="20" required
           class="form-control @Model.InvalidClass("firstName")" value="@Model.Values.FirstName">
    @if (Model.ErrorOf("firstName") is { } firstNameError)
    {
      <div class="invalid-feedback">@T(firstNameError)</div>
    }
  </div>
  <div class="col-sm-6">
    <label class="form-label" for="lastName">@T("PERSONS.LASTNAME_LABEL")</label>
    <input id="lastName" name="lastName" type="text" maxlength="20" required
           class="form-control @Model.InvalidClass("lastName")" value="@Model.Values.LastName">
    @if (Model.ErrorOf("lastName") is { } lastNameError)
    {
      <div class="invalid-feedback">@T(lastNameError)</div>
    }
  </div>
</div>

The identity fields (title, first name, last name), common to three forms: doctor or client of ADMIN, account creation, and patient account management.

  • Line 11: [FormView] is the parent (abstract) class for the templates of these three forms. It contains the entered values ([Values]), the error message for each rejected field ([Errors]), and the fields to be highlighted in red ([InvalidFields]). The partial view therefore accepts the template for each of the three pages;
  • lines 14–19: the list of titles. [Model.Titles] returns tuples (value, selected?); [var (value, selected)] breaks down each tuple into two variables. [selected="@selected"] is a Boolean attribute, written only if the value is true (see Example 17). [InvalidClass("title")] returns [is-invalid] if the field is to be framed in red (see Example 19);
  • lines 20–23: the field’s error message, if any. [ErrorOf] returns the message, or [null]; the condition [is { } titleError] is true if the value is not [null], and then stores it in the new variable [titleError];
  • Lines 26–45: the first name and last name, following the same format, side by side on a sufficiently wide screen (grid [Bootstrap]: [col-sm-6]). The attributes [maxlength] and [required] are merely for convenience: forms that insert this partial view have the attribute [novalidate], and the server handles validation.

11.17.1.10. Views/Schedule/_Booking.cshtml

@* =========================================================================
   Views/Schedule/_Booking.cshtml - the booking dialog.

   Same technique as the Angular client (booking-dialog): the REAL Bootstrap
   classes of a modal dialog (.modal, .modal-backdrop...), made
   visible "hard-coded" (show d-block) rather than by the JavaScript of
   Bootstrap. The difference: it is the SERVER that decides to display it
   (&book=... parameter of the URL), and closing it amounts to following a link
   to the same page without this parameter.
   ========================================================================= *@
@model ScheduleModel
@{
    var booking = Model.Booking!;
}
<div class="modal-backdrop fade show"></div>
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" aria-labelledby="booking-title">
  <div class="modal-dialog modal-dialog-centered">
    <form method="post" action="/schedule/book" class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title" id="booking-title">@T("BOOKING_DIALOG.TITLE")</h5>
        <a class="btn-close" href="@Model.BackUrl" aria-label="@T("BOOKING_DIALOG.CLOSE_ARIA")"></a>
      </div>

      <div class="modal-body">
        <p>@T("BOOKING_DIALOG.SLOT", ("time", booking.Time))</p>

        @* what the server needs to know in addition to the chosen patient *@
        <input type="hidden" name="doctorId" value="@Model.DoctorId">
        <input type="hidden" name="day" value="@Model.Day">
        <input type="hidden" name="slotId" value="@booking.SlotId">

        @if (booking.Patient is not null)
        {
          @* a patient books for themselves: no list to choose from
             (and the server ignores any clientId anyway) *@
          <p class="mb-0">@T("BOOKING_DIALOG.PATIENT_LABEL"): <strong>@booking.Patient</strong></p>
        }
        else
        {
          <label class="form-label" for="clientId">@T("BOOKING_DIALOG.PATIENT_LABEL")</label>
          <select id="clientId" name="clientId" class="form-select" required autofocus>
            <option value="">@T("BOOKING_DIALOG.CHOOSE_PATIENT")</option>
            @foreach (var client in booking.Clients)
            {
              <option value="@client.Id">@client.Label</option>
            }
          </select>
        }
      </div>

      <div class="modal-footer">
        <a class="btn btn-secondary" href="@Model.BackUrl">@T("BOOKING_DIALOG.CANCEL")</a>
        <button type="submit" class="btn btn-primary">@T("BOOKING_DIALOG.CONFIRM")</button>
      </div>
    </form>
  </div>
</div>
  • Line 11: The partial view receives the calendar page template. [Model.Booking!]: the page inserts it only if [Booking] is not [null], which the operator [!] asserts to the compiler;
  • lines 15–16: the classes [Bootstrap] for a modal window and its gray background, made visible “hard-coded” ([show d-block]): the server decides to display the window when URL contains the parameter [book]. The [JavaScript] from [Bootstrap] is not used (it isn’t even loaded);
  • line 18: the window’s content is the reservation form itself;
  • line 21: closing the window follows a link to the same calendar, without the [book] parameter ([BackUrl], prepared by the controller);
  • line 25: “Slot 8:00 - 8:20”: a translation parameter;
  • lines 28–30: hidden fields: additional information the server needs in addition to the selected patient;
  • lines 32–48: a patient sees their name; an administrator (ADMIN, DOCTOR) selects the patient from a list. The first option has an empty value: with [required], the browser requires that a real patient be selected. [autofocus] places the cursor in the list;
  • lines 52–53: “Cancel” is a link, as is the X; “Confirm” submits the form.

11.17.2. The pages

11.17.2.1. Views/Auth/Login.cshtml

@* =========================================================================
   Views/Auth/Login.cshtml - the login form.
   An ordinary HTML <form>: the browser itself sends login,
   password and captcha answer with POST /login, without JavaScript. On
   failure, the server sends back THIS page, with the login already typed, a
   NEW captcha and the error, displayed at the bottom of the page by the layout.

   @model: the type of the view model. The compiler checks each
   @Model.Xxx (a typo is a compilation error).
   ========================================================================= *@
@model LoginModel
@{
    var loginTyped = Model.Login != "";
}
<div class="row justify-content-center">
  <div class="col-sm-8 col-md-6 col-lg-5">
    <div class="card p-4">
      <h5 class="card-title mb-3">@T("LOGIN.TITLE")</h5>

      <form method="post" action="/login">
        <div class="mb-3">
          <label class="form-label" for="login">@T("LOGIN.LOGIN_LABEL")</label>
          <input id="login" name="login" type="text" class="form-control" autocomplete="username"
                 value="@Model.Login" required autofocus="@(!loginTyped)">
        </div>
        <div class="mb-3">
          <label class="form-label" for="password">@T("LOGIN.PASSWORD_LABEL")</label>
          <input id="password" name="password" type="password" class="form-control"
                 autocomplete="current-password" required autofocus="@loginTyped">
        </div>

        @await Html.PartialAsync("_Captcha", Model.Captcha)

        <button type="submit" class="btn btn-primary w-100">@T("LOGIN.SUBMIT")</button>

        @* no account yet: a patient can create one themselves.
           It is NOT a simple link: it is a second submit button of
           THIS form, which goes to another address (formaction). The
           server checks the captcha above there before opening the account
           creation page (see RegistrationController).
           formnovalidate: the browser does not require login and password
           (useless to create an account); it is the server that checks
           the captcha. Enter in a field always triggers the FIRST
           button of the form, "Log in". *@
        <button type="submit" class="btn btn-outline-primary w-100 mt-3"
                formaction="/register/access" formnovalidate>@T("LOGIN.CREATE_ACCOUNT")</button>
        <div class="form-text text-center">@T("LOGIN.CREATE_ACCOUNT_HELP")</div>
      </form>

      <div class="text-muted small mt-3">
        @T("LOGIN.DEMO_ACCOUNTS")
        <ul class="mb-0 mt-1">
          <li><code>admin</code> - @T("LOGIN.DEMO_ADMIN")</li>
          <li><code>pelissier</code>, <code>tatou</code>, <code>dupont</code> - @T("LOGIN.DEMO_DOCTOR")</li>
          <li><code>martin</code>, <code>durand</code>, <code>bistrou</code>, <code>german</code> - @T("LOGIN.DEMO_USER")</li>
        </ul>
      </div>
    </div>
  </div>
</div>
  • line 13: After a failure, the controller returns this page with the username already entered;
  • line 20: a standard HTML form: the browser submits the username, password, and CAPTCHA answer on its own;
  • lines 23–29: [autofocus] is a Boolean attribute: only one of the two fields receives it. The cursor is placed in the username field, or in the password field if the username is already known. The values of [autocomplete] ([username], [current-password]) assist the browsers’ password managers;
  • line 32: the CAPTCHA; the second argument provides the partial view template;
  • lines 45–46: the second button submits the same form to [/register/access] (attribute [formaction], see Example 20): the server verifies the CAPTCHA there before opening the account creation page. [formnovalidate]: the browser does not require a username and password, as they are not needed to create an account. Pressing the Enter key in a field always triggers the first button on the form: “Log in”;
  • lines 51–56: demo accounts (password = username).

11.17.2.2. Views/Schedule/Schedule.cshtml

@* =========================================================================
   Views/Schedule/Schedule.cshtml - the main page (see ScheduleController).
   Three blocks, like the three components of the Angular client:
     1. the choice of the doctor and the day  (<app-doctor-day-picker>)
     2. the schedule                          (<app-agenda>)
     3. the booking dialog                    (<app-booking-dialog>)
   A possible error (invalid date, unknown doctor) is displayed at the
   bottom of the page by the layout (see Views/Shared/_Errors.cshtml).
   ========================================================================= *@
@model ScheduleModel

@* 1. choice of the doctor and the day: a GET form, which simply
   reloads this page with ?doctorId=...&day=... in the URL *@
<form method="get" action="/schedule" class="card p-3 mb-3">
  <div class="row g-2 align-items-end">
    <div class="col-sm-5">
      <label class="form-label" for="doctorId">@T("DOCTOR_DAY_PICKER.DOCTOR_LABEL")</label>
      <select id="doctorId" name="doctorId" class="form-select" required>
        <option value="">@T("DOCTOR_DAY_PICKER.CHOOSE_DOCTOR")</option>
        @foreach (var doctor in Model.Doctors)
        {
          <option value="@doctor.Id" selected="@doctor.Selected">@doctor.Label</option>
        }
      </select>
    </div>
    <div class="col-sm-4">
      <label class="form-label" for="day">@T("DOCTOR_DAY_PICKER.DAY_LABEL")</label>
      <input id="day" name="day" type="date" class="form-control" value="@Model.Day" required>
    </div>
    <div class="col-sm-3">
      <button type="submit" class="btn btn-primary w-100">@T("DOCTOR_DAY_PICKER.VIEW_SCHEDULE")</button>
    </div>
  </div>
</form>

@* patient who already has an upcoming appointment: why there is no "Book" button *@
@if (Model.Info is not null)
{
  <div class="alert alert-info">
    @T(Model.Info) <a href="/my-area" class="alert-link">@T("APP.MENU_MY_AREA")</a>
  </div>
}

@* 2. the schedule *@
@if (Model.Schedule is { } schedule)
{
  <div class="card p-3">
    <h5>@T("SCHEDULE.TITLE_PREFIX") @schedule.Day - @schedule.Doctor</h5>

    @if (schedule.Rows.Count > 0)
    {
      <table class="table table-hover align-middle">
        <thead>
          <tr>
            <th>@T("SCHEDULE.COLUMN_SLOT")</th>
            <th>@T("SCHEDULE.COLUMN_STATUS")</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          @foreach (var row in schedule.Rows)
          {
            <tr class="@(row.Bookable ? "free-slot" : null)">
              <td>@row.Time</td>
              <td>
                @* [Patient] is filled only if the user is allowed to
                   see this name (ADMIN, DOCTOR, or the patient for THEIR
                   appointment) - otherwise, simply "Booked" *@
                @if (row.Free)
                {
                  <span class="badge text-bg-success">@T("SCHEDULE.FREE")</span>
                }
                else if (row.Patient is not null)
                {
                  <span class="badge @(row.Mine ? "text-bg-primary" : "text-bg-secondary")">@row.Patient</span>
                }
                else
                {
                  <span class="badge text-bg-secondary">@T("SCHEDULE.BOOKED_BY_OTHER")</span>
                }
              </td>
              <td>
                @* the displayed buttons have been decided by the controller
                   (Bookable, AppointmentId) according to the role of the user *@
                @if (row.Bookable)
                {
                  @* "Book" is a simple link: it reopens this page
                     with the booking dialog of this slot *@
                  <a class="btn btn-sm btn-outline-success" href="@row.BookUrl">@T("SCHEDULE.BOOK")</a>
                }
                @if (row.AppointmentId is not null)
                {
                  @* "Cancel" MODIFIES data: it is therefore a POST *@
                  <form method="post" action="/schedule/cancel" class="m-0"
                        data-confirmation="@T("SCHEDULE.CANCEL_CONFIRM")">
                    <input type="hidden" name="appointmentId" value="@row.AppointmentId">
                    <input type="hidden" name="doctorId" value="@Model.DoctorId">
                    <input type="hidden" name="day" value="@Model.Day">
                    <button type="submit" class="btn btn-sm btn-outline-danger">@T("SCHEDULE.CANCEL_APPOINTMENT")</button>
                  </form>
                }
              </td>
            </tr>
          }
        </tbody>
      </table>
    }
    else
    {
      <p class="text-muted mb-0">@T("SCHEDULE.NO_SLOTS")</p>
    }
  </div>
}
else
{
  <p class="text-muted">@T("SCHEDULE.EMPTY_STATE")</p>
}

@* 3. the booking dialog, only if the URL contains &book=... *@
@if (Model.Booking is not null)
{
  @await Html.PartialAsync("_Booking", Model)
}

Let’s comment on this code:

  • lines 14–34: selecting the doctor and the day: a GET form (see Example 17), which reloads the page with [?doctorId=...&day=...] in the URL. Each doctor is a [OptionView] that indicates whether they are selected. A [type="date"] field sends the date in [yyyy-mm-dd] format (see Example 18);
  • lines 37–42: a message for a patient who already has an upcoming appointment: it explains why the “Book” buttons are missing;
  • line 45: the calendar, if requested. The condition [is { } schedule] checks that the property is not [null] and stores its value in the variable [schedule];
  • line 48: the title: “Schedule for 2026-09-25 – Mme Marie PELISSIER”;
  • line 63: a bookable time slot changes color on hover (class [free-slot], see [styles.css]). When an attribute’s value is [null], [Razor] does not write the attribute at all: the other lines do not have the [class] attribute;
  • Lines 69–80: three cases: available; booked, with the patient’s name (in blue if it is the logged-in patient’s appointment, [Mine]); booked with no visible name, “Booked.” The controller only populates [Patient] if the user has permission to view that name;
  • Lines 85–90: “Book” is a link: it reopens this page, displaying the reservation window for this time slot ([BookUrl], prepared by the controller);
  • lines 91–101: “Cancel” modifies data: it is a POST form, with confirmation ([data-confirmation], see Example 21). The hidden fields [doctorId] and [day] allow the controller to redirect to the same calendar. In the loop, [Model] always refers to the page template: there is no need for the equivalent of [@root] or [Handlebars];
  • lines 110 and 116: a doctor with no available time slots, or no calendar has been requested yet;
  • lines 120–123: the booking window, if the controller has prepared it.

11.17.2.3. Views/Persons/List.cshtml

@* =========================================================================
   Views/Persons/List.cshtml - the list of the doctors OR of the clients (ADMIN).
   A single view for both: the labels specific to each one arrive in
   Model.Keys (see DoctorsController / ClientsController).
   ========================================================================= *@
@model PersonListModel
<div class="d-flex justify-content-between align-items-center mb-3">
  <h4 class="mb-0">@T(Model.Keys.Title)</h4>
  <a class="btn btn-primary" href="@Model.AddUrl">@T(Model.Keys.Add)</a>
</div>

<div class="card p-3">
  @if (Model.Persons.Count > 0)
  {
    <table class="table table-hover align-middle mb-0">
      <thead>
        <tr>
          <th>@T("PERSONS.COLUMN_TITLE")</th>
          <th>@T("PERSONS.COLUMN_FIRSTNAME")</th>
          <th>@T("PERSONS.COLUMN_LASTNAME")</th>
          <th>@T("PERSONS.COLUMN_LOGIN")</th>
          <th></th>
        </tr>
      </thead>
      <tbody>
        @foreach (var person in Model.Persons)
        {
          <tr>
            <td>@person.Title</td>
            <td>@person.FirstName</td>
            <td>@person.LastName</td>
            <td><code>@person.Login</code></td>
            <td class="text-end">
              <div class="d-inline-flex gap-2">
                <a class="btn btn-sm btn-outline-primary" href="@person.EditUrl">@T("PERSONS.EDIT")</a>
                @* data-confirmation: a confirmation dialog will open
                   before the form is sent (see wwwroot/js/app.js) *@
                <form method="post" action="@person.DeleteUrl" class="m-0"
                      data-confirmation="@T(Model.Keys.ConfirmDelete)">
                  <button type="submit" class="btn btn-sm btn-outline-danger">@T("PERSONS.DELETE")</button>
                </form>
              </div>
            </td>
          </tr>
        }
      </tbody>
    </table>
  }
  else
  {
    <p class="text-muted mb-0">@T(Model.Keys.Empty)</p>
  }
</div>

A single view for doctors and clients:

  • Lines 8–9: text specific to each user appears as keys ([Model.Keys], of the type [PersonKeys]), provided by [DoctorsController] or [ClientsController]: [DOCTORS.TITLE] or [CLIENTS.TITLE], etc.;
  • Lines 26–45: one line per person, with two buttons;
  • line 35: “Edit” is a link to the form;
  • lines 38–41: “Delete” is a POST form, with confirmation;
  • line 51: “No doctors.” or “No patients.”

11.17.2.4. Views/Persons/Form.cshtml

@* =========================================================================
   Views/Persons/Form.cshtml - creation OR update of a doctor
   or of a client (ADMIN). Model.Creating distinguishes the two cases.

   When the input is refused (empty field, login already taken...), the
   controller sends back THIS view with:
     - the values already typed (Model.Values);
     - the faulty fields outlined in red (Bootstrap "is-invalid" class);
     - under a field refused by the validation, its message;
     - the error message of the page, displayed AT THE BOTTOM of the page by the
       layout (see Views/Shared/_Errors.cshtml).
   ========================================================================= *@
@model PersonFormModel
<div class="row justify-content-center">
  <div class="col-md-8 col-lg-6">
    <div class="card p-4">
      <h5 class="card-title mb-3">@T(Model.PageTitle)</h5>

      @* novalidate: we let the SERVER validate (it is the one that has authority);
         the browser checks (required...) are only a convenience,
         which a user can always bypass *@
      <form method="post" action="@Model.Action" novalidate>
        @if (!Model.Creating)
        {
          <input type="hidden" name="version" value="@Model.Values.Version">
        }

        @* title, first name, last name: partial common to three forms *@
        @await Html.PartialAsync("_PersonFields", Model)

        <h6 class="mt-4 text-muted">@T("PERSONS.ACCOUNT_SECTION")</h6>

        <div class="mb-3">
          <label class="form-label" for="login">@T("PERSONS.LOGIN_LABEL")</label>
          <input id="login" name="login" type="text" maxlength="30" autocomplete="off"
                 class="form-control @Model.InvalidClass("login")" value="@Model.Values.Login">
          @if (Model.ErrorOf("login") is { } loginError)
          {
            <div class="invalid-feedback">@T(loginError)</div>
          }
          else
          {
            <div class="form-text">@T(Model.Creating ? "PERSONS.LOGIN_HELP_CREATE" : "PERSONS.LOGIN_HELP_EDIT")</div>
          }
        </div>

        <div class="mb-4">
          <label class="form-label" for="password">@T("PERSONS.PASSWORD_LABEL")</label>
          @* never pre-filled: the password never goes back to the browser *@
          <input id="password" name="password" type="password" maxlength="72" autocomplete="new-password"
                 class="form-control @Model.InvalidClass("password")">
          @if (Model.ErrorOf("password") is { } passwordError)
          {
            <div class="invalid-feedback">@T(passwordError)</div>
          }
          else
          {
            <div class="form-text">@T(Model.Creating ? "PERSONS.PASSWORD_HELP_CREATE" : "PERSONS.PASSWORD_HELP_EDIT")</div>
          }
        </div>

        <div class="d-flex gap-2">
          <button type="submit" class="btn btn-primary">@T("PERSONS.SAVE")</button>
          <a class="btn btn-secondary" href="@Model.ListUrl">@T("PERSONS.BACK")</a>
        </div>
      </form>
    </div>
  </div>
</div>

Let’s comment on this code:

  • line 17: The title (“Add a doctor,” “Edit a patient,” etc.) is the page title key, provided by the controller;
  • line 22: The URL is provided by the controller: the same view is used for both adding and editing doctors and clients. [novalidate]: The server handles validation (see Example 19);
  • lines 23–25: for an edit, the record version, in a hidden field: this is the optimistic lock from Example 28;
  • line 29: the identity fields;
  • lines 34–44: the login, with an error message or, if none, a help text. The help text differs depending on whether it is an addition (“Optional: defaults to the last name in lower case.”) or an edit;
  • lines 48–59: the password, which is never pre-filled; it is never sent back to the browser. [autocomplete="new-password"]: the browser does not suggest the administrator’s saved password. The length is limited to 72 characters because the [bcrypt] algorithm ignores the following characters;
  • Line 64: The link back to the list is a hyperlink.

11.17.2.5. Views/Registration/Registration.cshtml

@* =========================================================================
   Views/Registration/Registration.cshtml - "Create a patient account" (public
   page, see RegistrationController). Same identity fields as the
   ADMIN's form (_PersonFields partial), but the login and the
   password are required, with confirmation.
   No captcha HERE: this page is only reached after copying
   the one of the authentication page ("Create a patient account" button).
   ========================================================================= *@
@model RegistrationModel
<div class="row justify-content-center">
  <div class="col-md-8 col-lg-6">
    <div class="card p-4">
      <h5 class="card-title mb-1">@T("REGISTRATION.TITLE")</h5>
      <p class="text-muted small mb-3">@T("REGISTRATION.INTRO")</p>

      <form method="post" action="/register" novalidate>
        @await Html.PartialAsync("_PersonFields", Model)

        <h6 class="mt-4 text-muted">@T("PERSONS.ACCOUNT_SECTION")</h6>

        <div class="mb-3">
          <label class="form-label" for="login">@T("PERSONS.LOGIN_LABEL")</label>
          <input id="login" name="login" type="text" maxlength="30" autocomplete="username" required
                 class="form-control @Model.InvalidClass("login")" value="@Model.Values.Login">
          @if (Model.ErrorOf("login") is { } loginError)
          {
            <div class="invalid-feedback">@T(loginError)</div>
          }
          else
          {
            <div class="form-text">@T("REGISTRATION.LOGIN_HELP")</div>
          }
        </div>

        <div class="row g-3 mb-3">
          <div class="col-sm-6">
            <label class="form-label" for="password">@T("PERSONS.PASSWORD_LABEL")</label>
            <input id="password" name="password" type="password" maxlength="72" autocomplete="new-password" required
                   class="form-control @Model.InvalidClass("password")">
            @if (Model.ErrorOf("password") is { } passwordError)
            {
              <div class="invalid-feedback">@T(passwordError, ("min", Model.PasswordMin))</div>
            }
            else
            {
              <div class="form-text">@T("REGISTRATION.PASSWORD_HELP", ("min", Model.PasswordMin))</div>
            }
          </div>
          <div class="col-sm-6">
            <label class="form-label" for="confirmation">@T("ACCOUNT.CONFIRMATION_LABEL")</label>
            <input id="confirmation" name="confirmation" type="password" maxlength="72" autocomplete="new-password" required
                   class="form-control @Model.InvalidClass("confirmation")">
            @if (Model.ErrorOf("confirmation") is { } confirmationError)
            {
              <div class="invalid-feedback">@T(confirmationError)</div>
            }
          </div>
        </div>

        <div class="d-flex gap-2">
          <button type="submit" class="btn btn-primary">@T("REGISTRATION.SUBMIT")</button>
          <a class="btn btn-secondary" href="/login">@T("REGISTRATION.BACK_TO_LOGIN")</a>
        </div>
      </form>
    </div>
  </div>
</div>

Creating a customer account, public page. No CAPTCHA here: you can only reach this page after entering the CAPTCHA from the login page.

  • line 17: the same identity fields as the form on ADMIN;
  • Lines 22–32: the username, which is required this time;
  • lines 37–47: the help text and the “too short” message have a parameter, [{min}], which is the minimum password length provided by the template ([PasswordMin]). A message without the parameter [{min}] simply ignores the provided value;
  • lines 50–56: password confirmation;
  • line 62: return to the login page.

11.17.2.6. Views/PatientArea/Area.cshtml

@* =========================================================================
   Views/PatientArea/Area.cshtml - "My area": the home page of the
   patient (USER role), displayed right after logging in.
     - two options: [Manage my account] and [View a schedule];
     - their upcoming appointment, if they have one, with the means to cancel it.
   ========================================================================= *@
@model AreaModel
<h4 class="mb-3">@T("AREA.HELLO", ("name", Model.Patient))</h4>

<div class="row g-3 mb-4">
  <div class="col-sm-6">
    <a class="card p-4 h-100 text-decoration-none option-card" href="/my-account">
      <h5 class="mb-1">@T("AREA.MANAGE_ACCOUNT")</h5>
      <span class="text-muted">@T("AREA.MANAGE_ACCOUNT_HELP")</span>
    </a>
  </div>
  <div class="col-sm-6">
    <a class="card p-4 h-100 text-decoration-none option-card" href="/schedule">
      <h5 class="mb-1">@T("AREA.VIEW_SCHEDULE")</h5>
      <span class="text-muted">@T("AREA.VIEW_SCHEDULE_HELP")</span>
    </a>
  </div>
</div>

<div class="card p-3">
  <h5>@T("AREA.MY_APPOINTMENT")</h5>
  @if (Model.Appointments.Count > 0)
  {
    @* normally only one (rule "one upcoming appointment at a time"),
       but the page can display several *@
    for (var i = 0; i < Model.Appointments.Count; i++)
    {
      var appointment = Model.Appointments[i];
      var last = i == Model.Appointments.Count - 1;
      <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 py-2 @(last ? "" : "border-bottom")">
        <div>
          <div class="fw-semibold">@appointment.Day, @appointment.Time</div>
          <div class="text-muted">@appointment.Doctor - <a href="@appointment.ScheduleUrl">@T("AREA.SEE_IN_SCHEDULE")</a></div>
        </div>
        <form method="post" action="/my-area/cancel" class="m-0"
              data-confirmation="@T("SCHEDULE.CANCEL_CONFIRM")">
          <input type="hidden" name="appointmentId" value="@appointment.Id">
          <button type="submit" class="btn btn-outline-danger">@T("AREA.CANCEL_APPOINTMENT")</button>
        </form>
      </div>
    }
  }
  else
  {
    <p class="text-muted mb-0">@T("AREA.NO_APPOINTMENT")</p>
  }
</div>
  • line 8: “Hello Mr Jean MARTIN”;
  • lines 12–21: both options are links, displayed as cards (class [option-card], see [styles.css]);
  • lines 31–46: upcoming appointments (usually just one). Within a [@if] block, the format is [C#]: the [for] loop is written without [@]. A border ([border-bottom]) separates the appointments, except after the last one;
  • lines 40–44: the cancellation, a POST form with confirmation.

11.17.2.7. Views/PatientArea/Account.cshtml

@* =========================================================================
   Views/PatientArea/Account.cshtml - "Manage my account" (USER role).
   Two independent forms on the same page:
     1. the update (identity, login, optional new password);
     2. the deletion of the account - which also cancels its appointments.
   Both require the CURRENT password.
   ========================================================================= *@
@model MyAccountModel
<div class="row justify-content-center">
  <div class="col-md-8 col-lg-6">

    @* 1. update *@
    <div class="card p-4 mb-4">
      <h5 class="card-title mb-3">@T("MY_ACCOUNT.TITLE")</h5>

      <form method="post" action="/my-account" novalidate>
        <input type="hidden" name="version" value="@Model.Values.Version">

        @await Html.PartialAsync("_PersonFields", Model)

        <h6 class="mt-4 text-muted">@T("PERSONS.ACCOUNT_SECTION")</h6>

        <div class="mb-3">
          <label class="form-label" for="login">@T("PERSONS.LOGIN_LABEL")</label>
          <input id="login" name="login" type="text" maxlength="30" autocomplete="username" required
                 class="form-control @Model.InvalidClass("login")" value="@Model.Values.Login">
          @if (Model.ErrorOf("login") is { } loginError)
          {
            <div class="invalid-feedback">@T(loginError)</div>
          }
        </div>

        <div class="row g-3 mb-3">
          <div class="col-sm-6">
            <label class="form-label" for="password">@T("MY_ACCOUNT.NEW_PASSWORD_LABEL")</label>
            <input id="password" name="password" type="password" maxlength="72" autocomplete="new-password"
                   class="form-control @Model.InvalidClass("password")">
            @if (Model.ErrorOf("password") is { } passwordError)
            {
              <div class="invalid-feedback">@T(passwordError, ("min", Model.PasswordMin))</div>
            }
            else
            {
              <div class="form-text">@T("PERSONS.PASSWORD_HELP_EDIT")</div>
            }
          </div>
          <div class="col-sm-6">
            <label class="form-label" for="confirmation">@T("ACCOUNT.CONFIRMATION_LABEL")</label>
            <input id="confirmation" name="confirmation" type="password" maxlength="72" autocomplete="new-password"
                   class="form-control @Model.InvalidClass("confirmation")">
            @if (Model.ErrorOf("confirmation") is { } confirmationError)
            {
              <div class="invalid-feedback">@T(confirmationError)</div>
            }
          </div>
        </div>

        <div class="mb-4">
          <label class="form-label" for="currentPassword">@T("MY_ACCOUNT.CURRENT_PASSWORD_LABEL")</label>
          <input id="currentPassword" name="currentPassword" type="password" maxlength="72" autocomplete="current-password" required
                 class="form-control @Model.InvalidClass("currentPassword")">
          @if (Model.ErrorOf("currentPassword") is { } currentError)
          {
            <div class="invalid-feedback">@T(currentError)</div>
          }
          else
          {
            <div class="form-text">@T("MY_ACCOUNT.CURRENT_PASSWORD_HELP")</div>
          }
        </div>

        <div class="d-flex gap-2">
          <button type="submit" class="btn btn-primary">@T("PERSONS.SAVE")</button>
          <a class="btn btn-secondary" href="/my-area">@T("MY_ACCOUNT.BACK")</a>
        </div>
      </form>
    </div>

    @* 2. deletion *@
    <div class="card p-4 border-danger">
      <h5 class="card-title text-danger mb-1">@T("MY_ACCOUNT.DELETE_TITLE")</h5>
      <p class="text-muted small">@T("MY_ACCOUNT.DELETE_HELP")</p>
      <form method="post" action="/my-account/delete" class="d-flex flex-wrap gap-2 align-items-end"
            data-confirmation="@T("MY_ACCOUNT.DELETE_CONFIRM")">
        <div class="flex-grow-1">
          <label class="form-label" for="deletePassword">@T("MY_ACCOUNT.CURRENT_PASSWORD_LABEL")</label>
          <input id="deletePassword" name="deletePassword" type="password" maxlength="72"
                 autocomplete="current-password" required class="form-control">
        </div>
        <button type="submit" class="btn btn-danger">@T("MY_ACCOUNT.DELETE_SUBMIT")</button>
      </form>
    </div>

  </div>
</div>
  • lines 16–76: account modification: identity, login, new password (optional), and its confirmation;
  • line 17: the version of the customer record (optimistic lock);
  • lines 59–69: the current password, required for any changes;
  • Lines 83–91: Deletion, with confirmation. Two independent forms on the same page: each submits only its own fields. The password field therefore has a different name here ([deletePassword]) and a different identifier. This form does not have the [novalidate] attribute: the browser requires the password ([required]) even before triggering the [submit] event—that is, before asking the confirmation question.

11.17.2.8. Views/Shared/Error.cshtml

@* =========================================================================
   Views/Shared/Error.cshtml - the error page (400, 403, 404, 500),
   built by ErrorController.
   ========================================================================= *@
@model ErrorModel
<div class="row justify-content-center">
  <div class="col-md-8 col-lg-6">
    <div class="card p-4 text-center">
      <h1 class="display-4 text-danger">@Model.Code</h1>
      <p class="lead">@T(Model.Message)</p>
      <div><a class="btn btn-primary" href="/">@T("ERROR_PAGE.BACK")</a></div>
    </div>
  </div>
</div>
  • Lines 9–10: The error page generated by [ErrorController] (400, 403, 404, 500): the code HTTP and the translated message. Stored in [Views/Shared], it is retrieved regardless of which controller requests it.

11.18. The dictionaries

The dictionaries are those from version [NestJS], with no changes: they are read by the [Translator] service (see Example 22).

11.18.1. i18n/fr.json

{
  "APP": {
    "TITLE": "RdvMedecins",
    "LOGOUT": "Se déconnecter",
    "MENU_SCHEDULE": "Agenda",
    "MENU_DOCTORS": "Médecins",
    "MENU_PATIENTS": "Clients",
    "MENU_MY_AREA": "Mon espace"
  },
  "LOGIN": {
    "TITLE": "Connexion",
    "LOGIN_LABEL": "Login",
    "PASSWORD_LABEL": "Mot de passe",
    "SUBMIT": "Se connecter",
    "ERROR": "Login ou mot de passe incorrect.",
    "DEMO_ACCOUNTS": "Comptes de démonstration (mot de passe = login) :",
    "DEMO_ADMIN": "rôle ADMIN : rendez-vous + gestion des médecins et des clients",
    "DEMO_DOCTOR": "rôle DOCTOR : prise et annulation de rendez-vous",
    "DEMO_USER": "rôle USER (patients) : réservation pour eux-mêmes, gestion de leur compte",
    "CREATE_ACCOUNT": "Créer un compte client",
    "CREATE_ACCOUNT_HELP": "Pour créer un compte, recopiez d'abord les caractères de l'image."
  },
  "DOCTOR_DAY_PICKER": {
    "DOCTOR_LABEL": "Médecin",
    "CHOOSE_DOCTOR": "-- choisir un médecin --",
    "DAY_LABEL": "Jour",
    "VIEW_SCHEDULE": "Voir l'agenda"
  },
  "SCHEDULE": {
    "PAGE_TITLE": "Agenda",
    "TITLE_PREFIX": "Agenda du",
    "COLUMN_SLOT": "Créneau",
    "COLUMN_STATUS": "Statut",
    "FREE": "Libre",
    "BOOK": "Réserver",
    "CANCEL_APPOINTMENT": "Annuler",
    "CANCEL_CONFIRM": "Annuler ce rendez-vous ?",
    "EMPTY_STATE": "Choisissez un médecin et un jour, puis cliquez sur « Voir l'agenda ».",
    "NO_SLOTS": "Ce médecin n'a aucun créneau horaire.",
    "BOOKED": "Rendez-vous enregistré.",
    "CANCELLED": "Rendez-vous annulé.",
    "BOOKED_BY_OTHER": "Réservé",
    "ALREADY_BOOKED_INFO": "Vous avez déjà un rendez-vous à venir : annulez-le pour en prendre un autre."
  },
  "BOOKING_DIALOG": {
    "TITLE": "Réserver ce créneau",
    "CLOSE_ARIA": "Fermer",
    "SLOT": "Créneau de {time}",
    "PATIENT_LABEL": "Patient",
    "CHOOSE_PATIENT": "-- choisir un patient --",
    "CANCEL": "Annuler",
    "CONFIRM": "Confirmer"
  },
  "PERSONS": {
    "COLUMN_TITLE": "Titre",
    "COLUMN_FIRSTNAME": "Prénom",
    "COLUMN_LASTNAME": "Nom",
    "COLUMN_LOGIN": "Login",
    "EDIT": "Modifier",
    "DELETE": "Supprimer",
    "SAVE": "Enregistrer",
    "BACK": "Retour à la liste",
    "TITLE_LABEL": "Titre",
    "FIRSTNAME_LABEL": "Prénom",
    "LASTNAME_LABEL": "Nom",
    "LOGIN_LABEL": "Login",
    "PASSWORD_LABEL": "Mot de passe",
    "LOGIN_HELP_CREATE": "Facultatif : par défaut, le nom en minuscules.",
    "LOGIN_HELP_EDIT": "Laisser vide pour ne pas le changer.",
    "PASSWORD_HELP_CREATE": "Facultatif : par défaut, identique au login.",
    "PASSWORD_HELP_EDIT": "Laisser vide pour ne pas le changer.",
    "ACCOUNT_SECTION": "Compte utilisateur"
  },
  "DOCTORS": {
    "TITLE": "Médecins",
    "ADD": "Ajouter un médecin",
    "EDIT": "Modifier un médecin",
    "EMPTY": "Aucun médecin.",
    "DELETE_CONFIRM": "Supprimer ce médecin, ses créneaux horaires et son compte utilisateur ?",
    "ADDED": "Le médecin {name} a été ajouté (login : {login}).",
    "UPDATED": "Le médecin {name} a été modifié.",
    "DELETED": "Le médecin {name} a été supprimé."
  },
  "CLIENTS": {
    "TITLE": "Clients",
    "ADD": "Ajouter un client",
    "EDIT": "Modifier un client",
    "EMPTY": "Aucun client.",
    "DELETE_CONFIRM": "Supprimer ce client, son compte utilisateur et tous ses rendez-vous ?",
    "ADDED": "Le client {name} a été ajouté (login : {login}).",
    "UPDATED": "Le client {name} a été modifié.",
    "DELETED": "Le client {name} a été supprimé."
  },
  "ERROR_PAGE": {
    "TITLE": "Erreur",
    "BACK": "Retour à l'accueil"
  },
  "ERRORS": {
    "LOGIN_TAKEN": "Ce login est déjà utilisé par un autre compte.",
    "DOCTOR_ALREADY_EXISTS": "Un médecin portant ce prénom et ce nom existe déjà.",
    "PATIENT_ALREADY_EXISTS": "Un client portant ce prénom et ce nom existe déjà.",
    "DOCTOR_HAS_APPOINTMENTS": "Ce médecin a des rendez-vous : annulez-les avant de le supprimer.",
    "SLOT_ALREADY_BOOKED": "Ce créneau vient d'être réservé par quelqu'un d'autre.",
    "CONCURRENT_UPDATE": "Cette fiche a été modifiée entre-temps par un autre utilisateur. Revenez à la liste et rouvrez-la avant de recommencer.",
    "DOCTOR_NOT_FOUND": "Ce médecin n'existe pas (ou plus).",
    "PATIENT_NOT_FOUND": "Ce client n'existe pas (ou plus).",
    "SLOT_NOT_FOUND": "Ce créneau n'existe pas (ou plus).",
    "APPOINTMENT_NOT_FOUND": "Ce rendez-vous n'existe pas (ou plus).",
    "INVALID_DATE": "La date demandée n'est pas valide.",
    "CHOOSE_PATIENT": "Choisissez un patient.",
    "INVALID_FORM": "Le formulaire contient des erreurs.",
    "INVALID_TITLE": "Choisissez un titre dans la liste.",
    "FIRSTNAME_REQUIRED": "Le prénom est obligatoire.",
    "FIRSTNAME_TOO_LONG": "Le prénom ne doit pas dépasser 20 caractères.",
    "LASTNAME_REQUIRED": "Le nom est obligatoire.",
    "LASTNAME_TOO_LONG": "Le nom ne doit pas dépasser 20 caractères.",
    "INVALID_LOGIN": "30 caractères au plus : lettres minuscules non accentuées, chiffres, point, tiret, souligné.",
    "PASSWORD_TOO_LONG": "Le mot de passe ne doit pas dépasser 72 caractères.",
    "SESSION_EXPIRED": "Votre session a expiré : reconnectez-vous.",
    "BAD_REQUEST": "La requête est invalide.",
    "FORBIDDEN": "Votre rôle ne vous permet pas d'accéder à cette page.",
    "NOT_FOUND": "Cette page n'existe pas.",
    "SERVER_ERROR": "Une erreur inattendue s'est produite. Réessayez plus tard.",
    "TOO_MANY_ATTEMPTS": "Trop de tentatives infructueuses. Réessayez dans {minutes} minute(s).",
    "WRONG_CAPTCHA": "Les caractères recopiés ne correspondent pas à l'image. Une nouvelle image vous est proposée.",
    "TOO_MANY_ACCOUNTS": "Trop de comptes ont été créés depuis votre connexion. Réessayez dans {minutes} minute(s).",
    "LOGIN_REQUIRED": "Le login est obligatoire.",
    "PASSWORD_REQUIRED": "Le mot de passe est obligatoire.",
    "PASSWORD_TOO_SHORT": "Le mot de passe doit comporter au moins {min} caractères.",
    "CONFIRMATION_REQUIRED": "Confirmez le mot de passe.",
    "PASSWORDS_DIFFER": "La confirmation ne correspond pas au mot de passe.",
    "CURRENT_PASSWORD_REQUIRED": "Saisissez votre mot de passe actuel.",
    "WRONG_CURRENT_PASSWORD": "Mot de passe actuel incorrect.",
    "PAST_DATE": "On ne peut pas prendre de rendez-vous à une date passée.",
    "PATIENT_HAS_UPCOMING_APPOINTMENT": "Ce patient a déjà un rendez-vous à venir : il faut l'annuler avant d'en prendre un autre.",
    "CAPTCHA_BEFORE_ACCOUNT": "Pour créer un compte, recopiez d'abord correctement les caractères de l'image, puis cliquez sur « Créer un compte client »."
  },
  "CAPTCHA": {
    "LABEL": "Recopiez les caractères de l'image",
    "IMAGE_ALT": "Image de vérification anti-robots",
    "NEW_IMAGE": "Autre image",
    "HELP": "Majuscules ou minuscules, peu importe."
  },
  "REGISTRATION": {
    "TITLE": "Créer un compte client",
    "INTRO": "Ce compte vous permettra de prendre vous-même vos rendez-vous.",
    "LOGIN_HELP": "Lettres minuscules non accentuées, chiffres, point, tiret, souligné.",
    "PASSWORD_HELP": "Au moins {min} caractères.",
    "SUBMIT": "Créer mon compte",
    "BACK_TO_LOGIN": "Retour à la connexion",
    "DONE": "Votre compte a été créé. Vous pouvez vous connecter avec le login {login}."
  },
  "ACCOUNT": {
    "CONFIRMATION_LABEL": "Confirmation du mot de passe"
  },
  "AREA": {
    "TITLE": "Mon espace",
    "HELLO": "Bonjour {name}",
    "MANAGE_ACCOUNT": "Gérer mon compte",
    "MANAGE_ACCOUNT_HELP": "Modifier mes informations, mon mot de passe, ou supprimer mon compte.",
    "VIEW_SCHEDULE": "Voir un agenda",
    "VIEW_SCHEDULE_HELP": "Consulter l'agenda d'un médecin et réserver un créneau.",
    "MY_APPOINTMENT": "Mon rendez-vous",
    "SEE_IN_SCHEDULE": "voir dans l'agenda",
    "CANCEL_APPOINTMENT": "Annuler ce rendez-vous",
    "NO_APPOINTMENT": "Vous n'avez aucun rendez-vous à venir."
  },
  "MY_ACCOUNT": {
    "TITLE": "Gérer mon compte",
    "NEW_PASSWORD_LABEL": "Nouveau mot de passe",
    "CURRENT_PASSWORD_LABEL": "Mot de passe actuel",
    "CURRENT_PASSWORD_HELP": "Obligatoire pour enregistrer une modification.",
    "BACK": "Retour à mon espace",
    "DELETE_TITLE": "Supprimer mon compte",
    "DELETE_HELP": "Votre compte sera définitivement supprimé, ainsi que tous vos rendez-vous.",
    "DELETE_CONFIRM": "Supprimer définitivement votre compte ? Votre rendez-vous éventuel sera annulé.",
    "DELETE_SUBMIT": "Supprimer mon compte",
    "UPDATED": "Votre compte a été modifié.",
    "DELETED": "Votre compte a été supprimé."
  },
  "CONFIRMATION": {
    "TITLE": "Confirmation",
    "NO": "Non, revenir",
    "YES": "Oui, confirmer"
  }
}
  • The sections group the texts by page ([LOGIN], [SCHEDULE], [AREA]...) or by topic ([ERRORS], [CONFIRMATION]). Some names ([DOCTOR_DAY_PICKER], [BOOKING_DIALOG]) correspond to components of the client [Angular], whose dictionaries were inherited by version [NestJS];
  • line 80: text with two parameters, [{name}] and [{login}], which are replaced during translation;
  • Lines 98–136: error messages. These are the keys used by the [BusinessException] components in the business layer, the form validation attributes ([ErrorMessage = "ERRORS.FIRSTNAME_REQUIRED"]), the controllers, and the exception filter. Three of them have a parameter: [{minutes}] and [{min}].

11.18.2. i18n/en.json

{
  "APP": {
    "TITLE": "RdvMedecins",
    "LOGOUT": "Log out",
    "MENU_SCHEDULE": "Schedule",
    "MENU_DOCTORS": "Doctors",
    "MENU_PATIENTS": "Patients",
    "MENU_MY_AREA": "My area"
  },
  "LOGIN": {
    "TITLE": "Login",
    "LOGIN_LABEL": "Login",
    "PASSWORD_LABEL": "Password",
    "SUBMIT": "Log in",
    "ERROR": "Incorrect login or password.",
    "DEMO_ACCOUNTS": "Demo accounts (password = login):",
    "DEMO_ADMIN": "ADMIN role: appointments + doctor and patient management",
    "DEMO_DOCTOR": "DOCTOR role: booking and cancelling appointments",
    "DEMO_USER": "USER role (patients): booking for themselves, managing their account",
    "CREATE_ACCOUNT": "Create a patient account",
    "CREATE_ACCOUNT_HELP": "To create an account, first type the characters shown in the image."
  },
  "DOCTOR_DAY_PICKER": {
    "DOCTOR_LABEL": "Doctor",
    "CHOOSE_DOCTOR": "-- choose a doctor --",
    "DAY_LABEL": "Day",
    "VIEW_SCHEDULE": "View schedule"
  },
  "SCHEDULE": {
    "PAGE_TITLE": "Schedule",
    "TITLE_PREFIX": "Schedule for",
    "COLUMN_SLOT": "Slot",
    "COLUMN_STATUS": "Status",
    "FREE": "Free",
    "BOOK": "Book",
    "CANCEL_APPOINTMENT": "Cancel",
    "CANCEL_CONFIRM": "Cancel this appointment?",
    "EMPTY_STATE": "Choose a doctor and a day, then click \"View schedule\".",
    "NO_SLOTS": "This doctor has no time slots.",
    "BOOKED": "Appointment booked.",
    "CANCELLED": "Appointment cancelled.",
    "BOOKED_BY_OTHER": "Booked",
    "ALREADY_BOOKED_INFO": "You already have an upcoming appointment: cancel it to book another one."
  },
  "BOOKING_DIALOG": {
    "TITLE": "Book this slot",
    "CLOSE_ARIA": "Close",
    "SLOT": "Slot {time}",
    "PATIENT_LABEL": "Patient",
    "CHOOSE_PATIENT": "-- choose a patient --",
    "CANCEL": "Cancel",
    "CONFIRM": "Confirm"
  },
  "PERSONS": {
    "COLUMN_TITLE": "Title",
    "COLUMN_FIRSTNAME": "First name",
    "COLUMN_LASTNAME": "Last name",
    "COLUMN_LOGIN": "Login",
    "EDIT": "Edit",
    "DELETE": "Delete",
    "SAVE": "Save",
    "BACK": "Back to the list",
    "TITLE_LABEL": "Title",
    "FIRSTNAME_LABEL": "First name",
    "LASTNAME_LABEL": "Last name",
    "LOGIN_LABEL": "Login",
    "PASSWORD_LABEL": "Password",
    "LOGIN_HELP_CREATE": "Optional: defaults to the last name in lower case.",
    "LOGIN_HELP_EDIT": "Leave empty to keep it unchanged.",
    "PASSWORD_HELP_CREATE": "Optional: defaults to the login.",
    "PASSWORD_HELP_EDIT": "Leave empty to keep it unchanged.",
    "ACCOUNT_SECTION": "User account"
  },
  "DOCTORS": {
    "TITLE": "Doctors",
    "ADD": "Add a doctor",
    "EDIT": "Edit a doctor",
    "EMPTY": "No doctors.",
    "DELETE_CONFIRM": "Delete this doctor, their time slots and their user account?",
    "ADDED": "Doctor {name} has been added (login: {login}).",
    "UPDATED": "Doctor {name} has been updated.",
    "DELETED": "Doctor {name} has been deleted."
  },
  "CLIENTS": {
    "TITLE": "Patients",
    "ADD": "Add a patient",
    "EDIT": "Edit a patient",
    "EMPTY": "No patients.",
    "DELETE_CONFIRM": "Delete this patient, their user account and all their appointments?",
    "ADDED": "Patient {name} has been added (login: {login}).",
    "UPDATED": "Patient {name} has been updated.",
    "DELETED": "Patient {name} has been deleted."
  },
  "ERROR_PAGE": {
    "TITLE": "Error",
    "BACK": "Back to the home page"
  },
  "ERRORS": {
    "LOGIN_TAKEN": "This login is already used by another account.",
    "DOCTOR_ALREADY_EXISTS": "A doctor with this first and last name already exists.",
    "PATIENT_ALREADY_EXISTS": "A patient with this first and last name already exists.",
    "DOCTOR_HAS_APPOINTMENTS": "This doctor has appointments: cancel them before deleting the doctor.",
    "SLOT_ALREADY_BOOKED": "This slot has just been booked by someone else.",
    "CONCURRENT_UPDATE": "This record was changed by another user in the meantime. Go back to the list and reopen it before trying again.",
    "DOCTOR_NOT_FOUND": "This doctor does not exist (any more).",
    "PATIENT_NOT_FOUND": "This patient does not exist (any more).",
    "SLOT_NOT_FOUND": "This slot does not exist (any more).",
    "APPOINTMENT_NOT_FOUND": "This appointment does not exist (any more).",
    "INVALID_DATE": "The requested date is not valid.",
    "CHOOSE_PATIENT": "Choose a patient.",
    "INVALID_FORM": "The form contains errors.",
    "INVALID_TITLE": "Choose a title from the list.",
    "FIRSTNAME_REQUIRED": "The first name is required.",
    "FIRSTNAME_TOO_LONG": "The first name must not exceed 20 characters.",
    "LASTNAME_REQUIRED": "The last name is required.",
    "LASTNAME_TOO_LONG": "The last name must not exceed 20 characters.",
    "INVALID_LOGIN": "At most 30 characters: unaccented lower-case letters, digits, dot, hyphen, underscore.",
    "PASSWORD_TOO_LONG": "The password must not exceed 72 characters.",
    "SESSION_EXPIRED": "Your session has expired: please log in again.",
    "BAD_REQUEST": "The request is invalid.",
    "FORBIDDEN": "Your role does not allow you to access this page.",
    "NOT_FOUND": "This page does not exist.",
    "SERVER_ERROR": "An unexpected error occurred. Please try again later.",
    "TOO_MANY_ATTEMPTS": "Too many failed attempts. Try again in {minutes} minute(s).",
    "WRONG_CAPTCHA": "The characters you typed do not match the image. A new image is shown.",
    "TOO_MANY_ACCOUNTS": "Too many accounts have been created from your connection. Try again in {minutes} minute(s).",
    "LOGIN_REQUIRED": "The login is required.",
    "PASSWORD_REQUIRED": "The password is required.",
    "PASSWORD_TOO_SHORT": "The password must be at least {min} characters long.",
    "CONFIRMATION_REQUIRED": "Please confirm the password.",
    "PASSWORDS_DIFFER": "The confirmation does not match the password.",
    "CURRENT_PASSWORD_REQUIRED": "Enter your current password.",
    "WRONG_CURRENT_PASSWORD": "Incorrect current password.",
    "PAST_DATE": "Appointments cannot be booked for a past date.",
    "PATIENT_HAS_UPCOMING_APPOINTMENT": "This patient already has an upcoming appointment: it must be cancelled before booking another one.",
    "CAPTCHA_BEFORE_ACCOUNT": "To create an account, first type the characters shown in the image correctly, then click \"Create a patient account\"."
  },
  "CAPTCHA": {
    "LABEL": "Type the characters shown in the image",
    "IMAGE_ALT": "Anti-robot verification image",
    "NEW_IMAGE": "Another image",
    "HELP": "Upper or lower case, it does not matter."
  },
  "REGISTRATION": {
    "TITLE": "Create a patient account",
    "INTRO": "With this account you can book your own appointments.",
    "LOGIN_HELP": "Unaccented lower-case letters, digits, dot, hyphen, underscore.",
    "PASSWORD_HELP": "At least {min} characters.",
    "SUBMIT": "Create my account",
    "BACK_TO_LOGIN": "Back to login",
    "DONE": "Your account has been created. You can now log in with the login {login}."
  },
  "ACCOUNT": {
    "CONFIRMATION_LABEL": "Confirm password"
  },
  "AREA": {
    "TITLE": "My area",
    "HELLO": "Hello {name}",
    "MANAGE_ACCOUNT": "Manage my account",
    "MANAGE_ACCOUNT_HELP": "Change my details or password, or delete my account.",
    "VIEW_SCHEDULE": "View a schedule",
    "VIEW_SCHEDULE_HELP": "See a doctor's schedule and book a slot.",
    "MY_APPOINTMENT": "My appointment",
    "SEE_IN_SCHEDULE": "see in the schedule",
    "CANCEL_APPOINTMENT": "Cancel this appointment",
    "NO_APPOINTMENT": "You have no upcoming appointment."
  },
  "MY_ACCOUNT": {
    "TITLE": "Manage my account",
    "NEW_PASSWORD_LABEL": "New password",
    "CURRENT_PASSWORD_LABEL": "Current password",
    "CURRENT_PASSWORD_HELP": "Required to save any change.",
    "BACK": "Back to my area",
    "DELETE_TITLE": "Delete my account",
    "DELETE_HELP": "Your account will be permanently deleted, together with all your appointments.",
    "DELETE_CONFIRM": "Permanently delete your account? Any appointment you have will be cancelled.",
    "DELETE_SUBMIT": "Delete my account",
    "UPDATED": "Your account has been updated.",
    "DELETED": "Your account has been deleted."
  },
  "CONFIRMATION": {
    "TITLE": "Confirmation",
    "NO": "No, go back",
    "YES": "Yes, confirm"
  }
}

The English dictionary has exactly the same keys.

11.19. Static files

The [wwwroot] folder is served by [UseStaticFiles] (see Example 15). The [wwwroot/lib/bootstrap] folder contains [Bootstrap] (its stylesheet [css/bootstrap.min.css] and its license), which are not displayed.

11.19.1. wwwroot/css/styles.css

/* =========================================================================
   wwwroot/css/styles.css - a few settings on top of Bootstrap
   (taken from src/styles.css of the Angular client, via the NestJS project).
   ========================================================================= */

body {
  background-color: #f5f7fa;
}

.free-slot:hover {
  background-color: #e9f7ef;
}

/* -------------------------------------------------------------------------
   .error-zone: the error messages, at the bottom of the page, on a light pink background
   (see Views/Shared/_Errors.cshtml).
   position: sticky + bottom: if the page is taller than the window (a
   schedule of 12 slots, for example), the zone stays stuck to the bottom of the
   window instead of disappearing below the fold - without ever
   covering the content when the page is short.
   z-index: above the grey background of the booking dialog (.modal-backdrop).
   ------------------------------------------------------------------------- */
.error-zone {
  position: sticky;
  bottom: 1rem;
  z-index: 1060;
  margin-top: 1.5rem;
  padding: 0.75rem 1rem;
  background-color: #fdecef; /* light pink */
  border: 1px solid #f5c2c7;
  border-radius: 0.375rem;
  color: #842029;
}

/* the two big options of the patient's "My area" page */
.option-card {
  transition: border-color 0.15s, box-shadow 0.15s;
}

.option-card:hover {
  border-color: #0d6efd;
  box-shadow: 0 0.25rem 0.75rem rgba(13, 110, 253, 0.15);
}

/* the captcha image (SVG of 150 x 50 pixels) */
.captcha-image svg {
  display: block;
  border: 1px solid #dee2e6;
  border-radius: 0.375rem;
}
  • lines 10–12: a bookable time slot changes color on hover;
  • lines 23–33: the error area, on a light pink background. [position: sticky] and [bottom: 1rem]: if the page is taller than the window, the area remains at the bottom of the window instead of disappearing off the screen; [z-index] places it above the gray background of the booking window;
  • lines 36–43: the two “My area” options respond to mouseover;
  • lines 46–50: the captcha image frame.

11.19.2. wwwroot/js/app.js

// =========================================================================
// wwwroot/js/app.js - the ONLY JavaScript of the application.
// -------------------------------------------------------------------------
// The whole application works without JavaScript. This script adds a
// convenience: before sending a form that carries the attribute
// data-confirmation="...", a modal dialog asks "Are you sure?"
// (the dialog itself is in the page: Views/Shared/_Confirmation.cshtml).
// If JavaScript is disabled, the form is simply sent without
// confirmation - nothing breaks ("progressive enhancement" principle).
//
// Why not window.confirm()? Its box cannot be styled, and its
// buttons stay in the language of the browser, not in that of the page.
// =========================================================================

(() => {
  const modal = document.getElementById('confirmation-modal');
  if (!modal) {
    return;
  }
  const message = document.getElementById('confirmation-modal-message');
  const noButton = modal.querySelector('.modal-footer [data-action="no"]');

  let pendingForm = null; // the form to send if the user confirms
  let previousFocus = null; // the element to give the focus back to on closing

  function open(form) {
    pendingForm = form;
    previousFocus = document.activeElement;
    // textContent (and not innerHTML): the message is displayed as TEXT,
    // never interpreted as HTML
    message.textContent = form.dataset.confirmation;
    modal.classList.remove('d-none');
    // the focus goes on "No": an absent-minded press on Enter deletes nothing
    noButton.focus();
  }

  function close() {
    modal.classList.add('d-none');
    pendingForm = null;
    if (previousFocus) {
      previousFocus.focus();
    }
  }

  // 1. we intercept the sending of the forms to confirm
  document.querySelectorAll('form[data-confirmation]').forEach((form) => {
    form.addEventListener('submit', (event) => {
      event.preventDefault();
      open(form);
    });
  });

  // 2. the buttons of the dialog (Yes / No / close cross)
  modal.addEventListener('click', (event) => {
    const action = event.target.closest('[data-action]')?.dataset.action;
    if (action === 'yes') {
      const form = pendingForm;
      close();
      // form.submit() sends the form WITHOUT triggering the
      // 'submit' event again: no new confirmation request
      form.submit();
    } else if (action === 'no') {
      close();
    } else if (event.target.classList.contains('modal')) {
      close(); // click on the grey background, outside the dialog
    }
  });

  // 3. keyboard: Escape closes the dialog; Tab stays INSIDE the dialog
  //    (otherwise the focus would go to the greyed-out page, behind)
  modal.addEventListener('keydown', (event) => {
    if (event.key === 'Escape') {
      close();
    } else if (event.key === 'Tab') {
      const buttons = [...modal.querySelectorAll('button')];
      const first = buttons[0];
      const last = buttons[buttons.length - 1];
      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && document.activeElement === last) {
        event.preventDefault();
        first.focus();
      }
    }
  });
})();

The script for the confirmation window in Example 21, enhanced with keyboard handling. It follows the same principle of progressive enhancement: without [JavaScript], forms submit without confirmation, and the application still works.

  • Lines 16–19: A page without a confirmation window has nothing to do;
  • line 24: the element that had keyboard focus (the one receiving keystrokes) before the window opened: focus is returned to it when the window closes;
  • Lines 26–35: Focus is placed on “No, go back”; accidentally pressing Enter does not delete anything;
  • lines 37–43: The window is hidden, and focus is restored;
  • lines 46–51: We intercept the submission of forms that have the attribute [data-confirmation], as in Example 21;
  • Lines 54–67: “Yes,” “No,” the close button, and clicking outside the window. [closest('[data-action]')] finds the element containing the attribute, even if you clicked on an element within it. The operator [?.] returns [undefined] instead of failing when no element is found;
  • lines 71–86: the Esc key closes the window; the Tab key (and Shift+Tab) cycles the focus among the buttons in the window, rather than letting it move to the grayed-out page behind it.