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 AM to 12:00 PM in 20-minute increments, for example); an appointment consists of a day, a time slot, and a client (a patient). It uses the database and business layers from the [NestJS] server in the courses “An Example of a Flutter Client/Server / NestJS” and “An Example of an Angular Client/Server / NestJS, and displays the same pages as the [Angular] client; but instead of serving JSON to a client [JavaScript] or [Flutter], it generates its own HTML pages.

Three user roles share the application:

Role
Who
What they 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, book, 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, a misspelled 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 (Booked). 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 their information, change their password, or delete their account (and 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 patient 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 application folder:

rdvmedecins-nestjs-html/
├── database/
│   ├── README.md
│   └── dbrdvmedecins2.sql
├── i18n/
│   ├── en.json
│   └── fr.json
├── public/
│   ├── css/
│   │   └── styles.css
│   └── js/
│       └── app.js
├── src/
│   ├── auth/
│   │   ├── README.md
│   │   ├── attempt-limiter.service.ts
│   │   ├── auth.constants.ts
│   │   ├── auth.controller.ts
│   │   ├── auth.module.ts
│   │   ├── auth.service.ts
│   │   ├── captcha.service.ts
│   │   ├── jwt-auth.guard.ts
│   │   ├── jwt.strategy.ts
│   │   ├── registration-pass.service.ts
│   │   ├── roles.decorator.ts
│   │   └── roles.guard.ts
│   ├── business/
│   │   ├── README.md
│   │   ├── business.exception.ts
│   │   ├── business.interface.ts
│   │   ├── business.service.ts
│   │   └── dates.ts
│   ├── config/
│   │   ├── README.md
│   │   ├── database.config.ts
│   │   └── paths.ts
│   ├── domain/
│   │   ├── README.md
│   │   ├── doctor-day-schedule.ts
│   │   └── doctor-day-slot.ts
│   ├── entities/
│   │   ├── README.md
│   │   ├── abstract.entity.ts
│   │   ├── appointment.entity.ts
│   │   ├── client.entity.ts
│   │   ├── doctor.entity.ts
│   │   ├── person.entity.ts
│   │   ├── slot.entity.ts
│   │   └── user.entity.ts
│   ├── repositories/
│   │   ├── README.md
│   │   ├── appointment.repository.ts
│   │   ├── client.repository.ts
│   │   ├── doctor.repository.ts
│   │   ├── slot.repository.ts
│   │   └── user.repository.ts
│   ├── web/
│   │   ├── controllers/
│   │   │   ├── clients.controller.ts
│   │   │   ├── doctors.controller.ts
│   │   │   ├── language.controller.ts
│   │   │   ├── patient-area.controller.ts
│   │   │   ├── persons.controller.ts
│   │   │   ├── registration.controller.ts
│   │   │   └── schedule.controller.ts
│   │   ├── helpers/
│   │   │   ├── dates.helper.ts
│   │   │   ├── form.helper.ts
│   │   │   └── patient.helper.ts
│   │   ├── models/
│   │   │   └── person-form.dto.ts
│   │   ├── views/
│   │   │   └── handlebars-helpers.ts
│   │   ├── README.md
│   │   ├── application-model.service.ts
│   │   ├── html-exception.filter.ts
│   │   ├── i18n.ts
│   │   ├── view-context.ts
│   │   └── view-user.interceptor.ts
│   ├── README.md
│   ├── app.module.ts
│   └── main.ts
├── views/
│   ├── account/
│   │   └── registration.hbs
│   ├── partials/
│   │   ├── booking.hbs
│   │   ├── captcha.hbs
│   │   ├── confirmation.hbs
│   │   ├── errors.hbs
│   │   ├── flash.hbs
│   │   ├── navbar.hbs
│   │   └── personFields.hbs
│   ├── patient/
│   │   ├── account.hbs
│   │   └── area.hbs
│   ├── persons/
│   │   ├── form.hbs
│   │   └── list.hbs
│   ├── README.md
│   ├── error.hbs
│   ├── layout.hbs
│   ├── login.hbs
│   └── schedule.hbs
├── .env.example
├── README.md
├── nest-cli.json
├── package-lock.json
├── package.json
└── tsconfig.json

The [README.md] files describe the purpose of each folder; they are not reproduced here. To install and run the application:

  • create the [dbrdvmedecins2] database by running the [database/dbrdvmedecins2.sql] script (see appendices);
  • in a console window open to the application folder, install the dependencies: [npm install];
  • rename the file [.env.example] to [.env], and update the connection settings in it to match those in [MySQL];
  • run the application: [npm run start:dev], then open [http://localhost:8081].

The passwords for the demo accounts are their logins: [admin] (ADMIN), [pelissier], [tatou], [dupont] (DOCTOR), [martin], [durand], [bistrou], [german] (USER). At startup:

[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [NestFactory] Starting Nest application...
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [InstanceLoader] TypeOrmModule dependencies initialized +84ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [InstanceLoader] PassportModule dependencies initialized +0ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [InstanceLoader] JwtModule dependencies initialized +1ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [InstanceLoader] TypeOrmCoreModule dependencies initialized +58ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [InstanceLoader] TypeOrmModule dependencies initialized +1ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [InstanceLoader] TypeOrmModule dependencies initialized +0ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [InstanceLoader] AuthModule dependencies initialized +1ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [InstanceLoader] AppModule dependencies initialized +3ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [RoutesResolver] ScheduleController {/schedule}: +15ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [RouterExplorer] Mapped {/schedule, GET} route +3ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [RouterExplorer] Mapped {/schedule/book, POST} route +0ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [RouterExplorer] Mapped {/schedule/cancel, POST} route +0ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [RoutesResolver] DoctorsController {/doctors}: +1ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [RouterExplorer] Mapped {/doctors, GET} route +0ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [RouterExplorer] Mapped {/doctors/add, GET} route +0ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [RouterExplorer] Mapped {/doctors/add, POST} route +0ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [RouterExplorer] Mapped {/doctors/:id/edit, GET} route +1ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [RouterExplorer] Mapped {/doctors/:id/edit, POST} route +0ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [RouterExplorer] Mapped {/doctors/:id/delete, POST} route +0ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [RoutesResolver] ClientsController {/clients}: +0ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [RouterExplorer] Mapped {/clients, GET} route +1ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [RouterExplorer] Mapped {/clients/add, GET} route +0ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [RouterExplorer] Mapped {/clients/add, POST} route +0ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [RouterExplorer] Mapped {/clients/:id/edit, GET} route +0ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [RouterExplorer] Mapped {/clients/:id/edit, POST} route +0ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [RouterExplorer] Mapped {/clients/:id/delete, POST} route +0ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LOG [RoutesResolver] LanguageController {/language}: +0ms
[Nest] 14248  - 09/23/2026, 4:23:55 PM     LO

(the SQL requests displayed by [TypeORM] have been removed)

11.4. Application Architecture

Image

  • [src/main.ts] configures the application: view engine, static files, cookies, exception filter;
  • [src/auth]: authentication, tokens (JWT), roles, CAPTCHA, and attempt limits;
  • [src/web]: the web layer. Its controllers ([src/web/controllers]) all pass through a single service, [ApplicationModelService], which stores the lists of doctors and clients in memory and forwards other requests to the business layer;
  • [src/business]: the business rules;
  • [src/repositories]: the DAO layer, one class per table;
  • [src/entities]: the entities [TypeORM]; [src/domain]: the calendar objects, calculated by the business layer;
  • [views]: the views [Handlebars]; [i18n]: the dictionaries; [public]: the style sheet and the page script.

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

11.5. The database

The file [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 NestJS 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 comment on this script:

  • line 30: as in Example 28, the collation [utf8mb4_unicode_ci] ignores uppercase letters and accents in comparisons;
  • lines 34–38: tables are deleted from the “child” tables to the “parent” tables: a table referenced by a foreign key cannot be deleted before the tables that reference it;
  • lines 43–52: user accounts. [password] contains the bcrypt hash of the password (see Example 25). Line 51: a login is unique;
  • Lines 57–69: Doctors. Each has an account (column [user], foreign key to [users], line 64). The name [user] is a reserved word for [MySQL]: it is enclosed in backticks. Line 66: an account belongs to only one doctor; line 68: no two doctors can have the same first and last names;
  • lines 74–86: clients, following the same format;
  • lines 91–100: each doctor’s time slots (start and end times in hours and minutes);
  • lines 105–115: appointments: one day, one time slot, one client. Line 114: a time slot is reserved only once per day;
  • lines 127–137: demo accounts; each account’s password is its login;
  • lines 141–144: each doctor’s [user] column is retrieved via a subquery on the login, rather than assuming the generated credentials;
  • lines 167–168: a same-day appointment ([CURDATE()]), to immediately show a booked time slot.

11.6. Project Configuration

The [package.json] file:

{
  "name": "rdvmedecins-nestjs-html",
  "version": "1.0.0",
  "description": "NestJS web application (HTML pages generated server-side) for booking medical appointments [RdvMedecins] - database [dbrdvmedecins2], roles ADMIN / DOCTOR / USER",
  "private": true,
  "license": "UNLICENSED",
  "scripts": {
    "build": "nest build",
    "start": "nest start",
    "start:dev": "nest start --watch --watchAssets",
    "start:debug": "nest start --debug --watch --watchAssets",
    "start:prod": "node dist/main"
  },
  "dependencies": {
    "@nestjs/common": "^10.4.0",
    "@nestjs/core": "^10.4.0",
    "@nestjs/jwt": "^10.2.0",
    "@nestjs/passport": "^10.0.3",
    "@nestjs/platform-express": "^10.4.0",
    "@nestjs/typeorm": "^10.0.2",
    "bcryptjs": "^3.0.3",
    "bootstrap": "^5.3.3",
    "class-transformer": "^0.5.1",
    "class-validator": "^0.14.1",
    "cookie-parser": "^1.4.7",
    "dotenv": "^17.4.2",
    "hbs": "^4.2.0",
    "mysql2": "^3.11.0",
    "passport-jwt": "^4.0.1",
    "reflect-metadata": "^0.2.2",
    "rxjs": "^7.8.1",
    "svg-captcha": "^1.4.0",
    "typeorm": "^0.3.20"
  },
  "devDependencies": {
    "@nestjs/cli": "^10.4.5",
    "@types/bcryptjs": "^2.4.6",
    "@types/cookie-parser": "^1.4.7",
    "@types/express": "^4.17.21",
    "@types/node": "^20.14.0",
    "@types/passport-jwt": "^4.0.1",
    "typescript": "^5.5.0"
  }
}
  • lines 7–13: the [npm run ...] commands. [start:dev] launches the application in “watch” mode: the code is recompiled and the server restarted with every change; the [--watchAssets] option also monitors non-code files;
  • Lines 14–34: The application’s libraries, all of which are covered in the examples. [dotenv] reads the file [.env]; [mysql2] is the driver [MySQL] used by [TypeORM]; [reflect-metadata] and [rxjs] are required by [NestJS];
  • Lines 35–43: development tools: the [nest] tool, the [TypeScript] compiler, and the type declarations ([@types/...]) for libraries written in [JavaScript].

The [tsconfig.json] file:

{
  "compilerOptions": {
    "module": "commonjs",
    "declaration": false,
    "removeComments": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "allowSyntheticDefaultImports": true,
    "target": "ES2022",
    "lib": ["ES2022"],
    "sourceMap": true,
    "outDir": "./dist",
    "baseUrl": "./",
    "incremental": true,
    "skipLibCheck": true,
    "strictNullChecks": true,
    "noImplicitAny": false,
    "strictBindCallApply": false,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": false
  }
}

This file is generated by the [nest] tool for a new project. Compared to the examples: line 5, comments are not copied into the compiled code; line 14, only modified files are recompiled; line 17: a variable without an explicit or inferable type is accepted (it is then of type [any])—this is what allows the [hbs] package, which has no type declarations, to be imported.

The file [nest-cli.json]:

1
2
3
4
5
6
7
8
{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "deleteOutDir": true
  }
}

Identical to the one in the examples.

The [.env.example] file:

# -----------------------------------------------------------------------
# Sample configuration file.
# Copy this file to [.env] then adapt the values to your machine.
# The [.env] file must never be shared or committed (passwords).
# -----------------------------------------------------------------------

# listening port of the NestJS server. The JSON server already listens on 8080:
# we choose another port so that both can run at the same time.
PORT=8081

# connection parameters for the MySQL database [dbrdvmedecins2]
# (script: database/dbrdvmedecins2.sql)
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=
DB_DATABASE=dbrdvmedecins2

# -----------------------------------------------------------------------
# authentication (see src/auth/)
# -----------------------------------------------------------------------
# secret key used to sign/verify JWT tokens - MUST be changed
# in production (a long random string, never committed).
JWT_SECRET=change-me-to-a-long-random-string
# validity of a JWT token, in seconds (7200 s = 2 h). The cookie
# that carries the token has the same lifetime.
JWT_EXPIRES_IN_SECONDS=7200
# set to true in production (site served over HTTPS): the authentication
# cookie will then never be sent over an unencrypted connection.
COOKIE_SECURE=false

# -----------------------------------------------------------------------
# protection against bots (see src/auth/attempt-limiter.service.ts)
# -----------------------------------------------------------------------
# login: number of failures (wrong captcha or password) allowed for
# the same login from the same IP address, before this pair is blocked...
LOGIN_MAX_FAILURES=5
# ... for this duration (an IP address is also blocked if it accumulates
# 5 times more failures, all logins combined)
LOGIN_BLOCK_MINUTES=15
# client account creation: maximum number of accounts created per hour
# from the same IP address. In a classroom, all computers often go out
# through the same address: increase if needed.
REGISTRATION_MAX_PER_HOUR=10
  • A file named [.env] contains machine-specific configuration settings: it is never shared (it contains passwords). The file [.env.example] serves as the template to be copied;
  • line 9: the application port;
  • lines 13–17: the database connection;
  • lines 24–30: the token key, its lifetime (two hours), and the [secure] cookie option (in production, the site is served using HTTPS, and the cookie must never be transmitted over an unencrypted connection);
  • lines 37–44: the thresholds for bot protection.

The [src/config/database.config.ts] file:

// =========================================================================
// config/database.config.ts
// -------------------------------------------------------------------------
// Configuration of the connection to the MySQL database, used by
// TypeORM. It is the equivalent of the [DomainAndPersitenceConfig] class of the
// original Spring project, which defined a [DataSource] (@Bean block).
//
// Here, no class: NestJS/TypeORM prefers a simple function that
// returns a configuration object, read from the environment variables
// (.env file at the project root, loaded automatically by NestJS
// through the @nestjs/config module if installed - here we read
// process.env directly to stay simple and educational).
// =========================================================================

import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { Doctor } from '../entities/doctor.entity';
import { Client } from '../entities/client.entity';
import { Slot } from '../entities/slot.entity';
import { Appointment } from '../entities/appointment.entity';
import { User } from '../entities/user.entity';

export function getDatabaseConfig(): TypeOrmModuleOptions {
  return {
    // 'mysql': we use the MySQL driver, like the original project (MySQL5 -> MySQL8 here).
    type: 'mysql',
    host: process.env.DB_HOST ?? 'localhost',
    port: process.env.DB_PORT ? parseInt(process.env.DB_PORT, 10) : 3306,
    username: process.env.DB_USERNAME ?? 'root',
    password: process.env.DB_PASSWORD ?? '',
    database: process.env.DB_DATABASE ?? 'dbrdvmedecins2',

    // the list of entities (@Entity classes) that TypeORM must manage.
    // It is the equivalent of the automatic "scan" of @Entity classes done by Spring Boot.
    entities: [Doctor, Client, Slot, Appointment, User],

    // IMPORTANT: synchronize=false at all times in this course.
    // If synchronize were true, TypeORM would modify the database schema on its own
    // from the entities: handy for a throwaway prototype, dangerous as soon as there is
    // real data (risk of data loss). Like a real team would, we prefer
    // to keep control of the schema through the database/dbrdvmedecins2.sql script.
    synchronize: false,

    // displays the SQL queries generated by TypeORM in the console: very useful to
    // understand what the ORM does for you, exactly like the Hibernate logs
    // of the original Spring project.
    logging: process.env.NODE_ENV !== 'production',
  };
}

The connection to the database (see Example 28). Line 46: SQL requests are displayed, except in production.

The [src/config/paths.ts] file:

// =========================================================================
// config/paths.ts
// -------------------------------------------------------------------------
// The NON-compiled folders of the project (Handlebars views, static files,
// translation dictionaries) are at the root of the project, next to
// src/ and dist/. Once compiled, this very file is located in
// dist/config/: the project root is therefore two levels above.
// =========================================================================

import { join, resolve } from 'path';

export const PROJECT_ROOT = resolve(__dirname, '..', '..');

export const VIEWS_DIR = join(PROJECT_ROOT, 'views'); // page templates (.hbs)
export const PARTIALS_DIR = join(VIEWS_DIR, 'partials'); // reusable pieces of pages
export const PUBLIC_DIR = join(PROJECT_ROOT, 'public'); // CSS, JS, images
export const I18N_DIR = join(PROJECT_ROOT, 'i18n'); // fr.json, en.json
export const BOOTSTRAP_DIR = join(PROJECT_ROOT, 'node_modules', 'bootstrap', 'dist');
  • Line 12: Once compiled, this file is located in [dist/config]: the project root is two levels above;
  • lines 14–18: the uncompiled folders (views, static files, dictionaries), and the one for [Bootstrap].

11.7. Starting the application

The file [src/main.ts]:

// =========================================================================
// main.ts
// -------------------------------------------------------------------------
// Entry point of the application. Compared with the JSON server, the
// startup also configures everything needed to BUILD HTML PAGES
// on the server side:
//   - a view engine (Handlebars, files views/*.hbs);
//   - the static files (Bootstrap, CSS, a little JavaScript);
//   - cookie parsing (JWT token, language, flash messages);
//   - a filter that turns errors into HTML pages.
// There is no CORS to allow any more: pages and data now come from
// the SAME server.
// =========================================================================

import 'dotenv/config'; // MUST remain the very first import (see JSON server)
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import * as cookieParser from 'cookie-parser';
import * as hbs from 'hbs';
import { AppModule } from './app.module';
import { BOOTSTRAP_DIR, PARTIALS_DIR, PUBLIC_DIR, VIEWS_DIR } from './config/paths';
import { registerHelpers } from './web/views/handlebars-helpers';
import { viewContextMiddleware } from './web/view-context';
import { HtmlExceptionFilter } from './web/html-exception.filter';
import { ViewUserInterceptor } from './web/view-user.interceptor';

async function bootstrap() {
  // NestExpressApplication: we state that the underlying platform is
  // Express, to get access to its own methods (setViewEngine,
  // useStaticAssets...) - they do not exist for the Fastify platform.
  const app = await NestFactory.create<NestExpressApplication>(AppModule);

  // 1. static files: /css/..., /js/... (public/ folder) and
  //    /bootstrap/css/bootstrap.min.css (directly from node_modules:
  //    no need to copy Bootstrap into the project)
  app.useStaticAssets(PUBLIC_DIR);
  app.useStaticAssets(BOOTSTRAP_DIR, { prefix: '/bootstrap/' });

  // 2. the view engine. The views/layout.hbs file is used
  //    AUTOMATICALLY by hbs as the common template of all pages:
  //    each view is inserted at the {{{body}}} spot of this template.
  app.setBaseViewsDir(VIEWS_DIR);
  app.setViewEngine('hbs');
  registerHelpers(hbs);
  // the "partials" (views/partials/*.hbs): reusable pieces of page,
  // inserted with {{> name}}. registerPartials reads the folder
  // asynchronously: we wait until it is done before accepting requests.
  await new Promise<void>((done) => hbs.registerPartials(PARTIALS_DIR, () => done()));

  // 3. cookie-parser fills req.cookies; then our middleware prepares the
  //    language and the flash message of each page (see web/view-context.ts)
  app.use(cookieParser());
  app.use(viewContextMiddleware);

  // 4. the logged-in user, visible to all views; and the errors
  //    turned into HTML pages (or into a redirect to /login)
  app.useGlobalInterceptors(new ViewUserInterceptor());
  app.useGlobalFilters(new HtmlExceptionFilter());

  const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 8081;
  await app.listen(port);
  // eslint-disable-next-line no-console
  console.log(`[RdvMedecins] web application started on http://localhost:${port}`);
}

bootstrap();

Let’s break down this code:

  • Line 15: [dotenv/config] reads the file [.env] and places its values into [process.env]. This import must be the first one: the other modules read [process.env] as soon as they are loaded ([JWT_SECRET], for example);
  • lines 37–38: static files (see Example 15);
  • lines 43–49: the view engine, helpers, and partial views (see Examples 15 and 16);
  • lines 53–54: reading cookies, followed by the middleware that prepares the language and flash message for each page (see [web/view-context.ts]);
  • lines 58–59: a global interceptor exposes the logged-in user to the views, and a global filter converts exceptions into HTML pages (see Chapter 7);
  • line 61: the port, read from [.env] (8081 by default).

The [src/app.module.ts] file:

// =========================================================================
// app.module.ts
// -------------------------------------------------------------------------
// The root module. Same layers as the JSON server (DAO, business,
// cache/facade); only the controllers change: they now build
// HTML pages instead of JSON responses.
// =========================================================================

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { getDatabaseConfig } from './config/database.config';

import { Doctor } from './entities/doctor.entity';
import { Client } from './entities/client.entity';
import { Slot } from './entities/slot.entity';
import { Appointment } from './entities/appointment.entity';

import { DoctorRepository } from './repositories/doctor.repository';
import { ClientRepository } from './repositories/client.repository';
import { SlotRepository } from './repositories/slot.repository';
import { AppointmentRepository } from './repositories/appointment.repository';

import { BUSINESS_TOKEN } from './business/business.interface';
import { BusinessService } from './business/business.service';

import { ApplicationModelService } from './web/application-model.service';
import { ScheduleController } from './web/controllers/schedule.controller';
import { DoctorsController } from './web/controllers/doctors.controller';
import { ClientsController } from './web/controllers/clients.controller';
import { LanguageController } from './web/controllers/language.controller';
import { RegistrationController } from './web/controllers/registration.controller';
import { PatientAreaController } from './web/controllers/patient-area.controller';

import { AuthModule } from './auth/auth.module';

@Module({
  imports: [
    TypeOrmModule.forRoot(getDatabaseConfig()),
    TypeOrmModule.forFeature([Doctor, Client, Slot, Appointment]),
    // AuthModule provides /login and /logout; it exports UserRepository
    // (injected into BusinessService), AuthService, CaptchaService and
    // AttemptLimiterService (injected into the controllers of src/web/)
    AuthModule,
  ],
  controllers: [
    ScheduleController,
    DoctorsController,
    ClientsController,
    LanguageController,
    RegistrationController, // "Create a patient account" (public)
    PatientAreaController, // "My area", "Manage my account" (USER)
  ],
  providers: [
    DoctorRepository,
    ClientRepository,
    SlotRepository,
    AppointmentRepository,
    { provide: BUSINESS_TOKEN, useClass: BusinessService },
    ApplicationModelService,
  ],
})
export class AppModule {}
  • line 38: the database connection;
  • line 39: the repositories for the four entities managed by this module (the one for [User] is declared by [AuthModule]);
  • line 43: the authentication module, with its own controller and services;
  • lines 45–52: the web layer controllers;
  • Lines 53–60: the services: the DAO layer (the four repositories), the business layer, and the web layer cache. Line 58: The business layer is registered under a token ([BUSINESS_TOKEN]) rather than under the name of its class: those who use it depend on the [IBusiness] interface, not on the [BusinessService] class. The implementation could be replaced (with a test version, for example) by modifying only this line.

11.8. The entities

The [TypeORM] entities map to the database tables and classes in [TypeScript] (see Example 28). Their comments often refer to the [Java] / [Spring] project, of which the application is a distant descendant, and to the “JSON server”: the [NestJS] server from the client/server courses, whose case study incorporates the DAO and business layers.

11.8.1. src/entities/abstract.entity.ts

// =========================================================================
// entities/abstract.entity.ts
// -------------------------------------------------------------------------
// Equivalent of the [AbstractEntity] class of the original Spring project,
// which was annotated @MappedSuperclass. It groups the two fields
// common to all our persistent entities: the identifier [id] and the
// version number [version] used to manage concurrent access.
//
// In JPA, @MappedSuperclass means "this class is not a table,
// but its fields must be added to the tables of the child classes".
// TypeORM offers exactly the same mechanism: it is enough for an abstract
// class (not decorated with @Entity) to be inherited by a class which,
// itself, carries the @Entity annotation. TypeORM then automatically adds
// the inherited columns to the table of the child class.
// =========================================================================

import { PrimaryGeneratedColumn, VersionColumn } from 'typeorm';

export abstract class AbstractEntity {
  // equivalent of:
  //   @Id
  //   @GeneratedValue(strategy = GenerationType.AUTO)
  //   protected Long id;
  //
  // @PrimaryGeneratedColumn() declares a primary key auto-incremented by the DBMS,
  // exactly as GenerationType.AUTO delegated it to the DBMS.
  @PrimaryGeneratedColumn()
  id: number;

  // equivalent of:
  //   @Version
  //   protected Long version;
  //
  // @VersionColumn() is THE native TypeORM mechanism for optimistic locking:
  // TypeORM automatically increments this column on each UPDATE, and throws an error
  // when trying to save an entity whose version no longer matches the one in the
  // database (someone else changed it in the meantime). It maps directly to the
  // role of @Version in JPA/Hibernate.
  @VersionColumn()
  version: number;
}
  • line 19: an abstract class without a decorator [@Entity]: it does not correspond to any table, but its columns are added to those of the child classes;
  • lines 27–28: the primary key, generated by the database;
  • lines 39–40: the row version, incremented by [TypeORM] with each modification (optimistic lock; see Example 28).

11.8.2. src/entities/person.entity.ts

// =========================================================================
// entities/person.entity.ts
// -------------------------------------------------------------------------
// Parent class (not persisted itself) of the [Doctor] and
// [Client] entities: a title, a last name, a first name - and, new in the
// [dbrdvmedecins2] database, the associated user account ([user] column).
//
// A relation declared in an abstract class is inherited by the
// child classes exactly like the columns: TypeORM therefore adds the
// [user] relation to the [doctors] table AND to the [clients] table.
// =========================================================================

import { Column, JoinColumn, OneToOne, RelationId } from 'typeorm';
import { AbstractEntity } from './abstract.entity';
import { User } from './user.entity';

export abstract class Person extends AbstractEntity {
  @Column({ length: 5 })
  title: string; // Melle, Mme, Mr

  @Column({ name: 'last_name', length: 20 })
  lastName: string;

  @Column({ name: 'first_name', length: 20 })
  firstName: string;

  // @OneToOne: a doctor (or a client) has ONE user account, and a
  // user account belongs to at most ONE person (the [user] column
  // is declared UNIQUE in the SQL script).
  // @JoinColumn({ name: 'user' }): it is THIS table (doctors / clients)
  // that carries the foreign key, in the column named [user].
  // As for the other relations, [user] is only loaded on request
  // (`relations` option) - see doctor.repository.ts.
  @OneToOne(() => User)
  @JoinColumn({ name: 'user' })
  user: User;

  // the raw value of the foreign key, without loading the full User
  // (same technique as Slot.doctorId, see slot.entity.ts)
  @RelationId((person: Person) => person.user)
  userId: number;
}
  • line 17: the parent class of [Doctor] and [Client], itself a child of [AbstractEntity];
  • Lines 18–25: title, last name, first name;
  • lines 34–36: a one-to-one relationship to the user account. [@JoinColumn] indicates that this table contains the foreign key, in column [user]. The relationship is loaded only if requested (option [relations] in queries);
  • Lines 40–41: [@RelationId] exposes the value of the foreign key (the account ID) without loading the account itself.

11.8.3. src/entities/doctor.entity.ts

// =========================================================================
// entities/doctor.entity.ts
// -------------------------------------------------------------------------
// Equivalent of the JPA entity [Medecin] (a doctor):
//
//   @Entity
//   @Table(name = "medecins")
//   public class Medecin extends Personne { ... }
// =========================================================================

import { Entity, OneToMany, Unique } from 'typeorm';
import { Person } from './person.entity';
import { Slot } from './slot.entity';

// @Entity({ name: 'doctors' }): declares the class as a TypeORM entity,
// persisted in the [doctors] table. It is the exact equivalent of
// @Entity + @Table(name = "doctors") in JPA.
@Entity({ name: 'doctors' })
// reminder of the SQL constraint of the database/dbrdvmedecins2.sql script: two
// doctors cannot have the same first and last names. As for unq1_appointments
// (see appointment.entity.ts), it is documentation: synchronize=false, it is the
// SQL script that really creates the constraint - with THIS name, which the
// business layer recognizes in MySQL error messages (see business.service.ts).
@Unique('unq_doctors_first_name_last_name', ['firstName', 'lastName'])
export class Doctor extends Person {
  // inverse relation (optional, handy to navigate from a doctor to the doctor's slots).
  // The original project did not define it on the [Medecin] side (only on the [Creneau] side),
  // but TypeORM makes it almost free to add and it is educationally useful:
  // it shows the relation "1 doctor -> several slots" in both directions.
  @OneToMany(() => Slot, (slot) => slot.doctor)
  slots: Slot[];
}
  • line 18: the class corresponds to the table [doctors];
  • line 24: the uniqueness constraint, included here for documentation purposes; it is actually created by the script SQL ([synchronize: false]);
  • Lines 30–31: the one-to-many relationship to the doctor’s time slots, the inverse of the relationship [@ManyToOne] from [Slot].

11.8.4. src/entities/client.entity.ts

// =========================================================================
// entities/client.entity.ts
// -------------------------------------------------------------------------
// Equivalent of the JPA entity [Client]:
//
//   @Entity
//   @Table(name = "clients")
//   public class Client extends Personne { ... }
//
// Beware: [Client] here means a PATIENT of the medical practice, not an
// HTTP client! It is the vocabulary of the original business domain, kept
// as is to remain faithful to the source document.
// =========================================================================

import { Entity, Unique } from 'typeorm';
import { Person } from './person.entity';

@Entity({ name: 'clients' })
// two clients cannot have the same first and last names
// (see the same constraint on Doctor, doctor.entity.ts)
@Unique('unq_clients_first_name_last_name', ['firstName', 'lastName'])
export class Client extends Person {}

A client (a patient): a [Person] from the [clients] table, with no additional fields.

11.8.5. src/entities/slot.entity.ts

// =========================================================================
// entities/slot.entity.ts
// -------------------------------------------------------------------------
// Equivalent of the JPA entity [Creneau] (a time slot):
//
//   @Entity
//   @Table(name = "creneaux")
//   public class Creneau extends AbstractEntity {
//       private int hdebut, mdebut, hfin, mfin;
//       @ManyToOne(fetch = FetchType.LAZY)
//       @JoinColumn(name = "id_medecin")
//       private Medecin medecin;
//       @Column(name = "id_medecin", insertable = false, updatable = false)
//       private long idMedecin;
//   }
// =========================================================================

import { Column, Entity, JoinColumn, ManyToOne, RelationId } from 'typeorm';
import { AbstractEntity } from './abstract.entity';
import { Doctor } from './doctor.entity';

@Entity({ name: 'slots' })
export class Slot extends AbstractEntity {
  @Column({ name: 'start_hour' }) startHour: number; // start hour of the slot
  @Column({ name: 'start_minute' }) startMinute: number; // start minute of the slot
  @Column({ name: 'end_hour' }) endHour: number; // end hour of the slot
  @Column({ name: 'end_minute' }) endMinute: number; // end minute of the slot

  // equivalent of:
  //   @ManyToOne(fetch = FetchType.LAZY)
  //   @JoinColumn(name = "id_medecin")
  //   private Medecin medecin;
  //
  // @ManyToOne: several slots for the same doctor.
  // By default, TypeORM does NOT load the [doctor] relation when reading a slot
  // ("lazy" behaviour by default): it must be requested explicitly, through the option
  // `relations: ['doctor']` of a query - exactly as FetchType.LAZY required
  // in JPA an explicit `left join fetch` in the JPQL query to bring the doctor back.
  @ManyToOne(() => Doctor, (doctor) => doctor.slots)
  @JoinColumn({ name: 'doctor_id' })
  doctor: Doctor;

  // equivalent of:
  //   @Column(name = "id_medecin", insertable = false, updatable = false)
  //   private long idMedecin;
  //
  // @RelationId: it is the EXACT and idiomatic TypeORM equivalent of the read-only
  // "idMedecin" field of the original project. It directly exposes the value
  // of the foreign key, WITHOUT having to load the full [Doctor] entity.
  // Very useful to compare identifiers without loading the related entities.
  @RelationId((slot: Slot) => slot.doctor)
  doctorId: number;
}
  • lines 24–27: the start and end hours and minutes;
  • lines 39–41: the many-to-one relationship to the doctor: multiple time slots for one doctor. The foreign key is the [doctor_id] column;
  • lines 51–52: the doctor’s ID, without loading the doctor.

11.8.6. src/entities/appointment.entity.ts

// =========================================================================
// entities/appointment.entity.ts
// -------------------------------------------------------------------------
// Equivalent of the JPA entity [Rv] (an appointment):
//
//   @Entity
//   @Table(name = "rv")
//   public class Rv extends AbstractEntity {
//       @Temporal(TemporalType.DATE) private Date jour;
//       @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "id_client") private Client client;
//       @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "id_creneau") private Creneau creneau;
//       @Column(name = "id_client", insertable = false, updatable = false) private long idClient;
//       @Column(name = "id_creneau", insertable = false, updatable = false) private long idCreneau;
//   }
//
// The SQL uniqueness constraint on (day, slot_id) - which prevents two
// appointments on the same slot on the same day - is defined directly
// in the database/dbrdvmedecins2.sql script (CONSTRAINT unq1_appointments UNIQUE).
// =========================================================================

import { Column, Entity, JoinColumn, ManyToOne, RelationId, Unique } from 'typeorm';
import { AbstractEntity } from './abstract.entity';
import { Client } from './client.entity';
import { Slot } from './slot.entity';

@Entity({ name: 'appointments' })
// recalls, on the TypeORM side, the SQL constraint of the DB script. Beware: we reference here
// the name of the RELATION ('slot'), not the @RelationId field ('slotId') - the latter is
// a "virtual" (computed) column, which TypeORM does not accept in a @Unique constraint.
// TypeORM itself translates 'slot' into its real foreign key column (slot_id).
@Unique('unq1_appointments', ['day', 'slot'])
export class Appointment extends AbstractEntity {
  // equivalent of: @Temporal(TemporalType.DATE) private Date jour;
  // type 'date' (and not 'datetime'): only the day is kept, not the time,
  // exactly as TemporalType.DATE specified on the JPA side.
  @Column({ type: 'date' })
  day: string; // format "yyyy-MM-dd", e.g. "2026-09-13"

  @ManyToOne(() => Client)
  @JoinColumn({ name: 'client_id' })
  client: Client;

  @ManyToOne(() => Slot)
  @JoinColumn({ name: 'slot_id' })
  slot: Slot;

  // equivalents of the read-only idClient / idCreneau fields of the original project
  // (see the detailed explanation in slot.entity.ts about @RelationId)
  @RelationId((appointment: Appointment) => appointment.client)
  clientId: number;

  @RelationId((appointment: Appointment) => appointment.slot)
  slotId: number;
}
  • line 31: an appointment slot is reserved only once per day;
  • lines 36–37: a column of type [DATE], treated as a string [yyyy-mm-dd]: no time zone issues, and two dates are compared as strings;
  • lines 39–45: the client and the appointment time slot;
  • lines 49–53: their IDs.

11.8.7. src/entities/user.entity.ts

// =========================================================================
// entities/user.entity.ts
// -------------------------------------------------------------------------
// A user allowed to log in to the application ([users] table).
//
// New in the [dbrdvmedecins2] database: each doctor and each client
// now has THEIR OWN user account ([user] column of the
// [doctors] and [clients] tables, see person.entity.ts). Hence a third role:
//   - ADMIN  : the administrator - appointments + doctor and client management;
//   - DOCTOR : a doctor - books and cancels appointments;
//   - USER   : a client (patient) - books for themselves, manages their account.
//
// Roles remain mutually exclusive: a simple [role] column
// is still enough (no [roles] / [users_roles] table as in the
// original Spring project).
// =========================================================================

import { Column, Entity, Unique } from 'typeorm';
import { AbstractEntity } from './abstract.entity';

export type Role = 'ADMIN' | 'DOCTOR' | 'USER';

@Entity({ name: 'users' })
// a login belongs to one account only (SQL constraint unq_users_login)
@Unique('unq_users_login', ['login'])
export class User extends AbstractEntity {
  @Column({ length: 30 })
  login: string;

  // the HASHED password (bcrypt) - never in clear
  @Column({ length: 100 })
  password: string;

  // the name displayed once logged in - for a doctor or a client:
  // "firstName LASTNAME" (e.g. "Marie PELISSIER")
  @Column({ length: 40 })
  name: string;

  @Column({ type: 'varchar', length: 10 })
  role: Role;
}
  • line 21: a union type of strings: a variable of type [Role] can only be one of these three strings;
  • lines 31–32: the password hash;
  • lines 36–37: the name displayed in the navigation bar (“Marie PELISSIER”);
  • lines 39–40: the role. The column type is specified: [TypeORM] cannot infer a type SQL from a union type.

11.9. 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, as calculated by the business layer.

11.9.1. src/domain/doctor-day-slot.ts

// =========================================================================
// domain/doctor-day-slot.ts
// -------------------------------------------------------------------------
// Equivalent of the [CreneauMedecinJour] class of the original Spring project:
// a "business" entity (not persisted in the database) that links a time
// slot to the appointment possibly booked on that slot.
//
//   public class CreneauMedecinJour implements Serializable {
//       private Creneau creneau;
//       private Rv rv; // null if the slot is free
//   }
//
// This kind of class has no table in the database: it only exists during
// the computation of a doctor's schedule, which the web layer then turns
// into rows of the HTML schedule table (see schedule.controller.ts).
// =========================================================================

import { Slot } from '../entities/slot.entity';
import { Appointment } from '../entities/appointment.entity';

export class DoctorDaySlot {
  constructor(
    public slot: Slot,
    public appointment: Appointment | null, // null if the slot is free that day
  ) {}
}

lines 22–25: a time slot, and the appointment scheduled for that slot on that day ([null] if the slot is available). The syntax [public slot: Slot] in a constructor’s parameters declares and initializes the field in a single step.

11.9.2. src/domain/doctor-day-schedule.ts

// =========================================================================
// domain/doctor-day-schedule.ts
// -------------------------------------------------------------------------
// Equivalent of the [AgendaMedecinJour] class of the original Spring project:
// the full schedule of a doctor for a given day, i.e. the list of the
// doctor's time slots, each one free or taken by an appointment.
//
//   public class AgendaMedecinJour implements Serializable {
//       private Medecin medecin;
//       private Date jour;
//       private CreneauMedecinJour[] creneauxMedecinJour;
//   }
// =========================================================================

import { Doctor } from '../entities/doctor.entity';
import { DoctorDaySlot } from './doctor-day-slot';

export class DoctorDaySchedule {
  constructor(
    public doctor: Doctor,
    public day: string, // format "yyyy-MM-dd"
    public doctorDaySlots: DoctorDaySlot[],
  ) {}
}

The schedule: a doctor, a day, and the list of their time slots, whether available or booked.

11.10. The DAO layer

One class per table. All follow the model from Example 28: an injected [Repository] [TypeORM], a [repo(manager)] method that selects the repository for the current transaction, and short methods with no business logic.

11.10.1. src/repositories/doctor.repository.ts

// =========================================================================
// repositories/doctor.repository.ts
// -------------------------------------------------------------------------
// DAO layer of the [Doctor] entity. Compared with the JSON server, three
// new features, all related to the management of doctors by the ADMIN:
//   - the reads also bring back the associated user account (relation
//     [user]), to display the login in the list of doctors;
//   - save() / deleteById(): creation, update, deletion;
//   - each write method accepts an OPTIONAL EntityManager: when
//     the business layer provides it, the operation runs INSIDE the transaction
//     of that layer (see business.service.ts, dataSource.transaction(...)).
// =========================================================================

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm';
import { Doctor } from '../entities/doctor.entity';

@Injectable()
export class DoctorRepository {
  constructor(
    @InjectRepository(Doctor)
    private readonly repository: Repository<Doctor>,
  ) {}

  // the Repository to use: the one of the current transaction if there is
  // one, the "ordinary" Repository otherwise.
  private repo(manager?: EntityManager): Repository<Doctor> {
    return manager ? manager.getRepository(Doctor) : this.repository;
  }

  // list sorted by last name then first name, user account included
  findAll(): Promise<Doctor[]> {
    return this.repository.find({ relations: { user: true }, order: { lastName: 'ASC', firstName: 'ASC' } });
  }

  findById(id: number, manager?: EntityManager): Promise<Doctor | null> {
    return this.repo(manager).findOne({ where: { id }, relations: { user: true } });
  }

  // the person with this first and last name, if there is one. The comparison
  // is done by MySQL, hence with the collation of the database (utf8mb4_unicode_ci:
  // regardless of case and accents) - exactly the same
  // rule as the one of the SQL uniqueness constraint.
  findByFirstNameLastName(firstName: string, lastName: string, manager?: EntityManager): Promise<Doctor | null> {
    return this.repo(manager).findOneBy({ firstName, lastName });
  }

  save(doctor: Doctor, manager?: EntityManager): Promise<Doctor> {
    return this.repo(manager).save(doctor);
  }

  async deleteById(id: number, manager?: EntityManager): Promise<void> {
    await this.repo(manager).delete(id);
  }
}
  • lines 28–30: within a transaction, the repository for the current transaction ([manager]);
  • lines 33–35: all doctors, along with their account ([relations: { user: true }]), sorted by last name then first name. The account is used to display the login in the list of doctors;
  • lines 45–47: any potential namesakes. The comparison is performed by [MySQL], according to the database’s collation;
  • lines 49–51 and 53–55: write and delete operations.

11.10.2. src/repositories/client.repository.ts

// =========================================================================
// repositories/client.repository.ts
// -------------------------------------------------------------------------
// DAO layer of the [Client] entity (a PATIENT of the practice). Strictly
// the same principle as doctor.repository.ts (see its comments).
// =========================================================================

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm';
import { Client } from '../entities/client.entity';

@Injectable()
export class ClientRepository {
  constructor(
    @InjectRepository(Client)
    private readonly repository: Repository<Client>,
  ) {}

  private repo(manager?: EntityManager): Repository<Client> {
    return manager ? manager.getRepository(Client) : this.repository;
  }

  findAll(): Promise<Client[]> {
    return this.repository.find({ relations: { user: true }, order: { lastName: 'ASC', firstName: 'ASC' } });
  }

  findById(id: number, manager?: EntityManager): Promise<Client | null> {
    return this.repo(manager).findOne({ where: { id }, relations: { user: true } });
  }

  // the client (patient) whose user account is [userId], or null.
  // where: { user: { id } }: TypeORM translates this filter on the relation into
  // a simple "WHERE clients.user = ?" (no join needed)
  findByUserId(userId: number): Promise<Client | null> {
    return this.repository.findOne({ where: { user: { id: userId } }, relations: { user: true } });
  }

  // reads a client while setting a LOCK on its row until the end of the
  // current transaction (SELECT ... FOR UPDATE). A second transaction that
  // requests the same lock WAITS until the first one has finished.
  // Used when booking an appointment (see BusinessService.addAppointment): 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.
  lock(id: number, manager: EntityManager): Promise<Client | null> {
    return manager.getRepository(Client).findOne({ where: { id }, lock: { mode: 'pessimistic_write' } });
  }

  // the person with this first and last name, if there is one. The comparison
  // is done by MySQL, hence with the collation of the database (utf8mb4_unicode_ci:
  // regardless of case and accents) - exactly the same
  // rule as the one of the SQL uniqueness constraint.
  findByFirstNameLastName(firstName: string, lastName: string, manager?: EntityManager): Promise<Client | null> {
    return this.repo(manager).findOneBy({ firstName, lastName });
  }

  save(client: Client, manager?: EntityManager): Promise<Client> {
    return this.repo(manager).save(client);
  }

  async deleteById(id: number, manager?: EntityManager): Promise<void> {
    await this.repo(manager).delete(id);
  }
}

The same methods as for doctors, plus two:

  • lines 35–37: the client whose account is specified—this is how a logged-in patient (whose token contains their account ID) retrieves their client record. [TypeORM] translates the filter [where: { user: { id: userId } }] into [WHERE clients.user = ?];
  • lines 45–47: a read with a lock ([lock: { mode: 'pessimistic_write' }], i.e., [SELECT ... FOR UPDATE] in SQL). Until the end of the transaction, any other transaction requesting the same lock waits. The appointment scheduling module uses this to process two simultaneous reservations for the same patient one after the other.

11.10.3. src/repositories/slot.repository.ts

// =========================================================================
// repositories/slot.repository.ts
// -------------------------------------------------------------------------
// DAO layer of the [Slot] entity. New: deleteByDoctor(), used
// when a doctor is deleted (the doctor's time slots disappear
// along with the doctor - see BusinessService.deleteDoctor).
// =========================================================================

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm';
import { Slot } from '../entities/slot.entity';

@Injectable()
export class SlotRepository {
  constructor(
    @InjectRepository(Slot)
    private readonly repository: Repository<Slot>,
  ) {}

  findById(id: number): Promise<Slot | null> {
    return this.repository.findOne({ where: { id }, relations: { doctor: true } });
  }

  // the slots of a doctor, in chronological order
  // (same query as in the JSON server - see its comments)
  getAllSlots(doctorId: number): Promise<Slot[]> {
    return this.repository
      .createQueryBuilder('s')
      .where('s.doctor = :doctorId', { doctorId })
      .orderBy('s.startHour', 'ASC')
      .addOrderBy('s.startMinute', 'ASC')
      .getMany();
  }

  // deletes all the slots of a doctor. delete({ doctor: { id } })
  // generates: DELETE FROM slots WHERE doctor_id = ?
  async deleteByDoctor(doctorId: number, manager?: EntityManager): Promise<void> {
    const repo = manager ? manager.getRepository(Slot) : this.repository;
    await repo.delete({ doctor: { id: doctorId } });
  }
}
  • Lines 27–34: A doctor’s appointment slots, in chronological order. The query is written using QueryBuilder from [TypeORM], which constructs a SQL query method by method: [createQueryBuilder('s')] (the table, with the alias [s]), [where] (with a parameter named [:doctorId]—never a string concatenation, which would open the door to SQL injections), [orderBy], then [getMany], which executes the query;
  • lines 38–41: Deleting a doctor’s appointment slots, used when that doctor is removed.

11.10.4. src/repositories/appointment.repository.ts

// =========================================================================
// repositories/appointment.repository.ts
// -------------------------------------------------------------------------
// DAO layer of the [Appointment] entity. New features:
//   - the number of appointments of a doctor, to REFUSE the deletion
//     of a doctor who still has appointments (see BusinessService);
//   - the UPCOMING appointments of a client (patient's "My area" page, and
//     rule "only one upcoming appointment per patient");
//   - the deletion of all the appointments of a client (a client is
//     deleted along with their appointments, by the ADMIN or by themselves).
// =========================================================================

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm';
import { Appointment } from '../entities/appointment.entity';

@Injectable()
export class AppointmentRepository {
  constructor(
    @InjectRepository(Appointment)
    private readonly repository: Repository<Appointment>,
  ) {}

  private repo(manager?: EntityManager): Repository<Appointment> {
    return manager ? manager.getRepository(Appointment) : this.repository;
  }

  findById(id: number): Promise<Appointment | null> {
    return this.repository.findOne({ where: { id }, relations: { client: true, slot: true } });
  }

  save(appointment: Appointment, manager?: EntityManager): Promise<Appointment> {
    return this.repo(manager).save(appointment);
  }

  async deleteById(id: number): Promise<void> {
    await this.repository.delete(id);
  }

  // the appointments of a doctor on a given day, client and slot included
  // (same query as in the JSON server - see its comments)
  getDoctorDayAppointments(doctorId: number, day: string): Promise<Appointment[]> {
    return this.repository
      .createQueryBuilder('appointment')
      .leftJoinAndSelect('appointment.client', 'client')
      .leftJoinAndSelect('appointment.slot', 'slot')
      .where('slot.doctor = :doctorId', { doctorId })
      .andWhere('appointment.day = :day', { day })
      .getMany();
  }

  // the appointments of a client from day [from] (included), in
  // chronological order, with the slot AND the doctor of that slot
  // (cascading innerJoinAndSelect: appointment -> slot -> doctor)
  findUpcomingByClient(clientId: number, from: string): Promise<Appointment[]> {
    return this.repository
      .createQueryBuilder('appointment')
      .innerJoinAndSelect('appointment.slot', 'slot')
      .innerJoinAndSelect('slot.doctor', 'doctor')
      .where('appointment.client = :clientId', { clientId })
      .andWhere('appointment.day >= :from', { from })
      .orderBy('appointment.day', 'ASC')
      .addOrderBy('slot.startHour', 'ASC')
      .addOrderBy('slot.startMinute', 'ASC')
      .getMany();
  }

  // number of appointments of a client from day [from] (included)
  countUpcomingByClient(clientId: number, from: string, manager?: EntityManager): Promise<number> {
    return this.repo(manager)
      .createQueryBuilder('appointment')
      .where('appointment.client = :clientId', { clientId })
      .andWhere('appointment.day >= :from', { from })
      .getCount();
  }

  // deletes ALL the appointments (past and future) of a client:
  //   DELETE FROM appointments WHERE client_id = ?
  async deleteByClient(clientId: number, manager?: EntityManager): Promise<void> {
    await this.repo(manager).delete({ client: { id: clientId } });
  }

  // number of appointments (past or future) of a doctor - we must go
  // through the doctor's slots, since [appointments] does not reference [doctors] directly:
  //   SELECT COUNT(*) FROM appointments INNER JOIN slots s ON ... WHERE s.doctor_id = ?
  countByDoctor(doctorId: number, manager?: EntityManager): Promise<number> {
    return this.repo(manager)
      .createQueryBuilder('appointment')
      .innerJoin('appointment.slot', 'slot')
      .where('slot.doctor = :doctorId', { doctorId })
      .getCount();
  }
}
  • lines 43–51: a doctor’s appointments for a given day. [leftJoinAndSelect] performs a join and loads the joined entity: each appointment is retrieved along with its patient and time slot;
  • lines 56–67: a client’s appointments starting on a given day, with their time slot and the doctor assigned to that slot (two cascading joins): this is what is displayed in “My area”;
  • lines 70–76: their count, for the “only one upcoming appointment” rule;
  • lines 80–82: the deletion of all appointments for a client;
  • lines 87–93: the number of appointments for a doctor. The table [appointments] does not directly reference doctors: you must go through the time slots ([innerJoin], a join without loading).

11.10.5. src/repositories/user.repository.ts

// =========================================================================
// repositories/user.repository.ts
// -------------------------------------------------------------------------
// DAO layer of the [User] entity. New: save() / deleteById(), because
// creating (or deleting) a doctor or a client also creates (or deletes) the
// user account - see BusinessService.
// =========================================================================

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm';
import { User } from '../entities/user.entity';

@Injectable()
export class UserRepository {
  constructor(
    @InjectRepository(User)
    private readonly repository: Repository<User>,
  ) {}

  private repo(manager?: EntityManager): Repository<User> {
    return manager ? manager.getRepository(User) : this.repository;
  }

  // returns null if no user has this login
  findByLogin(login: string, manager?: EntityManager): Promise<User | null> {
    return this.repo(manager).findOneBy({ login });
  }

  save(user: User, manager?: EntityManager): Promise<User> {
    return this.repo(manager).save(user);
  }

  async deleteById(id: number, manager?: EntityManager): Promise<void> {
    await this.repo(manager).delete(id);
  }
}

Lines 26–28: a login’s account, used for logging in and to check if a login is available.

11.11. The business layer

11.11.1. src/business/dates.ts

// =========================================================================
// business/dates.ts
// -------------------------------------------------------------------------
// Today's date in the "yyyy-MM-dd" format (that of the appointments.day column).
// It is used by BUSINESS RULES (no booking in the past, only one
// UPCOMING appointment per patient): it therefore lives in the business
// layer. The web layer reuses it (see web/helpers/dates.helper.ts).
//
// Two "yyyy-MM-dd" dates can be compared directly as strings:
// "2026-09-23" < "2026-10-01" - alphabetical order is chronological order.
// =========================================================================

// today's date, in the server's time zone
export function today(): string {
  const d = new Date();
  const twoDigits = (n: number) => n.toString().padStart(2, '0');
  return `${d.getFullYear()}-${twoDigits(d.getMonth() + 1)}-${twoDigits(d.getDate())}`;
}

Lines 14–18: Today’s date in the format [yyyy-mm-dd]; the date in column [appointments.day]. [padStart(2, '0')] is used to pad a two-digit number (9 becomes [09]). In this format, alphabetical order is chronological order: two dates are compared using [<].

11.11.2. src/business/business.exception.ts

// =========================================================================
// business/business.exception.ts
// -------------------------------------------------------------------------
// 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).
//
// The business layer thus remains independent of the interface language -
// as it was already independent of the output format (JSON or HTML).
// =========================================================================

export class BusinessException extends Error {
  constructor(
    public readonly key: string, // translation key of the message
    public readonly params: Record<string, string> = {}, // optional parameters of the message
  ) {
    super(key);
    this.name = 'BusinessException';
  }
}
  • line 17: a [BusinessException] does not carry a message, but a translation key ([ERRORS.LOGIN_TAKEN]). The business layer thus ignores the interface language: the view will translate the key (see Chapter 5);
  • line 18: any message parameters;
  • line 21: the exception name, displayed in error logs.

11.11.3. src/business/business.interface.ts

// =========================================================================
// business/business.interface.ts
// -------------------------------------------------------------------------
// The contract of the business layer. To the 11 methods of the JSON server (reading
// lists, schedule, adding/deleting an appointment) are added 2
// methods for the patient area and 6 methods to manage doctors
// and clients.
// =========================================================================

import { Client } from '../entities/client.entity';
import { Slot } from '../entities/slot.entity';
import { Doctor } from '../entities/doctor.entity';
import { Appointment } from '../entities/appointment.entity';
import { DoctorDaySchedule } from '../domain/doctor-day-schedule';

// the data entered in the "doctor" or "client" form
export interface PersonData {
  title: string; // Mr, Mme, Melle
  lastName: string;
  firstName: string;
  // creation: optional, defaults to the last name in lower case
  // update: optional, the login is unchanged if absent
  login?: string;
  // creation: optional, defaults to the login
  // update: optional, the password is unchanged if absent
  password?: string;
  // update: the version of the record when the form was
  // displayed (optimistic lock, see AbstractEntity.version)
  version?: number;
}

export interface IBusiness {
  // ------------------------------------------------ reads (unchanged)
  getAllClients(): Promise<Client[]>;
  getAllDoctors(): Promise<Doctor[]>;
  getAllSlots(doctorId: number): Promise<Slot[]>;
  getDoctorDayAppointments(doctorId: number, day: string): Promise<Appointment[]>;
  getClientById(id: number): Promise<Client | null>;
  getDoctorById(id: number): Promise<Doctor | null>;
  getAppointmentById(id: number): Promise<Appointment | null>;
  getSlotById(id: number): Promise<Slot | null>;
  getDoctorDaySchedule(doctorId: number, day: string): Promise<DoctorDaySchedule | null>;
  /** the client (patient) whose user account is [userId], or null */
  getClientByUserId(userId: number): Promise<Client | null>;
  /** the appointments of a client, today or in the future, with slot and doctor */
  getClientUpcomingAppointments(clientId: number): Promise<Appointment[]>;

  // ------------------------------------------------ appointments (ADMIN, DOCTOR, USER)
  /**
   * 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 taken that day ('ERRORS.SLOT_ALREADY_BOOKED').
   */
  addAppointment(day: string, slot: Slot, client: Client): Promise<Appointment>;
  deleteAppointment(appointment: Appointment): Promise<void>;

  // ------------------------------------------------ doctors (ADMIN)
  /** creates the doctor AND the doctor's user account (role DOCTOR) */
  addDoctor(data: PersonData): Promise<Doctor>;
  /** updates the doctor AND the doctor's user account */
  editDoctor(id: number, data: PersonData): Promise<Doctor>;
  /** deletes the doctor, the slots AND the account - refused if there are appointments */
  deleteDoctor(id: number): Promise<void>;

  // ------------------------------------------------ clients
  // (the ADMIN; addClient is also used for a patient's registration,
  // editClient and deleteClient by the patient for their own account)
  /** creates the client AND the client's user account (role USER) */
  addClient(data: PersonData): Promise<Client>;
  editClient(id: number, data: PersonData): Promise<Client>;
  /**
   * deletes the client, the account AND all the appointments (past and future).
   * Used by the ADMIN ("Clients" page) and by the patient themselves
   * ("Manage my account").
   */
  deleteClient(id: number): Promise<void>;
}

export const BUSINESS_TOKEN = 'IBusiness';
  • lines 17–30: the data for a doctor or client to be created or modified. Username and password are optional: upon creation, they default to the name in lowercase; upon modification, they change only if they have been entered;
  • lines 32–78: the business layer contract—what other layers can request from it. The comments [/** ... */] are documentation comments, displayed by [VS Code] when you hover over the method;
  • line 80: the token under which the implementation is registered in [app.module.ts]. A [TypeScript] interface disappears at compile time: it cannot be used for dependency injection. Therefore, injection is performed using this token: [@Inject(BUSINESS_TOKEN)].

11.11.4. src/business/business.service.ts

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
// =========================================================================
// business/business.service.ts
// -------------------------------------------------------------------------
// Implementation of the business layer.
//
// The reads and the schedule are taken as is from the JSON server. The
// new feature is the management of doctors and clients, which obeys three
// business rules:
//
//   1. a person (doctor or client) and their user account ALWAYS go
//      together: they are created, updated and deleted together, in
//      a single TRANSACTION - either everything succeeds, or nothing is written;
//   2. the login is unique across 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 (the doctor's
//        patients would lose their appointment without being told): they
//        must be cancelled first. The doctor's time slots disappear too;
//      - 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 person booking (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 (private xxxPerson methods), and parameterized by a
// small "descriptor" that tells which type of person we are dealing with.
// =========================================================================

import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager, QueryFailedError } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import { PersonData, IBusiness } from './business.interface';
import { BusinessException } from './business.exception';
import { DoctorRepository } from '../repositories/doctor.repository';
import { ClientRepository } from '../repositories/client.repository';
import { SlotRepository } from '../repositories/slot.repository';
import { AppointmentRepository } from '../repositories/appointment.repository';
import { UserRepository } from '../repositories/user.repository';
import { Client } from '../entities/client.entity';
import { Slot } from '../entities/slot.entity';
import { Doctor } from '../entities/doctor.entity';
import { Person } from '../entities/person.entity';
import { Appointment } from '../entities/appointment.entity';
import { Role, User } from '../entities/user.entity';
import { DoctorDaySchedule } from '../domain/doctor-day-schedule';
import { DoctorDaySlot } from '../domain/doctor-day-slot';
import { today } from './dates';

// number of bcrypt salt rounds - the same as in the SQL script ($2b$10$...)
const BCRYPT_ROUNDS = 10;

// what the two DAOs DoctorRepository and ClientRepository have in common
// (TypeScript checks compatibility by the SHAPE of objects: both
// classes satisfy this interface without having to declare it)
interface PersonDao<T extends Person> {
  findById(id: number, manager?: EntityManager): Promise<T | null>;
  findByFirstNameLastName(firstName: string, lastName: string, manager?: EntityManager): Promise<T | null>;
  save(person: T, manager?: EntityManager): Promise<T>;
  deleteById(id: number, manager?: EntityManager): Promise<void>;
}

// everything that distinguishes a doctor from a client for the business rules
interface PersonDescriptor<T extends Person> {
  dao: PersonDao<T>;
  create: () => T; // new Doctor() or new Client()
  role: Role; // DOCTOR or USER
  notFoundKey: string; // message if the person does not exist
  alreadyExistsKey: string; // message if the same first and last names already exist
  // optional: if present, deletion is REFUSED as long as the person
  // has appointments (countAppointments > 0), with the message hasAppointmentsKey
  countAppointments?: (id: number, manager: EntityManager) => Promise<number>;
  hasAppointmentsKey?: string;
  // optional: what must be deleted BEFORE the person (the rows that
  // reference the person through a foreign key)
  beforeDelete?: (id: number, manager: EntityManager) => Promise<void>;
}

@Injectable()
export class BusinessService implements IBusiness {
  private readonly doctorDescriptor: PersonDescriptor<Doctor>;
  private readonly clientDescriptor: PersonDescriptor<Client>;

  constructor(
    // DataSource: the TypeORM connection itself, provided by
    // TypeOrmModule.forRoot(...). We use it ONLY to open
    // transactions (dataSource.transaction(...)) - the queries, on the other hand,
    // remain in the DAO layer.
    private readonly dataSource: DataSource,
    private readonly doctorRepository: DoctorRepository,
    private readonly clientRepository: ClientRepository,
    private readonly slotRepository: SlotRepository,
    private readonly appointmentRepository: AppointmentRepository,
    private readonly userRepository: UserRepository,
  ) {
    this.doctorDescriptor = {
      dao: doctorRepository,
      create: () => new Doctor(),
      role: 'DOCTOR',
      notFoundKey: 'ERRORS.DOCTOR_NOT_FOUND',
      alreadyExistsKey: 'ERRORS.DOCTOR_ALREADY_EXISTS',
      countAppointments: (id, manager) => appointmentRepository.countByDoctor(id, manager),
      hasAppointmentsKey: 'ERRORS.DOCTOR_HAS_APPOINTMENTS',
      // a doctor without appointments may still have time slots:
      // they make no sense without the doctor, they are deleted too (otherwise
      // the foreign key slots.doctor_id would forbid the deletion).
      beforeDelete: (id, manager) => slotRepository.deleteByDoctor(id, manager),
    };
    this.clientDescriptor = {
      dao: clientRepository,
      create: () => new Client(),
      role: 'USER',
      notFoundKey: 'ERRORS.PATIENT_NOT_FOUND',
      alreadyExistsKey: 'ERRORS.PATIENT_ALREADY_EXISTS',
      // no countAppointments: deleting 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: (id, manager) => appointmentRepository.deleteByClient(id, manager),
    };
  }

  // =======================================================================
  // reads - unchanged compared with the JSON server
  // =======================================================================

  getAllClients(): Promise<Client[]> {
    return this.clientRepository.findAll();
  }

  getAllDoctors(): Promise<Doctor[]> {
    return this.doctorRepository.findAll();
  }

  getAllSlots(doctorId: number): Promise<Slot[]> {
    return this.slotRepository.getAllSlots(doctorId);
  }

  getDoctorDayAppointments(doctorId: number, day: string): Promise<Appointment[]> {
    return this.appointmentRepository.getDoctorDayAppointments(doctorId, day);
  }

  getClientById(id: number): Promise<Client | null> {
    return this.clientRepository.findById(id);
  }

  getDoctorById(id: number): Promise<Doctor | null> {
    return this.doctorRepository.findById(id);
  }

  getAppointmentById(id: number): Promise<Appointment | null> {
    return this.appointmentRepository.findById(id);
  }

  getSlotById(id: number): Promise<Slot | null> {
    return this.slotRepository.findById(id);
  }

  getClientByUserId(userId: number): Promise<Client | null> {
    return this.clientRepository.findByUserId(userId);
  }

  getClientUpcomingAppointments(clientId: number): Promise<Appointment[]> {
    return this.appointmentRepository.findUpcomingByClient(clientId, today());
  }

  // same algorithm as the JSON server (and as the original Spring project)
  async getDoctorDaySchedule(doctorId: number, day: string): Promise<DoctorDaySchedule | null> {
    const doctor = await this.getDoctorById(doctorId);
    if (doctor === null) {
      return null;
    }
    const timeSlots = await this.getAllSlots(doctorId);
    const bookings = await this.getDoctorDayAppointments(doctorId, day);
    const bookingsBySlot = new Map<number, Appointment>();
    for (const booking of bookings) {
      bookingsBySlot.set(booking.slotId, booking);
    }
    const doctorDaySlots = timeSlots.map(
      (slot) => new DoctorDaySlot(slot, bookingsBySlot.get(slot.id) ?? null),
    );
    return new DoctorDaySchedule(doctor, day, doctorDaySlots);
  }

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

  // -----------------------------------------------------------------------
  // booking an appointment, in a transaction:
  //   1. no appointment in the past;
  //   2. we LOCK the patient's row (SELECT ... FOR UPDATE): if the
  //      same patient books two slots at the same moment (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. only one 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 transaction().
  // -----------------------------------------------------------------------
  addAppointment(day: string, slot: Slot, client: Client): Promise<Appointment> {
    return this.transaction(async (manager) => {
      const todayDate = today();
      if (day < todayDate) {
        throw new BusinessException('ERRORS.PAST_DATE');
      }
      if ((await this.clientRepository.lock(client.id, manager)) === null) {
        throw new BusinessException('ERRORS.PATIENT_NOT_FOUND');
      }
      if ((await this.appointmentRepository.countUpcomingByClient(client.id, todayDate, manager)) > 0) {
        throw new BusinessException('ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT');
      }
      const appointment = new Appointment();
      appointment.day = day;
      appointment.slot = slot;
      appointment.client = client;
      return this.appointmentRepository.save(appointment, manager);
    });
  }

  async deleteAppointment(appointment: Appointment): Promise<void> {
    await this.appointmentRepository.deleteById(appointment.id);
  }

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

  addDoctor(data: PersonData): Promise<Doctor> {
    return this.addPerson(this.doctorDescriptor, data);
  }

  editDoctor(id: number, data: PersonData): Promise<Doctor> {
    return this.editPerson(this.doctorDescriptor, id, data);
  }

  deleteDoctor(id: number): Promise<void> {
    return this.deletePerson(this.doctorDescriptor, id);
  }

  addClient(data: PersonData): Promise<Client> {
    return this.addPerson(this.clientDescriptor, data);
  }

  editClient(id: number, data: PersonData): Promise<Client> {
    return this.editPerson(this.clientDescriptor, id, data);
  }

  // the client, the account AND all the appointments (ADMIN, or the patient
  // themselves from "Manage my account": same method, same rule)
  deleteClient(id: number): Promise<void> {
    return this.deletePerson(this.clientDescriptor, id);
  }

  // -----------------------------------------------------------------------
  // creation: the user account first (the person references it),
  // then the person - all in ONE transaction.
  // -----------------------------------------------------------------------
  private addPerson<T extends Person>(d: PersonDescriptor<T>, data: PersonData): Promise<T> {
    const lastName = data.lastName.trim().toUpperCase();
    const firstName = data.firstName.trim();
    // the rules of the dbrdvmedecins2 database: login = last name in lower case,
    // password = login - unless the ADMIN entered other values
    const login = (data.login || lastName).toLowerCase();
    const password = data.password || login;

    return this.transaction(async (manager) => {
      await this.checkPersonUnique(d, firstName, lastName, null, manager);
      await this.checkLoginFree(login, manager);

      const user = new User();
      user.login = login;
      user.password = await bcrypt.hash(password, BCRYPT_ROUNDS);
      user.name = `${firstName} ${lastName}`;
      user.role = d.role;
      await this.userRepository.save(user, manager);

      const person = d.create();
      person.title = data.title;
      person.lastName = lastName;
      person.firstName = firstName;
      person.user = user;
      return d.dao.save(person, manager);
    });
  }

  // -----------------------------------------------------------------------
  // update: the person AND the account (the display name "firstName LASTNAME"
  // must follow; the login and the password change only if they have been
  // entered).
  // -----------------------------------------------------------------------
  private editPerson<T extends Person>(
    d: PersonDescriptor<T>,
    id: number,
    data: PersonData,
  ): Promise<T> {
    return this.transaction(async (manager) => {
      const person = await d.dao.findById(id, manager);
      if (person === null) {
        throw new BusinessException(d.notFoundKey);
      }
      // optimistic lock: if the version in the database is no longer that of the
      // form, someone has changed this record in the meantime - we refuse
      // rather than silently overwrite their changes.
      if (data.version !== undefined && data.version !== person.version) {
        throw new BusinessException('ERRORS.CONCURRENT_UPDATE');
      }

      person.title = data.title;
      person.lastName = data.lastName.trim().toUpperCase();
      person.firstName = data.firstName.trim();
      await this.checkPersonUnique(d, person.firstName, person.lastName, id, manager);

      const user = person.user;
      user.name = `${person.firstName} ${person.lastName}`;
      if (data.login && data.login.toLowerCase() !== user.login) {
        const login = data.login.toLowerCase();
        await this.checkLoginFree(login, manager);
        user.login = login;
      }
      if (data.password) {
        user.password = await bcrypt.hash(data.password, BCRYPT_ROUNDS);
      }
      await this.userRepository.save(user, manager);
      return d.dao.save(person, manager);
    });
  }

  // -----------------------------------------------------------------------
  // deletion, in ONE transaction, in the order imposed by the foreign
  // keys:
  //   1. possible refusal if there are still appointments (doctor);
  //   2. what references the person (slots of a doctor, appointments
  //      of a client);
  //   3. the person (it references the account);
  //   4. the account.
  // -----------------------------------------------------------------------
  private deletePerson<T extends Person>(d: PersonDescriptor<T>, id: number): Promise<void> {
    return this.transaction(async (manager) => {
      const person = await d.dao.findById(id, manager);
      if (person === null) {
        throw new BusinessException(d.notFoundKey);
      }
      if (d.countAppointments && (await d.countAppointments(id, manager)) > 0) {
        throw new BusinessException(d.hasAppointmentsKey ?? 'ERRORS.INVALID_FORM');
      }
      if (d.beforeDelete) {
        await d.beforeDelete(id, manager);
      }
      await d.dao.deleteById(id, manager);
      await this.userRepository.deleteById(person.userId, manager);
    });
  }

  // refuses a first and last name 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 async checkPersonUnique<T extends Person>(
    d: PersonDescriptor<T>,
    firstName: string,
    lastName: string,
    currentId: number | null,
    manager: EntityManager,
  ): Promise<void> {
    const namesake = await d.dao.findByFirstNameLastName(firstName, lastName, manager);
    if (namesake !== null && namesake.id !== currentId) {
      throw new BusinessException(d.alreadyExistsKey);
    }
  }

  private async checkLoginFree(login: string, manager: EntityManager): Promise<void> {
    if ((await this.userRepository.findByLogin(login, manager)) !== null) {
      throw new BusinessException('ERRORS.LOGIN_TAKEN');
    }
  }

  // -----------------------------------------------------------------------
  // runs [work] in a transaction: dataSource.transaction() does
  // START TRANSACTION, calls [work] with an EntityManager bound to this
  // transaction, then COMMIT if everything went well - or ROLLBACK if an
  // exception was thrown (BusinessException included).
  //
  // The "login free" and "unique first + last name" checks are done BEFORE
  // the write, to give a clear message. But two ADMINs could
  // save the same login (or the same doctor) at the same moment: in that
  // 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 transaction<R>(work: (manager: EntityManager) => Promise<R>): Promise<R> {
    try {
      return await this.dataSource.transaction(work);
    } catch (e) {
      throw translateDuplicate(e);
    }
  }
}

// -------------------------------------------------------------------------
// Uniqueness constraint violations -> BusinessException
//
// When a UNIQUE constraint refuses a write, MySQL returns the error
// ER_DUP_ENTRY (no. 1062), whose 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 the table's, MariaDB does not). That is
// why the SQL script NAMES all its constraints: each one can then
// be translated into its own message.
// -------------------------------------------------------------------------
const CONSTRAINT_MESSAGES: Record<string, string> = {
  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',
};

// returns the BusinessException matching the violated constraint, or
// the original error, unchanged, if it is not a known violation
function translateDuplicate(e: unknown): unknown {
  if (!(e instanceof QueryFailedError)) {
    return e;
  }
  const sqlError = e.driverError as { code?: string; sqlMessage?: string };
  if (sqlError?.code !== 'ER_DUP_ENTRY') {
    return e;
  }
  // "... for key 'doctors.unq_doctors_first_name_last_name'" -> "unq_doctors_first_name_last_name"
  const constraint = /for key '(?:[^'.]*\.)?([^']+)'/.exec(sqlError.sqlMessage ?? e.message)?.[1];
  const key = constraint ? CONSTRAINT_MESSAGES[constraint] : undefined;
  return key ? new BusinessException(key) : e;
}

This is the most comprehensive file in the application. Let’s break it down section by section.

11.11.4.1. The descriptors

  • line 54: the cost of bcrypt, the same as in the script SQL;
  • lines 59–64: what the DAO files for doctors and clients have in common. [TypeScript] compares types by their form: [DoctorRepository] and [ClientRepository] satisfy this interface without declaring it ([implements]);
  • Lines 67–80: Everything that distinguishes a doctor from a client in terms of business logic: its DAO, how to create an instance, the role of its account, its error messages, and what happens when it is deleted. Since doctors and clients follow the same rules, the code for these rules is written once, in generic methods parameterized by a descriptor;
  • lines 87–98: the constructor receives the [DataSource] (to open transactions) and the five DAO. It constructs the two descriptors. The one for doctors (lines 99–111) prevents the deletion of a doctor who has appointments ([countAppointments]) and deletes their time slots before the doctor is deleted ([beforeDelete]). The one for clients (lines 112–122) does not have a [countAppointments]: a client is deleted along with their appointments.

11.11.4.2. Read operations and the calendar

  • lines 129–167: the queries delegate to the DAO layer;
  • lines 170–185: calendar calculation. The doctor’s time slots and today’s appointments are read; the appointments are stored in a [Map] indexed by their time slot ID; then each time slot is associated with its appointment, or with [null]. The [Map] avoids having to scan the list of appointments for each time slot.

11.11.4.3. Scheduling an appointment

  • lines 203–221: everything takes place within a single transaction;
  • lines 206–208: no appointments in the past;
  • lines 209–211: the patient’s record is locked. Without this lock, two appointments for the same patient submitted at the same time (two tabs, a double-click) could both pass the following test before either one has written its appointment to the database. With the lock, the second transaction waits for the first to finish, then sees the appointment it created;
  • lines 212–214: only one upcoming appointment per patient;
  • lines 215–219: the insertion. If another patient has just booked the same time slot, the constraint [unq1_appointments] rejects the insertion, and the method [transaction] handles the error (see below);
  • lines 223–225: deleting an appointment.

11.11.4.4. Management of Doctors and Clients

  • lines 232–256: the six public methods delegate to the three generic methods, using the correct descriptor;
  • Lines 262–288: Creation. The name is capitalized; by default, the username is the name in lowercase, and the password is the username (lines 267–268). Within the transaction: uniqueness rules are verified, the account is created (the password is hashed), followed by the person, who is linked to the account. If the second write operation fails, the first is rolled back: an account is never created without a person;
  • lines 295–299: the update. The optimistic lock (lines 308–310) rejects a record that has been modified in the meantime. The displayed name of the account follows the first name and last name; the username and password change only if they have been entered;
  • lines 341–356: deletion, in the order dictated by foreign keys: possible rejection (a doctor who has appointments), deletion of references to the person (a doctor’s time slots, a client’s appointments), deletion of the person, then deletion of their account;
  • lines 361–367: a person with the same name is rejected, unless it is the person themselves (modification);
  • lines 374–378: a username that is already taken is rejected.

11.11.4.5. Transactions and Uniqueness Constraints

  • lines 392–398: All writes go through this method: [dataSource.transaction] commits or rolls back the transaction (see Example 28), and an error is translated by [translateDuplicate];
  • lines 411–416: each named uniqueness constraint in the SQL script has its own error message;
  • lines 420–432: an error SQL ([QueryFailedError]) with code [ER_DUP_ENTRY] is a uniqueness constraint violation. Its message includes the constraint name (prefixed by the table name with [MySQL] 8, without a prefix with [MariaDB]): the regular expression on line 429 extracts it. A known constraint becomes [BusinessException]; any other error is reported as-is: this is a genuine anomaly.

11.12. 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 account creation.

11.12.1. src/auth/auth.constants.ts

// =========================================================================
// auth/auth.constants.ts
// -------------------------------------------------------------------------
// Where does the JWT token travel?
//
// With the Angular client, it was the client's JavaScript code that kept
// the token (localStorage) and added it itself to each request (header
// "Authorization: Bearer ..."). A browser that displays ordinary HTML
// pages cannot do that: for it to present the token on its
// own with each request, the token must be stored in a COOKIE.
//
// The options of this cookie matter for security:
//   - httpOnly: the page's JavaScript 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 change
//     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.
// =========================================================================

import { CookieOptions } from 'express';

export const COOKIE_JWT = 'rdvmedecins_jwt';

export const JWT_SECRET = process.env.JWT_SECRET ?? 'dev-secret-change-in-production';

export const JWT_EXPIRES_IN_SECONDS = process.env.JWT_EXPIRES_IN_SECONDS
  ? parseInt(process.env.JWT_EXPIRES_IN_SECONDS, 10)
  : 7200;

export function jwtCookieOptions(): CookieOptions {
  return {
    httpOnly: true,
    sameSite: 'lax',
    secure: process.env.COOKIE_SECURE === 'true',
    maxAge: JWT_EXPIRES_IN_SECONDS * 1000, // in milliseconds for Express
    path: '/',
  };
}
  • lines 4–21: the comment explains why the token is stored in a cookie and the purpose of each of its options (see example 25);
  • line 28: the key, read from [.env]. The default value should only be used during development;
  • lines 30–32: the token’s lifetime: two hours by default;
  • lines 34–42: the cookie options, grouped in one place (they are used for logging in and modifying a patient’s account). [secure] is enabled only in production, where the site is served as HTTPS; [maxAge] is in milliseconds.

11.12.2. src/auth/auth.service.ts

// =========================================================================
// auth/auth.service.ts
// -------------------------------------------------------------------------
// The authentication logic:
//   - validateUser() : checks a login / password pair;
//   - createToken()  : builds the JWT token of a validated user;
//   - readToken()    : checks a token (login page: if the
//                      user is already logged in, no need to ask
//                      for the password again).
// =========================================================================

import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { UserRepository } from '../repositories/user.repository';
import { User } from '../entities/user.entity';
import { JwtPayload } from './jwt.strategy';

@Injectable()
export class AuthService {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly jwtService: JwtService,
  ) {}

  async validateUser(login: string, password: string): Promise<User | null> {
    const user = await this.userRepository.findByLogin(login);
    if (user === null) {
      return null;
    }
    return (await bcrypt.compare(password, user.password)) ? user : null;
  }

  createToken(user: User): string {
    const payload: JwtPayload = { sub: user.id, login: user.login, name: user.name, role: user.role };
    return this.jwtService.sign(payload);
  }

  // returns the content of the token if it is valid (correct signature, not
  // expired), null otherwise
  readToken(token: string | undefined): JwtPayload | null {
    if (!token) {
      return null;
    }
    try {
      return this.jwtService.verify<JwtPayload>(token);
    } catch {
      return null;
    }
  }
}
  • lines 26–32: the account if the username and password are correct ([bcrypt.compare]), [null] otherwise;
  • lines 34–37: the token, which contains the ID, username, display name, and role;
  • lines 41–50: [jwtService.verify] verifies a token’s signature and expiration date, and throws an exception if it is invalid. This method is used by unprotected pages (the login page) that want to determine whether the user is already logged in.

11.12.3. src/auth/jwt.strategy.ts

// =========================================================================
// auth/jwt.strategy.ts
// -------------------------------------------------------------------------
// The Passport strategy that authenticates EACH protected request from the
// JWT token. Only difference with the JSON server: the token is read from the
// [rdvmedecins_jwt] cookie (see auth.constants.ts) instead of the
// "Authorization: Bearer ..." header.
// =========================================================================

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Request } from 'express';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Role } from '../entities/user.entity';
import { COOKIE_JWT, JWT_SECRET } from './auth.constants';

// what is stored IN the token when it is built (see AuthService).
// The display name is also there, so that the navigation bar can
// display it without querying the database on each page.
export interface JwtPayload {
  sub: number; // the user's id (JWT convention)
  login: string;
  name: string;
  role: Role;
}

// what request.user will contain once the token is verified
export interface ConnectedUser {
  userId: number;
  login: string;
  name: string;
  role: Role;
}

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor() {
    super({
      // fromExtractors([...]): we provide OUR own token extraction
      // function - here, reading a cookie (req.cookies is filled by the
      // cookie-parser middleware, see main.ts).
      jwtFromRequest: ExtractJwt.fromExtractors([(req: Request) => req?.cookies?.[COOKIE_JWT] ?? null]),
      ignoreExpiration: false,
      secretOrKey: JWT_SECRET,
    });
  }

  validate(payload: JwtPayload): ConnectedUser {
    return { userId: payload.sub, login: payload.login, name: payload.name, role: payload.role };
  }
}
  • lines 20–25: the token’s contents;
  • lines 28–33: what [req.user] will contain;
  • lines 38–45: the token is read from the cookie; if it has expired or is incorrectly signed, it is rejected;
  • lines 48–50: the token’s content becomes [req.user].

11.12.4. src/auth/jwt-auth.guard.ts

// =========================================================================
// auth/jwt-auth.guard.ts
// -------------------------------------------------------------------------
// The guard that protects all the pages of the application (except /login): it
// requires a valid JWT token (see jwt.strategy.ts, registered under the name
// 'jwt'). Without a token, or with an invalid/expired token, it throws an
// UnauthorizedException - which HtmlExceptionFilter turns into a redirect
// to the login page (see web/html-exception.filter.ts).
// =========================================================================

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

The guard that requires a valid token (see Example 25). Without a token, it raises a [UnauthorizedException], which the exception filter converts into a redirect to [/login].

11.12.5. src/auth/roles.decorator.ts

// =========================================================================
// auth/roles.decorator.ts
// -------------------------------------------------------------------------
// @Roles('ADMIN') is a HOME-MADE decorator (we write it ourselves,
// unlike @Get/@Post/@Injectable provided by NestJS): it merely
// attaches a piece of metadata ("this method requires such a role") to
// the method it decorates, without checking anything itself - it is RolesGuard
// (see roles.guard.ts) that will read this metadata and perform the check.
//
// SetMetadata(key, value) is the low-level NestJS function that lets
// you write this kind of "custom" decorator: we use it here so as not to
// have to repeat the 'roles' string everywhere in the code.
// =========================================================================

import { SetMetadata } from '@nestjs/common';
import { Role } from '../entities/user.entity';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

Line 19: the [@Roles(...)] decorator (see Example 26). Its parameters are of type [Role]: [@Roles('ADMIM')] (typo) is rejected by the compiler.

11.12.6. src/auth/roles.guard.ts

// =========================================================================
// auth/roles.guard.ts
// -------------------------------------------------------------------------
// Second level of control, AFTER JwtAuthGuard: the latter only checks
// that we are indeed authenticated (a valid JWT token); RolesGuard
// additionally checks that the role of the logged-in user (request.user.role,
// set by JwtStrategy) is among those required by @Roles(...)
// on the targeted method.
//
// Unchanged compared with the JSON server, except the destination of the error:
// the ForbiddenException thrown here is turned into a "403 - access
// forbidden" PAGE by HtmlExceptionFilter (see web/html-exception.filter.ts).
//
// It is the equivalent, in spirit, of the Spring Security rule:
//   http.authorizeRequests().antMatchers(...).hasRole("ADMIN")
// but applied here method by method rather than URL by URL, thanks to the
// pair decorator (@Roles) + Reflector (which can read back the metadata
// set by a decorator).
// =========================================================================

import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from './roles.decorator';
import { Role } from '../entities/user.entity';
import { ConnectedUser } from './jwt.strategy';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    // we read back the metadata set by @Roles(...) on the targeted method -
    // getAllAndOverride looks first on the method, then on the class.
    const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [context.getHandler(), context.getClass()]);

    // no @Roles(...) metadata on this method: no additional role
    // restriction (JwtAuthGuard will already have required a valid
    // authentication, whatever the role).
    if (!requiredRoles || requiredRoles.length === 0) {
      return true;
    }

    // request.user was set by JwtStrategy.validate() (see jwt.strategy.ts) -
    // RolesGuard therefore ALWAYS runs after JwtAuthGuard (see their order
    // in @UseGuards(JwtAuthGuard, RolesGuard), see src/web/controllers/).
    const request = context.switchToHttp().getRequest<{ user: ConnectedUser }>();
    const user = request.user;

    if (!requiredRoles.includes(user.role)) {
      throw new ForbiddenException(
        `role [${user.role}] does not allow this action (required role: ${requiredRoles.join(', ')})`,
      );
    }
    return true;
  }
}

The role mapping is identical to that in Example 26.

11.12.7. src/auth/captcha.service.ts

// =========================================================================
// auth/captcha.service.ts
// -------------------------------------------------------------------------
// A CAPTCHA ("test to tell a human from a robot") built by the
// server itself, without any external service: the svg-captcha library
// draws a few distorted characters, blurred by lines, in an
// SVG image. A human reads them easily; a robot that just
// sends forms in a loop cannot.
//
// 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.
//
// Accepted limit: the memory of a single Node process. With several
// servers behind a load balancer, a shared storage
// would be needed (Redis, database...).
// =========================================================================

import { Injectable } from '@nestjs/common';
import { randomUUID } from 'crypto';
import * as svgCaptcha from 'svg-captcha';

// validity of an image: you have 10 minutes to fill in the form
const LIFETIME_MS = 10 * 60 * 1000;
// memory safeguard: beyond that, the oldest captchas are forgotten
const MAX_CAPTCHAS = 10_000;

export interface Captcha {
  id: string; // to put in the hidden "captchaId" field of the form
  svg: string; // the image, to insert as is in the page
}

@Injectable()
export class CaptchaService {
  // id -> { expected answer, expiry date }. A Map keeps the insertion
  // order: its first entries are the oldest ones.
  private readonly pending = new Map<string, { text: string; expires: number }>();

  generate(): Captcha {
    this.purge();
    const { text, data } = svgCaptcha.create({
      size: 5, // 5 characters
      noise: 3, // 3 blurring lines
      color: true,
      background: '#f5f7fa',
      // characters too easy to confuse with one another: excluded
      ignoreChars: '0oO1iIlL',
    });
    const id = randomUUID();
    this.pending.set(id, { text: text.toLowerCase(), expires: Date.now() + LIFETIME_MS });
    return { id, svg: data };
  }

  // true if [input] is the right answer to captcha [id] (regardless
  // of case). The captcha is consumed in every case.
  verify(id: unknown, input: unknown): boolean {
    if (typeof id !== 'string' || typeof input !== 'string') {
      return false;
    }
    const expected = this.pending.get(id);
    this.pending.delete(id);
    return expected !== undefined && expected.expires > Date.now() && expected.text === input.trim().toLowerCase();
  }

  private purge(): void {
    const now = Date.now();
    for (const [id, { expires }] of this.pending) {
      if (expires <= now || this.pending.size >= MAX_CAPTCHAS) {
        this.pending.delete(id);
      }
    }
  }
}
  • Lines 29–31: Compared to Example 27, a CAPTCHA has a lifespan (ten minutes), and the number of CAPTCHAs in memory is capped: a bot requesting the login page millions of times would not be able to exhaust the server’s memory;
  • line 42: a [Map] preserves the order of insertion: its earliest entries are the oldest;
  • lines 44–57: the image, its expected response (in lowercase), and its expiration date;
  • lines 61–68: The answer is compared without regard to case; the CAPTCHA is consumed in all cases;
  • lines 70–77: Expired CAPTCHAs are discarded, as are the oldest ones if the limit is reached.

11.12.8. src/auth/attempt-limiter.service.ts

// =========================================================================
// auth/attempt-limiter.service.ts
// -------------------------------------------------------------------------
// Second line of defence against bots (the first one is the captcha): we COUNT
// the attempts, and beyond a threshold we BLOCK for a while.
//
//   - login: after LOGIN_MAX_FAILURES failures (wrong captcha or wrong
//     password) for the same login from the same IP address, this pair
//     is blocked for LOGIN_BLOCK_MINUTES minutes. The same IP address is also
//     blocked if it accumulates 5 times more failures, all logins combined (a
//     bot that tries thousands of different logins);
//   - account creation: at most REGISTRATION_MAX_PER_HOUR 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 neighbours - and why the thresholds can be set
// in the .env file.
//
// As for the captcha, the counters live in the memory of the process.
// =========================================================================

import { Injectable } from '@nestjs/common';

interface Counter {
  count: number; // attempts counted in the current window
  start: number; // start of the counting window (ms)
  blockedUntil: number; // 0 if not blocked
}

const MINUTE = 60 * 1000;

function envInteger(name: string, fallback: number): number {
  const value = parseInt(process.env[name] ?? '', 10);
  return Number.isInteger(value) && value > 0 ? value : fallback;
}

@Injectable()
export class AttemptLimiterService {
  private readonly counters = new Map<string, Counter>();

  private readonly maxLoginFailures = envInteger('LOGIN_MAX_FAILURES', 5);
  private readonly loginBlockDuration = envInteger('LOGIN_BLOCK_MINUTES', 15) * MINUTE;
  private readonly maxRegistrations = envInteger('REGISTRATION_MAX_PER_HOUR', 10);

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

  // number of remaining minutes of blocking (0 = the attempt is allowed)
  loginBlockMinutes(ip: string, login: string): number {
    return Math.max(this.remainingMinutes(loginKey(ip, login)), this.remainingMinutes(ipKey(ip)));
  }

  loginFailure(ip: string, login: string): void {
    this.count(loginKey(ip, login), this.maxLoginFailures, this.loginBlockDuration, this.loginBlockDuration);
    this.count(ipKey(ip), this.maxLoginFailures * 5, this.loginBlockDuration, this.loginBlockDuration);
  }

  // a successful login resets the counter of THIS login
  loginSuccess(ip: string, login: string): void {
    this.counters.delete(loginKey(ip, login));
  }

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

  registrationBlockMinutes(ip: string): number {
    return this.remainingMinutes(`registration|${ip}`);
  }

  registrationSuccess(ip: string): void {
    // one-hour window; once the quota is reached, blocked until the end of the hour
    this.count(`registration|${ip}`, this.maxRegistrations, 60 * MINUTE, 60 * MINUTE);
  }

  // ---------------------------------------------------------------- common mechanics

  private remainingMinutes(key: string): number {
    const c = this.counters.get(key);
    const remaining = c ? c.blockedUntil - Date.now() : 0;
    return remaining > 0 ? Math.ceil(remaining / MINUTE) : 0;
  }

  // counts an attempt; if the threshold is reached within the window, blocks
  private count(key: string, max: number, window: number, block: number): void {
    const now = Date.now();
    this.purge(now);
    let c = this.counters.get(key);
    if (!c || now - c.start > window) {
      c = { count: 0, start: now, blockedUntil: 0 };
      this.counters.set(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 purge(now: number): void {
    if (this.counters.size < 1000) {
      return;
    }
    for (const [key, c] of this.counters) {
      if (c.blockedUntil < now && now - c.start > 60 * MINUTE) {
        this.counters.delete(key);
      }
    }
  }
}

function loginKey(ip: string, login: string): string {
  return `login|${ip}|${login.trim().toLowerCase()}`;
}

function ipKey(ip: string): string {
  return `ip|${ip}`;
}
  • lines 26–30: an attempt counter, with the start of its counting window and its lockout end date;
  • lines 34–37: a threshold read from [.env], with a default value;
  • lines 50–52: The lock applies to the pair (address IP, login), and to address IP alone when it accumulates a large number of failed attempts, across all logins. In a classroom, all workstations often connect via the same address, IP: a student who enters the wrong password should not lock out their neighbors;
  • lines 54–57: one failed attempt counts toward both keys;
  • lines 66–68 and 70–73: the number of accounts created per hour from the same address, IP, is limited;
  • lines 84–98: counting. An expired window resets to zero; when the threshold is reached, the key is blocked;
  • lines 101–110: clearing old counters to prevent keys from accumulating indefinitely in memory;
  • lines 113–118: generation of keys for [Map].

11.12.9. src/auth/registration-pass.service.ts

// =========================================================================
// auth/registration-pass.service.ts
// -------------------------------------------------------------------------
// The "pass" for account creation.
//
// To open the "Create a patient account" page, you must first have
// typed 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 (time to fill in the form, even when
//     trying several times after input errors);
//   - consumed as soon as an account is created: ONE solved captcha = ONE account at
//     most. A bot cannot create accounts in bulk.
//
// Without a valid pass, GET and POST /register send back to the
// authentication page: typing the /register URL directly does not
// get around the captcha.
//
// Like the captcha, the passes live in the memory of the process.
// =========================================================================

import { Injectable } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { CookieOptions } from 'express';

export const COOKIE_REGISTRATION_PASS = 'rdvmedecins_registration';

const LIFETIME_MS = 15 * 60 * 1000;
const MAX_PASSES = 10_000;

@Injectable()
export class RegistrationPassService {
  // id -> expiry date
  private readonly passes = new Map<string, number>();

  // issues a new pass; returns its identifier
  issue(): string {
    this.purge();
    const id = randomUUID();
    this.passes.set(id, Date.now() + LIFETIME_MS);
    return id;
  }

  // true if [id] is an existing, non-expired pass
  isValid(id: unknown): boolean {
    if (typeof id !== 'string') {
      return false;
    }
    const expires = this.passes.get(id);
    return expires !== undefined && expires > Date.now();
  }

  // the pass has been used: we forget it
  consume(id: unknown): void {
    if (typeof id === 'string') {
      this.passes.delete(id);
    }
  }

  cookieOptions(): CookieOptions {
    return { httpOnly: true, sameSite: 'lax', maxAge: LIFETIME_MS, path: '/register' };
  }

  private purge(): void {
    const now = Date.now();
    for (const [id, expires] of this.passes) {
      if (expires <= now || this.passes.size >= MAX_PASSES) {
        this.passes.delete(id);
      }
    }
  }
}

Creating a patient account is available from the login page, provided the CAPTCHA has been solved. Therefore, a request (the registration form) must know that a previous request solved the CAPTCHA. This is the role of the pass:

  • lines 38–43: a random identifier, valid for 15 minutes, stored in memory; it will be saved in a cookie;
  • lines 46–52: the registration form is displayed and accepted only with a valid pass;
  • lines 55–59: Creating an account consumes the pass: a solved CAPTCHA entitles the user to one account, and no more;
  • lines 61–63: The option [path: '/register'] restricts the sending of the cookie to URLs that begin with [/register].

11.12.10. src/auth/auth.controller.ts

// =========================================================================
// auth/auth.controller.ts
// -------------------------------------------------------------------------
// The NON-protected login routes:
//   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
//
// The JSON server used a Passport "local" guard for POST /login:
// on failure, it answered 401 directly, which suits a
// JavaScript client. An HTML page, however, must DISPLAY the form AGAIN
// with an error message: so we call AuthService ourselves.
//
// Protection against bots, in this order (see captcha.service.ts and
// attempt-limiter.service.ts):
//   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, the password is checked.
// The password of an attempt refused at steps 1 or 2 is NEVER checked:
// a bot can therefore learn nothing by insisting.
// =========================================================================

import { Body, Controller, Get, HttpCode, Post, Query, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { AuthService } from './auth.service';
import { CaptchaService } from './captcha.service';
import { AttemptLimiterService } from './attempt-limiter.service';
import { COOKIE_JWT, jwtCookieOptions } from './auth.constants';
import { Role } from '../entities/user.entity';

// the home page of each role, right after login
export function homePage(role: Role): string {
  return role === 'USER' ? '/my-area' : '/schedule';
}

@Controller()
export class AuthController {
  constructor(
    private readonly authService: AuthService,
    private readonly captchaService: CaptchaService,
    private readonly limiter: AttemptLimiterService,
  ) {}

  @Get()
  home(@Req() req: Request, @Res() res: Response): void {
    const token = this.authService.readToken(req.cookies?.[COOKIE_JWT]);
    res.redirect(token ? homePage(token.role) : '/login');
  }

  // GET /login[?login=xxx]: the optional parameter pre-fills the login
  // field (used right after an account is created, see RegistrationController)
  @Get('login')
  showLogin(@Req() req: Request, @Res() res: Response, @Query('login') login?: string): void {
    // already logged in (token still valid): straight to the home page
    const token = this.authService.readToken(req.cookies?.[COOKIE_JWT]);
    if (token !== null) {
      return res.redirect(homePage(token.role));
    }
    this.showForm(res, typeof login === 'string' ? login.slice(0, 30) : '');
  }

  // @HttpCode(200): by default, NestJS answers 201 (Created) to a POST - which
  // makes no sense when the form is DISPLAYED AGAIN (a redirect, for its part,
  // imposes its own code anyway, 302).
  @Post('login')
  @HttpCode(200)
  async logIn(
    @Body() body: { login?: string; password?: string; captchaId?: string; captcha?: string },
    @Req() req: Request,
    @Res() res: Response,
  ): Promise<void> {
    const login = (body.login ?? '').trim();
    const password = body.password ?? '';
    const ip = req.ip ?? 'unknown';

    // 1. too many recent failures?
    const minutes = this.limiter.loginBlockMinutes(ip, login);
    if (minutes > 0) {
      return this.showForm(res, login, 'ERRORS.TOO_MANY_ATTEMPTS', { minutes: String(minutes) });
    }

    // 2. the captcha
    if (!this.captchaService.verify(body.captchaId, body.captcha)) {
      this.limiter.loginFailure(ip, login);
      return this.showForm(res, login, 'ERRORS.WRONG_CAPTCHA');
    }

    // 3. the password
    const user = login && password ? await this.authService.validateUser(login, password) : null;
    if (user === null) {
      this.limiter.loginFailure(ip, login);
      return this.showForm(res, login, 'LOGIN.ERROR');
    }

    this.limiter.loginSuccess(ip, login);
    res.cookie(COOKIE_JWT, this.authService.createToken(user), jwtCookieOptions());
    res.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, auth.constants.ts)
  @Post('logout')
  logOut(@Res() res: Response): void {
    res.clearCookie(COOKIE_JWT, { path: '/' });
    res.redirect('/login');
  }

  // the form, always with a NEW captcha (each image is used only once)
  private showForm(
    res: Response,
    login: string,
    error?: string,
    errorParams?: Record<string, string>,
  ): void {
    res.render('login', {
      pageTitle: 'LOGIN.TITLE',
      login, // the entered login is displayed again, never the password
      captcha: this.captchaService.generate(),
      error,
      errorParams,
    });
  }
}

Let’s comment on this code:

  • lines 34–36: The home page depends on the user’s role: “My area” for patients, the calendar for everyone else;
  • lines 46–50: The site root redirects depending on whether the user is logged in or not. Since the route is not protected by the guard, the token is verified by [readToken];
  • lines 54–62: A user who is already logged in is sent to their home page. The parameter [login] pre-fills the field (after an account is created). [slice(0, 30)] limits its length: data from the request is never used without precautions;
  • lines 69–73: the login process, in order: potential lockout, CAPTCHA, then password (see Example 27). [req.ip] is the client’s IP address. If successful: counter reset to zero, cookie set, redirection to the role’s home page;
  • lines 104–108: logout;
  • lines 111–116: the login page, always with a new CAPTCHA. The error is a translation key, along with its parameters (the number of minutes of lockout).

11.12.11. src/auth/auth.module.ts

// =========================================================================
// auth/auth.module.ts
// -------------------------------------------------------------------------
// The authentication module. It EXPORTS:
//   - UserRepository, which the business layer needs (create, update,
//     delete the account of a doctor or a client - see business.service.ts);
//   - AuthService, CaptchaService, AttemptLimiterService and
//     RegistrationPassService, also used by the "Create an account" pages
//     and "Manage my account" (src/web/).
// =========================================================================

import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../entities/user.entity';
import { UserRepository } from '../repositories/user.repository';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
import { CaptchaService } from './captcha.service';
import { AttemptLimiterService } from './attempt-limiter.service';
import { RegistrationPassService } from './registration-pass.service';
import { JWT_EXPIRES_IN_SECONDS, JWT_SECRET } from './auth.constants';

@Module({
  imports: [
    TypeOrmModule.forFeature([User]),
    PassportModule,
    JwtModule.register({
      secret: JWT_SECRET,
      signOptions: { expiresIn: JWT_EXPIRES_IN_SECONDS },
    }),
  ],
  controllers: [AuthController],
  providers: [
    AuthService,
    UserRepository,
    JwtStrategy,
    CaptchaService,
    AttemptLimiterService,
    RegistrationPassService,
  ],
  exports: [UserRepository, AuthService, CaptchaService, AttemptLimiterService, RegistrationPassService],
})
export class AuthModule {}
  • line 28: the repository for the [User] entity;
  • lines 30–33: the configuration for [JwtService];
  • line 44: the services exported to the root module (see Example 06): [UserRepository] for the business layer (create a doctor’s or client’s account), the others for the web layer controllers (account creation, account management by the patient).

11.13. The Web Layer: Common Services

Before we look at the controllers, here’s what they have in common: translation, the context for each page, the interceptor, the exception filter, the list cache, and the helpers.

11.13.1. src/web/i18n.ts

// =========================================================================
// web/i18n.ts
// -------------------------------------------------------------------------
// French / English translation of the interface.
//
// The Angular client loaded its dictionaries (public/i18n/fr.json and
// en.json) in the BROWSER with @ngx-translate. Here, the pages are
// built on the SERVER: these are therefore the same dictionaries (same
// keys, completed), but read only once by the server at startup.
// A Handlebars view translates a key with the helper {{t 'KEY'}}
// (see views/handlebars-helpers.ts), the equivalent of the `| translate` pipe.
// =========================================================================

import { readFileSync } from 'fs';
import { join } from 'path';
import { I18N_DIR } from '../config/paths';

export type Language = 'fr' | 'en';
export const LANGUAGES: Language[] = ['fr', 'en'];
export const DEFAULT_LANGUAGE: Language = 'en';

// a dictionary is a nested JSON object: { "LOGIN": { "TITLE": "Login" } }
type Dictionary = { [key: string]: string | Dictionary };

const dictionaries: Record<Language, Dictionary> = {
  fr: JSON.parse(readFileSync(join(I18N_DIR, 'fr.json'), 'utf-8')),
  en: JSON.parse(readFileSync(join(I18N_DIR, 'en.json'), 'utf-8')),
};

export function isLanguage(value: unknown): value is Language {
  return typeof value === 'string' && (LANGUAGES as string[]).includes(value);
}

// -------------------------------------------------------------------------
// translate('en', 'DOCTORS.ADDED', { name: 'Marie PELISSIER' })
//   -> looks up DOCTORS then ADDED in the English dictionary, 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 shown
//      (an omission in the dictionaries is thus spotted immediately).
// -------------------------------------------------------------------------
export function translate(language: Language, key: string, params: Record<string, unknown> = {}): string {
  const text = lookup(dictionaries[language], key) ?? lookup(dictionaries[DEFAULT_LANGUAGE], key) ?? key;
  return text.replace(/\{(\w+)\}/g, (whole, name: string) => (name in params ? String(params[name]) : whole));
}

function lookup(dictionary: Dictionary, key: string): string | null {
  let node: string | Dictionary | undefined = dictionary;
  for (const part of key.split('.')) {
    if (typeof node !== 'object' || node === null) {
      return null;
    }
    node = node[part];
  }
  return typeof node === 'string' ? node : null;
}

The translation module from Example 22, with one clarification (line 42): if a key is missing from the French dictionary, it is looked up in the English dictionary (the default language); if it is missing from both, the key itself is displayed.

11.13.2. src/web/view-context.ts

// =========================================================================
// web/view-context.ts
// -------------------------------------------------------------------------
// What ALL the pages need to know, whatever the route
// that displays them: the interface language, the possible "flash" message to
// display, and the logged-in user (for the navigation bar).
//
// Express offers [res.locals] for that: an object attached to the response, whose
// content is AUTOMATICALLY merged with the model of each view at the
// moment of res.render(...). The controllers therefore do not have to repeat this
// information in each of their models.
//
// Reminder: the Angular client kept this information in the browser
// (signals + localStorage). An HTML server, however, must find it again on
// EACH request - here in cookies.
// =========================================================================

import { NextFunction, Request, Response } from 'express';
import { Language, DEFAULT_LANGUAGE, isLanguage } from './i18n';
import { ConnectedUser } from '../auth/jwt.strategy';

export const COOKIE_LANGUAGE = 'rdvmedecins_language';
export const COOKIE_FLASH = 'rdvmedecins_flash';

// a message to display ONCE, on the next page
export interface FlashMessage {
  type: 'success' | 'danger'; // Bootstrap classes alert-success / alert-danger
  key: string; // translation key
  params?: Record<string, string>;
}

// the logged-in user, as the views see them
export interface ViewUser {
  login: string;
  name: string;
  role: string;
  isAdmin: boolean; // ADMIN: Doctors / Clients menus
  isPatient: boolean; // USER: My area menu
}

// -------------------------------------------------------------------------
// Express middleware, run BEFORE any route (see main.ts):
//   - res.locals.lang  : chosen language (cookie), English by default;
//   - res.locals.flash : the possible flash message, which is cleared
//                        at once (it must be displayed only once);
//   - res.locals.currentPath: the requested URL (active menu of the navigation bar).
// -------------------------------------------------------------------------
export function viewContextMiddleware(req: Request, res: Response, next: NextFunction): void {
  res.locals.lang = languageOf(req);
  res.locals.currentPath = req.path;
  res.locals.flash = null;
  res.locals.currentUser = null;

  const raw = req.cookies?.[COOKIE_FLASH];
  if (raw) {
    try {
      res.locals.flash = JSON.parse(raw) as FlashMessage;
    } catch {
      // unreadable cookie: ignored
    }
    res.clearCookie(COOKIE_FLASH);
  }
  next();
}

export function languageOf(req: Request): Language {
  const value = req.cookies?.[COOKIE_LANGUAGE];
  return isLanguage(value) ? value : DEFAULT_LANGUAGE;
}

// -------------------------------------------------------------------------
// sets a flash message, which will be displayed by 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 redirect: a short-lived
// cookie does the job.
// -------------------------------------------------------------------------
export function flash(res: Response, message: FlashMessage): void {
  res.cookie(COOKIE_FLASH, JSON.stringify(message), { httpOnly: true, sameSite: 'lax', maxAge: 60_000 });
}

// -------------------------------------------------------------------------
// exposes the logged-in user (request.user, set by JwtStrategy) to the
// views. Called by ViewUserInterceptor (normal routes) and by
// HtmlExceptionFilter (error pages).
// -------------------------------------------------------------------------
export function exposeUser(req: Request, res: Response): void {
  const user = (req as Request & { user?: ConnectedUser }).user;
  if (!user) {
    return;
  }
  const currentUser: ViewUser = {
    login: user.login,
    name: user.name,
    role: user.role,
    isAdmin: user.role === 'ADMIN',
    isPatient: user.role === 'USER',
  };
  res.locals.currentUser = currentUser;
}

Let’s comment on this code:

  • lines 22–23: the names of the language and flash message cookies;
  • lines 26–30: a flash message: a type (success or error), a translation key, and its parameters. It is stored in the cookie as JSON;
  • lines 33–39: the logged-in user, as seen by the views. The booleans [isAdmin] and [isPatient] prevent the views from comparing strings;
  • lines 48–64: the middleware executed before any route (see examples 20 and 22): the language, the path (for the active menu), and the flash message, which is read and then cleared. [JSON.parse] is protected by a [try]: a manually modified cookie should not cause the page to fail;
  • lines 66–69: the language, based on its cookie;
  • lines 80–82: displays a flash message (see example 20);
  • Lines 89–102: Copies the logged-in user ([req.user]) to [res.locals] for the navigation bar. This function is not called by the middleware: the middleware runs before the guards, and therefore before [req.user] exists (see Chapter 7). It is called by the following interceptor and by the exception filter.

11.13.3. src/web/view-user.interceptor.ts

// =========================================================================
// web/view-user.interceptor.ts
// -------------------------------------------------------------------------
// Why an interceptor and not simply the middleware of
// view-context.ts? Because the execution ORDER matters:
//
//     middlewares -> guards (JwtAuthGuard...) -> interceptors -> controller
//
// It is the JwtAuthGuard guard that sets request.user: a middleware runs
// too early to see it. An interceptor, however, runs AFTER the guards and
// BEFORE the controller - the right moment to copy the logged-in user
// into res.locals, where all the views will find it.
// =========================================================================

import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Request, Response } from 'express';
import { Observable } from 'rxjs';
import { exposeUser } from './view-context';

@Injectable()
export class ViewUserInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const http = context.switchToHttp();
    exposeUser(http.getRequest<Request>(), http.getResponse<Response>());
    return next.handle();
  }
}

Lines 22–26: An interceptor runs after the guards and before the action: this is the right time to expose the user passed by [JwtAuthGuard]. It has nothing to do after the action: it returns [next.handle()] directly.

11.13.4. src/web/html-exception.filter.ts

// =========================================================================
// web/html-exception.filter.ts
// -------------------------------------------------------------------------
// A NestJS "exception filter" receives every exception that was not
// handled by the controllers' code. By default, NestJS answers in JSON
// ({"statusCode":403,"message":...}) - perfect for the JSON server, but
// unreadable in a browser. Instead, this filter builds a response
// suited to an HTML application:
//
//   401 (not logged in, or token expired) -> redirect to /login
//   403 (insufficient role)               -> error page "access forbidden"
//   404 (unknown page)                    -> error page "page not found"
//   400 (invalid parameter)               -> error page "invalid request"
//   everything else                       -> error page 500
//
// @Catch() without argument: the filter catches ALL exceptions. It is
// registered once for the whole application in main.ts.
// =========================================================================

import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { Request, Response } from 'express';
import { COOKIE_JWT } from '../auth/auth.constants';
import { exposeUser, flash } from './view-context';

const MESSAGES: Record<number, string> = {
  [HttpStatus.BAD_REQUEST]: 'ERRORS.BAD_REQUEST',
  [HttpStatus.FORBIDDEN]: 'ERRORS.FORBIDDEN',
  [HttpStatus.NOT_FOUND]: 'ERRORS.NOT_FOUND',
};

@Catch()
export class HtmlExceptionFilter implements ExceptionFilter {
  private readonly logger = new Logger(HtmlExceptionFilter.name);

  catch(exception: unknown, host: ArgumentsHost): void {
    const http = host.switchToHttp();
    const req = http.getRequest<Request>();
    const res = http.getResponse<Response>();

    const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;

    if (status === HttpStatus.UNAUTHORIZED) {
      // a cookie was present but is no longer valid: the session has expired
      if (req.cookies?.[COOKIE_JWT]) {
        res.clearCookie(COOKIE_JWT, { path: '/' });
        flash(res, { type: 'danger', key: 'ERRORS.SESSION_EXPIRED' });
      }
      res.redirect('/login');
      return;
    }

    if (status >= 500) {
      // a real anomaly: we keep the full trace in the server
      // console, without ever displaying it to the user
      this.logger.error(exception instanceof Error ? exception.stack : String(exception));
    }

    // the error page keeps the navigation bar of the logged-in user
    // (if any - a 403 occurs AFTER authentication)
    exposeUser(req, res);
    res.status(status).render('error', {
      pageTitle: 'ERROR_PAGE.TITLE',
      code: status,
      message: MESSAGES[status] ?? 'ERRORS.SERVER_ERROR',
    });
  }
}
  • lines 25–29: the message key for each error code;
  • lines 42–50: a 401 redirects to the login page. If a token cookie was present, it has expired (or the account has been deleted): it is deleted, and the user is notified via a pop-up message;
  • lines 52–56: A genuine error is logged in the server console, along with its call stack, but is never disclosed to the user: these details would assist an attacker;
  • lines 60–65: the error page, with the logged-in user’s navigation bar.

11.13.5. src/web/application-model.service.ts

// =========================================================================
// web/application-model.service.ts
// -------------------------------------------------------------------------
// The cache / facade already present in the JSON server (equivalent of the
// [ApplicationModel] class of the Spring project): the lists of doctors and
// of clients are read once at startup, then served from memory.
//
// New: these lists can now CHANGE (the ADMIN adds,
// edits, deletes doctors and clients). A cache that would
// never be updated would display stale data: each write
// therefore goes through this service, which reloads the list concerned right after.
// That is the whole point of having a SINGLE entry point to the business
// layer: the rule "write => cache reload" is written only here.
// =========================================================================

import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { PersonData, IBusiness, BUSINESS_TOKEN } from '../business/business.interface';
import { Client } from '../entities/client.entity';
import { Doctor } from '../entities/doctor.entity';
import { Slot } from '../entities/slot.entity';
import { Appointment } from '../entities/appointment.entity';
import { DoctorDaySchedule } from '../domain/doctor-day-schedule';

@Injectable()
export class ApplicationModelService implements IBusiness, OnModuleInit {
  private doctors: Doctor[] = [];
  private clients: Client[] = [];

  constructor(@Inject(BUSINESS_TOKEN) private readonly business: IBusiness) {}

  // if the database is unreachable at startup, the exception stops the server
  // with an explicit message - clearer, for an HTML application, than
  // starting "empty" and displaying empty lists.
  async onModuleInit(): Promise<void> {
    await this.reloadDoctors();
    await this.reloadClients();
  }

  private async reloadDoctors(): Promise<void> {
    this.doctors = await this.business.getAllDoctors();
  }

  private async reloadClients(): Promise<void> {
    this.clients = await this.business.getAllClients();
  }

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

  async getAllDoctors(): Promise<Doctor[]> {
    return this.doctors;
  }

  async getAllClients(): Promise<Client[]> {
    return this.clients;
  }

  // the doctor whose user account is [userId] - used to preselect,
  // for a logged-in doctor, the doctor's own schedule
  getDoctorByUserId(userId: number): Doctor | null {
    return this.doctors.find((d) => d.userId === userId) ?? null;
  }

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

  getAllSlots(doctorId: number): Promise<Slot[]> {
    return this.business.getAllSlots(doctorId);
  }

  getDoctorDayAppointments(doctorId: number, day: string): Promise<Appointment[]> {
    return this.business.getDoctorDayAppointments(doctorId, day);
  }

  getClientById(id: number): Promise<Client | null> {
    return this.business.getClientById(id);
  }

  getDoctorById(id: number): Promise<Doctor | null> {
    return this.business.getDoctorById(id);
  }

  getAppointmentById(id: number): Promise<Appointment | null> {
    return this.business.getAppointmentById(id);
  }

  getSlotById(id: number): Promise<Slot | null> {
    return this.business.getSlotById(id);
  }

  getDoctorDaySchedule(doctorId: number, day: string): Promise<DoctorDaySchedule | null> {
    return this.business.getDoctorDaySchedule(doctorId, day);
  }

  // no cache here: the most recent data is what matters (a patient
  // who has just updated or deleted their account)
  getClientByUserId(userId: number): Promise<Client | null> {
    return this.business.getClientByUserId(userId);
  }

  getClientUpcomingAppointments(clientId: number): Promise<Appointment[]> {
    return this.business.getClientUpcomingAppointments(clientId);
  }

  addAppointment(day: string, slot: Slot, client: Client): Promise<Appointment> {
    return this.business.addAppointment(day, slot, client);
  }

  deleteAppointment(appointment: Appointment): Promise<void> {
    return this.business.deleteAppointment(appointment);
  }

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

  async addDoctor(data: PersonData): Promise<Doctor> {
    const doctor = await this.business.addDoctor(data);
    await this.reloadDoctors();
    return doctor;
  }

  async editDoctor(id: number, data: PersonData): Promise<Doctor> {
    const doctor = await this.business.editDoctor(id, data);
    await this.reloadDoctors();
    return doctor;
  }

  async deleteDoctor(id: number): Promise<void> {
    await this.business.deleteDoctor(id);
    await this.reloadDoctors();
  }

  async addClient(data: PersonData): Promise<Client> {
    const client = await this.business.addClient(data);
    await this.reloadClients();
    return client;
  }

  async editClient(id: number, data: PersonData): Promise<Client> {
    const client = await this.business.editClient(id, data);
    await this.reloadClients();
    return client;
  }

  async deleteClient(id: number): Promise<void> {
    await this.business.deleteClient(id);
    await this.reloadClients();
  }
}

Let’s comment on this code:

  • line 25: The service has the same interface as the business layer: for the controllers, it is the business layer. It sits between the two to store in memory (application scope, see Chapter 6) the lists of doctors and clients, which are displayed on almost every page;
  • line 29: the business layer is injected via its token;
  • lines 34–37: [onModuleInit] is called by [NestJS] once the module is built, before the server starts: the lists are read at startup. If the database is inaccessible, the application terminates with a clear message;
  • lines 49–55: The lists are served from memory;
  • lines 59–61: a doctor’s account: a logged-in doctor immediately sees their own schedule;
  • lines 65–109: other requests are forwarded as-is;
  • lines 113–145: any write operation involving doctors or clients refreshes the relevant list. Since this service is the sole entry point to the business layer, the rule “write = cache refresh” is implemented only here, and the cache cannot become stale.

11.13.6. src/web/views/handlebars-helpers.ts

// =========================================================================
// web/views/handlebars-helpers.ts
// -------------------------------------------------------------------------
// Handlebars is deliberately a LOGIC-LESS template language: no
// JavaScript expressions in the views, only {{variables}},
// {{#if}} / {{#each}} blocks... When a view needs a little more, we
// give it a "helper": a TypeScript function, callable from the view.
//
// The application needs only four helpers:
//   {{t 'KEY'}}                      translation (the equivalent of the `| translate` pipe)
//   {{t 'KEY' name=person.lastName}} translation with parameter(s)
//   {{#if (eq a b)}}                 equality comparison
//   {{#if (or a b)}}                 true if at least one of the arguments is true
//   {{active currentPath '/doctors'}} "active" if the current page is under this path
// =========================================================================

import { HelperOptions } from 'handlebars';
import { Language, DEFAULT_LANGUAGE, isLanguage, translate } from '../i18n';

// the 'hbs' module has no type declarations: we describe here the little
// we use of it
interface Hbs {
  registerHelper(name: string, fn: (...args: any[]) => unknown): void;
}

export function registerHelpers(hbs: Hbs): void {
  // The last argument received by a helper is always the Handlebars [options]
  // object: options.hash contains the named parameters (name=...), and
  // options.data.root the FULL model of the page - hence also res.locals.lang,
  // the chosen language (see view-context.ts), even inside an {{#each}}.
  hbs.registerHelper('t', (key: unknown, options: HelperOptions) => {
    const lang = options.data?.root?.lang;
    const language: Language = isLanguage(lang) ? lang : DEFAULT_LANGUAGE;
    // the returned text is then escaped by Handlebars ({{ }} and not {{{ }}}):
    // a name entered by the user therefore cannot inject HTML
    return typeof key === 'string' ? translate(language, key, options.hash) : '';
  });

  hbs.registerHelper('eq', (a: unknown, b: unknown) => a === b);

  // ...args: all the arguments; the last one is the Handlebars [options]
  // object (see above), which we set aside before testing the others
  hbs.registerHelper('or', (...args: unknown[]) => args.slice(0, -1).some(Boolean));

  hbs.registerHelper('active', (path: unknown, prefix: unknown) =>
    typeof path === 'string' && typeof prefix === 'string' && path.startsWith(prefix) ? 'active' : '',
  );
}
  • lines 31–37: the translation helper (see Example 22);
  • line 39: the equality check (see Example 16);
  • line 43: true if any of the arguments is true. [...args] receives all the arguments; the last one is the [options] object from [Handlebars], which is discarded;
  • lines 45–47: the [active] class of the current menu (see Example 16).

11.13.7. src/web/models/person-form.dto.ts

// =========================================================================
// web/models/person-form.dto.ts
// -------------------------------------------------------------------------
// The fields of the "person" forms and their constraints. Three
// forms share the same identity fields (title, first name, last name):
//
//   PersonFieldsDto                 title, firstName, lastName
//    ├─ PersonFormDto               + OPTIONAL login / password, version
//    │                                (the ADMIN adds or edits a doctor / client)
//    ├─ RegistrationFormDto         + REQUIRED login / password / confirmation
//    │                                (a patient creates their account)
//    └─ MyAccountFormDto            + login, optional new password,
//                                     CURRENT password required, version
//                                     (a patient edits their account)
//
// Class inheritance also applies to class-validator decorators:
// a child class inherits the constraints of the fields of its parent class.
//
// The error messages are translation KEYS (see i18n/*.json).
// @Transform(...) (class-transformer) cleans the input BEFORE the
// validation: extra spaces removed, optional fields left empty
// turned into `undefined`, login put in lower case, version converted to a number
// (everything that comes from an HTML form is a character string).
//
// The "password = confirmation" check involves TWO fields: it
// is done by the controller, after the validation of each field.
// =========================================================================

import { Transform } from 'class-transformer';
import { IsIn, IsInt, IsNotEmpty, IsOptional, Matches, MaxLength, MinLength } from 'class-validator';

export const TITLES = ['Mr', 'Mme', 'Melle'];

const clean = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value);
const emptyToUndefined = ({ value }: { value: unknown }) =>
  typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
const cleanLogin = ({ value }: { value: unknown }) => emptyToUndefined({ value })?.toLowerCase();
// a password is not "cleaned" (spaces may be part of it)
const passwordOrUndefined = ({ value }: { value: unknown }) =>
  typeof value === 'string' && value !== '' ? value : undefined;
const numberOrUndefined = ({ value }: { value: unknown }) =>
  value === undefined || value === '' ? undefined : Number(value);

// login: lower-case letters, digits, dot, hyphen, underscore - 30 characters at most
const LOGIN_FORMAT = /^[a-z0-9._-]{1,30}$/;
// minimum length of a password CHOSEN by a patient
export const PASSWORD_MIN = 6;

// ------------------------------------------------------------------ common base
export class PersonFieldsDto {
  @IsIn(TITLES, { message: 'ERRORS.INVALID_TITLE' })
  title: string;

  // maximum lengths = those of the SQL columns (VARCHAR(20))
  @Transform(clean)
  @IsNotEmpty({ message: 'ERRORS.FIRSTNAME_REQUIRED' })
  @MaxLength(20, { message: 'ERRORS.FIRSTNAME_TOO_LONG' })
  firstName: string;

  @Transform(clean)
  @IsNotEmpty({ message: 'ERRORS.LASTNAME_REQUIRED' })
  @MaxLength(20, { message: 'ERRORS.LASTNAME_TOO_LONG' })
  lastName: string;
}

// ------------------------------------------------------------------ ADMIN
export class PersonFormDto extends PersonFieldsDto {
  // optional (see PersonData, business.interface.ts)
  @Transform(cleanLogin)
  @IsOptional()
  @Matches(LOGIN_FORMAT, { message: 'ERRORS.INVALID_LOGIN' })
  login?: string;

  // optional; 72 characters at most (beyond that, bcrypt ignores the rest)
  @Transform(passwordOrUndefined)
  @IsOptional()
  @MaxLength(72, { message: 'ERRORS.PASSWORD_TOO_LONG' })
  password?: string;

  // hidden field of the edit form (optimistic lock)
  @Transform(numberOrUndefined)
  @IsOptional()
  @IsInt({ message: 'ERRORS.INVALID_FORM' })
  version?: number;
}

// ------------------------------------------------------------------ registration of a patient
export class RegistrationFormDto extends PersonFieldsDto {
  @Transform(cleanLogin)
  @IsNotEmpty({ message: 'ERRORS.LOGIN_REQUIRED' })
  @Matches(LOGIN_FORMAT, { message: 'ERRORS.INVALID_LOGIN' })
  login: string;

  @Transform(passwordOrUndefined)
  @IsNotEmpty({ message: 'ERRORS.PASSWORD_REQUIRED' })
  @MinLength(PASSWORD_MIN, { message: 'ERRORS.PASSWORD_TOO_SHORT' })
  @MaxLength(72, { message: 'ERRORS.PASSWORD_TOO_LONG' })
  password: string;

  @Transform(passwordOrUndefined)
  @IsNotEmpty({ message: 'ERRORS.CONFIRMATION_REQUIRED' })
  confirmation: string;
}

// ------------------------------------------------------------------ "Manage my account"
export class MyAccountFormDto extends PersonFieldsDto {
  @Transform(cleanLogin)
  @IsNotEmpty({ message: 'ERRORS.LOGIN_REQUIRED' })
  @Matches(LOGIN_FORMAT, { message: 'ERRORS.INVALID_LOGIN' })
  login: string;

  // optional: empty = password unchanged
  @Transform(passwordOrUndefined)
  @IsOptional()
  @MinLength(PASSWORD_MIN, { message: 'ERRORS.PASSWORD_TOO_SHORT' })
  @MaxLength(72, { message: 'ERRORS.PASSWORD_TOO_LONG' })
  password?: string;

  @Transform(passwordOrUndefined)
  @IsOptional()
  confirmation?: string;

  // the CURRENT password, required for any change to the account
  @Transform(passwordOrUndefined)
  @IsNotEmpty({ message: 'ERRORS.CURRENT_PASSWORD_REQUIRED' })
  currentPassword: string;

  @Transform(numberOrUndefined)
  @IsOptional()
  @IsInt({ message: 'ERRORS.INVALID_FORM' })
  version?: number;
}

Let’s comment on this code:

  • line 32: possible titles;
  • lines 34–42: the cleaning functions applied by [@Transform] before validation: removal of spaces, empty fields reduced to [undefined] (so that [@IsOptional] will accept them), login in lowercase, version converted to a number. A password is not sanitized: its spaces are part of it;
  • line 45: the format of a username, described by a regular expression;
  • lines 50–64: the identity fields, common to three forms. The maximum lengths are those of the SQL columns. The messages are translation keys;
  • lines 67–85: the ADMIN form (doctor or client): optional username and password, and the version for optimistic locking. [extends]: the class inherits the fields and decorators from its parent. A password must be 72 characters or fewer; bcrypt ignores any characters beyond that limit;
  • lines 88–103: a patient creates an account: username and password required, at least six characters, and confirmation;
  • lines 106–132: a patient modifying their account: new password optional, current password required.

11.13.8. src/web/helpers/form.helper.ts

// =========================================================================
// web/helpers/form.helper.ts
// -------------------------------------------------------------------------
// Validation of an HTML form with class-validator.
//
// The JSON server left validation to the global ValidationPipe: on
// error, it answered 400 Bad Request itself - which suits a
// JavaScript client, which then displays the error its own way. For an HTML
// page, on the contrary, we want to DISPLAY the form AGAIN, with the values
// already entered and a message under each invalid field. So we call
// class-validator ourselves, to keep control over what happens next.
// =========================================================================

import { ClassConstructor, plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { TITLES } from '../models/person-form.dto';

// errors by field: { lastName: 'ERRORS.LASTNAME_REQUIRED', ... } - each
// message is a translation KEY (see the decorators of the DTOs)
export type FormErrors = Record<string, string>;

export interface ValidationResult<T> {
  dto: T; // the data, converted and cleaned (see the @Transform of the DTOs)
  errors: FormErrors | null; // null = valid form
}

export async function validateForm<T extends object>(
  cls: ClassConstructor<T>,
  body: Record<string, unknown>,
): Promise<ValidationResult<T>> {
  // plainToInstance: turns the plain object received ({ lastName: ' dupont ' })
  // into an instance of the DTO class, applying its @Transform(...)
  const dto = plainToInstance(cls, body);
  // validate: checks all the constraints (@IsNotEmpty, @MaxLength...);
  // forbidUnknownValues:false because we validate an object we built
  const violations = await validate(dto, { forbidUnknownValues: false });
  if (violations.length === 0) {
    return { dto, errors: null };
  }
  const errors: FormErrors = {};
  for (const violation of violations) {
    // we keep only the first message of each field
    errors[violation.property] = Object.values(violation.constraints ?? {})[0] ?? 'ERRORS.INVALID_FORM';
  }
  return { dto, errors };
}

// -------------------------------------------------------------------------
// the form fields concerned by each "input" business error
// (a duplicate): they are framed in red, the message being at the bottom of the page
// -------------------------------------------------------------------------
export const BUSINESS_ERROR_FIELDS: Record<string, string[]> = {
  'ERRORS.LOGIN_TAKEN': ['login'],
  'ERRORS.DOCTOR_ALREADY_EXISTS': ['firstName', 'lastName'],
  'ERRORS.PATIENT_ALREADY_EXISTS': ['firstName', 'lastName'],
};

// { firstName: true, lastName: true }: the fields to frame in red (is-invalid) -
// those that have a validation message, plus possible others
export function invalidFields(errors: FormErrors, others: string[] = []): Record<string, boolean> {
  const fields: Record<string, boolean> = {};
  for (const field of [...Object.keys(errors), ...others]) {
    fields[field] = true;
  }
  return fields;
}

// the options of the "Title" drop-down list, the one of [value] selected
export function titleOptions(value: string): { value: string; selected: boolean }[] {
  return TITLES.map((t) => ({ value: t, selected: t === value }));
}
  • lines 27–30: Form validation, as in Example 19. The option [forbidUnknownValues: false] is required because we are validating an object generated by [plainToInstance], not one received from [ValidationPipe];
  • lines 52–56: certain business errors relate to specific fields (e.g., a username that is already taken): these fields will be highlighted in red, with the message appearing at the bottom of the page;
  • lines 60–66: the fields to be highlighted in red;
  • lines 69–71: the options in the list of titles, with the selected one highlighted (see example 18).

11.13.9. src/web/helpers/dates.helper.ts

// =========================================================================
// web/helpers/dates.helper.ts
// -------------------------------------------------------------------------
// Small functions on dates in the "yyyy-MM-dd" format (that of the
// appointments.day column and of the <input type="date"> field of the forms).
// =========================================================================

// today's date: defined in the business layer, which needs it for
// its business rules - we simply re-export it here
export { today } from '../../business/dates';

// true if [day] is a date that exists, in the yyyy-MM-dd format
// (same check as the JSON server: "2026-02-30" is refused)
export function isValidDate(day: unknown): day is string {
  if (typeof day !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(day)) {
    return false;
  }
  const date = new Date(`${day}T00:00:00Z`);
  return !isNaN(date.getTime()) && date.toISOString().slice(0, 10) === day;
}

// "8:05" rather than "8:5"
export function formatTime(h: number, m: number): string {
  return `${h}:${twoDigits(m)}`;
}

// "mercredi 23 septembre 2026" / "Wednesday 23 September 2026", depending on the language
// (Intl.DateTimeFormat: the date formatting API built into JavaScript)
export function formatDay(day: string, language: string): string {
  return new Intl.DateTimeFormat(language === 'en' ? 'en-GB' : 'fr-FR', {
    dateStyle: 'full',
    timeZone: 'UTC', // "2026-09-23T00:00:00Z" must remain the 23rd, whatever the server's time zone
  }).format(new Date(`${day}T00:00:00Z`));
}

function twoDigits(n: number): string {
  return n.toString().padStart(2, '0');
}
  • Line 10: The current date is defined in the business layer; this module re-exports it: controllers import all date functions from a single location;
  • lines 14–20: a [yyyy-mm-dd] date that exists (see Example 12);
  • lines 29–34: a date spelled out in full, depending on the language. The [timeZone: 'UTC'] option prevents a one-day offset when the server is not set to Greenwich Mean Time.

11.13.10. src/web/helpers/patient.helper.ts

// =========================================================================
// web/helpers/patient.helper.ts
// -------------------------------------------------------------------------
// The patient (Client entity) matching the logged-in user.
//
// The JWT token contains only the id of the ACCOUNT (users.id). For a USER role,
// the matching client is found through the clients.user column.
//
// If there is none, the account has been deleted since login
// (by the patient themselves in another tab, or by the ADMIN) while
// the token is still valid: we treat it as an expired session
// (UnauthorizedException -> back to the login page, see
// HtmlExceptionFilter).
// =========================================================================

import { UnauthorizedException } from '@nestjs/common';
import { ApplicationModelService } from '../application-model.service';
import { ConnectedUser } from '../../auth/jwt.strategy';
import { Client } from '../../entities/client.entity';
import { Person } from '../../entities/person.entity';

// "Mme Alice DURAND"
export function label(p: Person): string {
  return `${p.title} ${p.firstName} ${p.lastName}`;
}

export async function connectedPatient(
  application: ApplicationModelService,
  user: ConnectedUser,
): Promise<Client> {
  const client = await application.getClientByUserId(user.userId);
  if (client === null) {
    throw new UnauthorizedException();
  }
  return client;
}
  • lines 23–25: “Mme Alice DURAND”;
  • lines 27–30: the logged-in patient’s record, retrieved from their account. If it no longer exists (account deleted in another tab, or by the ADMIN, even though the token is still valid), a 401 error is returned: redirect to the login page.

11.14. The web layer: the controllers

11.14.1. src/web/controllers/language.controller.ts

// =========================================================================
// web/controllers/language.controller.ts
// -------------------------------------------------------------------------
// GET /language/fr, GET /language/en: the two FR / EN buttons of the
// navigation bar. The choice is stored in a cookie (the Angular client
// kept it in localStorage), then we go back to the page we came from.
// Unprotected route: the language must be changeable before logging in.
// =========================================================================

import { Controller, Get, NotFoundException, Param, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { isLanguage } from '../i18n';
import { COOKIE_LANGUAGE } from '../view-context';

const ONE_YEAR = 365 * 24 * 3600 * 1000;

@Controller('language')
export class LanguageController {
  @Get(':language')
  change(@Param('language') language: string, @Req() req: Request, @Res() res: Response): void {
    if (!isLanguage(language)) {
      throw new NotFoundException();
    }
    res.cookie(COOKIE_LANGUAGE, language, { maxAge: ONE_YEAR, sameSite: 'lax', httpOnly: true });
    res.redirect(originPage(req));
  }
}

// the previous page, according to the HTTP "Referer" header sent by the
// browser. Only its path is kept (/schedule?...): redirecting to
// a full address supplied by the request would allow sending
// the user to another site (so-called "open redirect" flaw).
function originPage(req: Request): string {
  const referer = req.get('referer');
  if (!referer) {
    return '/';
  }
  try {
    const url = new URL(referer);
    return url.pathname.startsWith('/language/') ? '/' : url.pathname + url.search;
  } catch {
    return '/';
  }
}

The controller from Example 22. Note: if the original page is itself a [/language/...] page (line 40), the request is redirected to the site’s root to prevent an infinite loop.

11.14.2. src/web/controllers/schedule.controller.ts

// =========================================================================
// web/controllers/schedule.controller.ts
// -------------------------------------------------------------------------
// The schedule page: choice of a doctor and a day, schedule of that
// doctor on that day, booking and cancelling appointments.
// It is the server-side equivalent of the Angular components
// <app-doctor-day-picker>, <app-agenda> and <app-booking-dialog>.
//
//   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: you can reload it, bookmark it,
// use the browser's "Back" button - without a single line
// of JavaScript.
//
// What each user sees and can do:
//
//   | role          | occupied 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 one       |         |
//
//   (*) 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 put 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 reveal it otherwise).
//
// No booking in the past: the "Book" button only appears
// for today and the following days (and the business layer would refuse it
// anyway).
// =========================================================================

import { BadRequestException, Body, Controller, Get, Post, Query, Render, Req, Res, UseGuards } from '@nestjs/common';
import { Request, Response } from 'express';
import { ApplicationModelService } from '../application-model.service';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { RolesGuard } from '../../auth/roles.guard';
import { Roles } from '../../auth/roles.decorator';
import { ConnectedUser } from '../../auth/jwt.strategy';
import { BusinessException } from '../../business/business.exception';
import { flash } from '../view-context';
import { today, isValidDate, formatTime } from '../helpers/dates.helper';
import { label, connectedPatient } from '../helpers/patient.helper';
import { Client } from '../../entities/client.entity';
import { Person } from '../../entities/person.entity';

type AuthenticatedRequest = Request & { user: ConnectedUser };

// ----------------------------------------------------------------- view model
// (what views/schedule.hbs displays - only data already formatted
// AND already filtered according to the role: the view decides nothing)

interface OptionView {
  id: number;
  label: string; // "Mme Marie PELISSIER"
  selected: boolean;
}

interface ScheduleRowView {
  slotId: number;
  time: string; // "8:00 - 8:20"
  free: boolean;
  patient: string | null; // name displayed for an occupied slot; null = "Booked"
  mine: boolean; // the appointment of the logged-in patient
  bookable: boolean; // display the "Book" button?
  bookUrl: string;
  appointmentId: number | null; // set only if the "Cancel" button is displayed
}

interface ScheduleView {
  doctor: string;
  day: string;
  rows: ScheduleRowView[];
}

interface BookingView {
  slotId: number;
  time: string;
  // ADMIN / DOCTOR: the list of patients to choose from
  clients: OptionView[];
  // USER: the patient is the user themselves
  patient: string | null;
}

export interface ScheduleModel {
  pageTitle: string;
  doctors: OptionView[];
  doctorId: number | null;
  day: string;
  schedule: ScheduleView | null;
  booking: BookingView | null;
  backUrl: string; // the displayed schedule, without the booking dialog
  // patient who already has an upcoming appointment: the message that explains
  // why there is no "Book" button
  info: string | null;
  error: string | null;
}

// what the controller knows about the user, to decide what to display
interface Rights {
  manager: boolean; // ADMIN or DOCTOR
  patient: Client | null; // the logged-in client (USER role), null otherwise
  patientCanBook: boolean; // USER without an upcoming appointment
}

@UseGuards(JwtAuthGuard, RolesGuard)
@Controller('schedule')
export class ScheduleController {
  constructor(private readonly application: ApplicationModelService) {}

  // -----------------------------------------------------------------------
  // GET /schedule
  // @Render('schedule'): the object returned by the method becomes the model of
  // the view views/schedule.hbs (+ the content of res.locals, see view-context.ts).
  // -----------------------------------------------------------------------
  @Get()
  @Render('schedule')
  async show(
    @Req() req: AuthenticatedRequest,
    @Query('doctorId') rawDoctorId?: string,
    @Query('day') rawDay?: string,
    @Query('book') rawBook?: string,
  ): Promise<ScheduleModel> {
    let error: string | null = null;
    const rights = await this.rights(req.user);

    // the day: the one in the URL if it is valid, today otherwise
    let day = today();
    if (rawDay !== undefined) {
      if (isValidDate(rawDay)) {
        day = rawDay;
      } else {
        error = 'ERRORS.INVALID_DATE';
      }
    }
    const dayBookable = day >= today();

    // the doctor: the one in the URL; failing that, for a logged-in doctor, the doctor themselves
    let doctorId = positiveInteger(rawDoctorId);
    if (rawDoctorId === undefined && req.user.role === 'DOCTOR') {
      doctorId = this.application.getDoctorByUserId(req.user.userId)?.id ?? null;
    }

    const doctors = (await this.application.getAllDoctors()).map((d) => option(d, d.id === doctorId));

    // the schedule, if a doctor is chosen (and the date is valid)
    let schedule: ScheduleView | null = null;
    if (doctorId !== null && error === null) {
      const doctorSchedule = await this.application.getDoctorDaySchedule(doctorId, day);
      if (doctorSchedule === null) {
        error = 'ERRORS.DOCTOR_NOT_FOUND';
      } else {
        schedule = {
          doctor: label(doctorSchedule.doctor),
          day,
          rows: doctorSchedule.doctorDaySlots.map(({ slot, appointment }) => {
            const free = appointment === null;
            // the appointment of the logged-in patient?
            const mine = !free && rights.patient !== null && appointment.clientId === rights.patient.id;
            return {
              slotId: slot.id,
              time: `${formatTime(slot.startHour, slot.startMinute)} - ${formatTime(slot.endHour, slot.endMinute)}`,
              free,
              // THE confidentiality point: a patient receives the name only
              // of THEIR appointment; for the others, null (the view displays "Booked")
              patient: free ? null : rights.manager || mine ? label(appointment.client) : null,
              mine,
              bookable: free && dayBookable && (rights.manager || rights.patientCanBook),
              bookUrl: scheduleUrl(doctorId as number, day, slot.id),
              appointmentId: !free && rights.manager ? appointment.id : null,
            };
          }),
        };
      }
    }

    // the booking dialog: only if the requested slot is
    // bookable by this user
    let booking: BookingView | null = null;
    const slotId = positiveInteger(rawBook);
    const row = schedule?.rows.find((r) => r.slotId === slotId && r.bookable);
    if (row) {
      booking = {
        slotId: row.slotId,
        time: row.time,
        clients: rights.manager ? (await this.application.getAllClients()).map((c) => option(c, false)) : [],
        patient: rights.patient ? label(rights.patient) : null,
      };
    }

    return {
      pageTitle: 'SCHEDULE.PAGE_TITLE',
      doctors,
      doctorId,
      day,
      schedule,
      booking,
      backUrl: doctorId !== null ? scheduleUrl(doctorId, day) : '/schedule',
      info: rights.patient !== null && !rights.patientCanBook ? 'SCHEDULE.ALREADY_BOOKED_INFO' : null,
      error,
    };
  }

  // -----------------------------------------------------------------------
  // 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 let a clever patient book in someone else's name.
  //
  // "Post / Redirect / Get" pattern: whatever the result, we do not
  // build a page here - we set a flash message and redirect
  // to the schedule (see flash(), view-context.ts).
  // -----------------------------------------------------------------------
  @Post('book')
  @Roles('ADMIN', 'DOCTOR', 'USER')
  async book(
    @Body() body: Record<string, string>,
    @Req() req: AuthenticatedRequest,
    @Res() res: Response,
  ): Promise<void> {
    const { doctorId, day } = readScheduleRequest(body);
    const slotId = positiveInteger(body.slotId);
    if (slotId === null) {
      throw new BadRequestException();
    }

    let client: Client | null;
    if (req.user.role === 'USER') {
      client = await connectedPatient(this.application, req.user);
    } else {
      const clientId = positiveInteger(body.clientId);
      if (clientId === null) {
        // no patient chosen: the booking dialog is opened again
        flash(res, { type: 'danger', key: 'ERRORS.CHOOSE_PATIENT' });
        return res.redirect(scheduleUrl(doctorId, day, slotId));
      }
      client = await this.application.getClientById(clientId);
    }

    const slot = await this.application.getSlotById(slotId);
    if (client === null) {
      flash(res, { type: 'danger', key: 'ERRORS.PATIENT_NOT_FOUND' });
    } else if (slot === null || slot.doctorId !== doctorId) {
      flash(res, { type: 'danger', key: 'ERRORS.SLOT_NOT_FOUND' });
    } else {
      try {
        // the rules (not in the past, only one upcoming appointment per
        // patient, free slot) are checked by the business layer
        await this.application.addAppointment(day, slot, client);
        flash(res, { type: 'success', key: 'SCHEDULE.BOOKED' });
      } catch (e) {
        // same rule, but a message in the 2nd person when it is the patient
        // themselves who books ("You already have..." rather than "This patient...")
        if (
          req.user.role === 'USER' &&
          e instanceof BusinessException &&
          e.key === 'ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT'
        ) {
          flash(res, { type: 'danger', key: 'SCHEDULE.ALREADY_BOOKED_INFO' });
        } else {
          flashBusinessError(res, e);
        }
      }
    }
    res.redirect(scheduleUrl(doctorId, day));
  }

  // -----------------------------------------------------------------------
  // POST /schedule/cancel   fields: appointmentId, doctorId, day
  // (a patient cancels THEIR appointment from "My area", not from here)
  // -----------------------------------------------------------------------
  @Post('cancel')
  @Roles('ADMIN', 'DOCTOR')
  async cancel(@Body() body: Record<string, string>, @Res() res: Response): Promise<void> {
    const { doctorId, day } = readScheduleRequest(body);
    const appointmentId = positiveInteger(body.appointmentId);
    if (appointmentId === null) {
      throw new BadRequestException();
    }
    const appointment = await this.application.getAppointmentById(appointmentId);
    if (appointment === null) {
      // already cancelled (by someone else, or double click)
      flash(res, { type: 'danger', key: 'ERRORS.APPOINTMENT_NOT_FOUND' });
    } else {
      await this.application.deleteAppointment(appointment);
      flash(res, { type: 'success', key: 'SCHEDULE.CANCELLED' });
    }
    res.redirect(scheduleUrl(doctorId, day));
  }

  // -----------------------------------------------------------------------
  // who is logged in, and what are they allowed to do on this page?
  // -----------------------------------------------------------------------
  private async rights(user: ConnectedUser): Promise<Rights> {
    if (user.role !== 'USER') {
      return { manager: true, patient: null, patientCanBook: false };
    }
    const patient = await connectedPatient(this.application, user);
    const upcoming = await this.application.getClientUpcomingAppointments(patient.id);
    return { manager: false, patient, patientCanBook: upcoming.length === 0 };
  }
}

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

// "3" -> 3 ; "", "abc", "-2", undefined -> null
function positiveInteger(value: unknown): number | null {
  const n = Number(value);
  return typeof value === 'string' && value !== '' && Number.isInteger(n) && n > 0 ? n : null;
}

// the hidden doctorId / day fields, common to both POST forms
function readScheduleRequest(body: Record<string, string>): { doctorId: number; day: string } {
  const doctorId = positiveInteger(body.doctorId);
  if (doctorId === null || !isValidDate(body.day)) {
    throw new BadRequestException(); // -> "invalid request" page (see HtmlExceptionFilter)
  }
  return { doctorId, day: body.day };
}

function scheduleUrl(doctorId: number, day: string, book?: number): string {
  const params = new URLSearchParams({ doctorId: String(doctorId), day });
  if (book !== undefined) {
    params.set('book', String(book));
  }
  return `/schedule?${params.toString()}`;
}

function option(p: Person, selected: boolean): OptionView {
  return { id: p.id, label: label(p), selected };
}

// a BusinessException becomes a flash message; any other error is a
// real anomaly, which we let bubble up to HtmlExceptionFilter
function flashBusinessError(res: Response, e: unknown): void {
  if (e instanceof BusinessException) {
    flash(res, { type: 'danger', key: e.key, params: e.params });
    return;
  }
  throw e;
}

The application’s main page. Let’s walk through this code section by section.

11.14.2.1. The URL section of the page

The comments on lines 9–14 describe the routes. Note that the booking window is not opened by [JavaScript]: it is a parameter of URL ([book=7]) that instructs the server to display 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.

11.14.2.2. The view template

  • line 56: the type of request for a protected route: an [Express] request whose [user] field was set by [JwtAuthGuard];
  • lines 62–106: The view template is typed. The view receives only data that has already been formatted ([time: "8:00 - 8:20"]) and filtered according to the user’s role; it does not make any decisions. In [ScheduleRowView] (lines 68–77), [patient] is equal to [null] when the user does not have permission to view the patient’s name; [bookable] and [appointmentId] determine whether the “Book” and “Cancel” buttons should be displayed;
  • lines 109–113: what the controller knows about the logged-in user.

11.14.2.3. GET /schedule

  • lines 115–116: All routes require a logged-in user. Without [@Roles] on the class, all roles have access to the page;
  • lines 127–132: the three parameters of URL, all optional, received as strings and converted later;
  • lines 137–145: the requested date, if valid; otherwise, today. Appointments can only be booked starting today;
  • lines 148–151: the requested doctor; otherwise, a logged-in doctor sees their own schedule;
  • line 153: the drop-down list of doctors, retrieved from the cache;
  • lines 157–184: the calendar, calculated by the business layer, is converted into table rows. Line 175 is the application’s privacy checkpoint: a patient receives only the name of their own appointment. For others, 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;
  • lines 188–198: the booking window opens only if the requested time slot is bookable by that user. An administrator selects the patient from a list; a patient books for themselves;
  • lines 200–210: The template [info] explains to a patient who already has an appointment why they do not see a “Book” button.

11.14.2.4. POST /schedule/book

  • line 225: all three roles can make a reservation;
  • line 231: the [doctorId] and [day] fields—which are hidden in the form—allow the user to return to the displayed calendar;
  • lines 238–239: For a patient, the field [clientId] is ignored: the patient always books for themselves. Relying on this field, which is sent by the browser, would allow a crafty patient to book on behalf of someone else;
  • Lines 242–246: An administrator who has not selected a patient is returned to the booking window, along with a message;
  • lines 251–254: The time slot must exist and belong to the doctor in the calendar;
  • lines 256–261: The business logic rules are verified by the business layer. For a patient, the message “This patient already has an upcoming appointment” is replaced by a message addressed to the patient directly;
  • Line 275: PRG pattern: Regardless of the result, display a pop-up message and redirect to the calendar.

11.14.2.5. POST /schedule/cancel

Lines 282–299: Reserved for administrators; a patient cancels their appointment from “My area.” An appointment that has already been canceled (by double-clicking or by another administrator) returns an error message, not an error page.

11.14.2.6. Permissions and Utility Functions

  • Lines 304–311: An administrator has full permissions; a patient can make a reservation if they have no upcoming appointments;
  • lines 317–320: safe conversion of a parameter to an identifier;
  • lines 323–329: Invalid hidden fields can only come from a manually crafted request: error 400;
  • lines 331–337: [URLSearchParams] constructs a properly encoded query string;
  • lines 345–351: a [BusinessException] becomes a flash message; any other exception is propagated up to the exception filter.

11.14.3. src/web/controllers/persons.controller.ts

// =========================================================================
// web/controllers/persons.controller.ts
// -------------------------------------------------------------------------
// The management of doctors and that of clients (reserved for the ADMIN) are
// IDENTICAL: same list, same form, same rules. Rather than
// writing the same routes twice, we write them once in this
// ABSTRACT class, inherited by DoctorsController (@Controller('doctors'))
// and ClientsController (@Controller('clients')). Each subclass only
// has to provide its "configuration": which methods to call, which
// labels to display.
//
// The route decorators (@Get, @Post...) set HERE are inherited: NestJS
// walks the whole prototype chain of the controller class. We
// therefore get, for each of the two subclasses:
//
//   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 shared too:
// views/persons/list.hbs and views/persons/form.hbs.
// =========================================================================

import { Body, Get, HttpCode, NotFoundException, Param, ParseIntPipe, Post, Render, Res } from '@nestjs/common';
import { Response } from 'express';
import { ApplicationModelService } from '../application-model.service';
import { PersonData } from '../../business/business.interface';
import { BusinessException } from '../../business/business.exception';
import { Person } from '../../entities/person.entity';
import { flash } from '../view-context';
import {
  BUSINESS_ERROR_FIELDS,
  FormErrors,
  invalidFields,
  titleOptions,
  validateForm,
} from '../helpers/form.helper';
import { PersonFormDto } from '../models/person-form.dto';

// the translation keys specific to each type of person
export interface PersonKeys {
  title: string; // "Doctors"
  add: string; // "Add a doctor"
  edit: string; // "Edit a doctor"
  empty: string; // "No doctors."
  confirmDelete: string; // "Delete this doctor and their account?"
  added: string; // "Doctor {name} has been added (login: {login})."
  edited: string;
  deleted: string;
}

export interface PersonsConfig<T extends Person> {
  url: string; // '/doctors' or '/clients'
  keys: PersonKeys;
  list(): Promise<T[]>;
  find(id: number): Promise<T | null>;
  add(data: PersonData): Promise<T>;
  edit(id: number, data: PersonData): Promise<T>;
  delete(id: number): Promise<void>;
}

// the values displayed in the fields of the form
interface FormValues {
  title: string;
  firstName: string;
  lastName: string;
  login: string;
  version: number | null;
}

export abstract class PersonsController<T extends Person> {
  protected abstract readonly config: PersonsConfig<T>;

  constructor(protected readonly application: ApplicationModelService) {}

  // -----------------------------------------------------------------------
  // GET /doctors (or /clients)
  // -----------------------------------------------------------------------
  @Get()
  @Render('persons/list')
  async list() {
    const persons = await this.config.list();
    return {
      pageTitle: this.config.keys.title,
      keys: this.config.keys,
      addUrl: `${this.config.url}/add`,
      persons: persons.map((p) => ({
        title: p.title,
        firstName: p.firstName,
        lastName: p.lastName,
        login: p.user?.login ?? '',
        editUrl: `${this.config.url}/${p.id}/edit`,
        deleteUrl: `${this.config.url}/${p.id}/delete`,
      })),
    };
  }

  // -----------------------------------------------------------------------
  // GET /doctors/add
  // -----------------------------------------------------------------------
  @Get('add')
  addForm(@Res() res: Response): void {
    this.showForm(res, null, { title: 'Mr', firstName: '', lastName: '', login: '', version: null });
  }

  // -----------------------------------------------------------------------
  // POST /doctors/add
  // -----------------------------------------------------------------------
  @Post('add')
  @HttpCode(200) // if the form is displayed again (see auth.controller.ts)
  async add(@Body() body: Record<string, unknown>, @Res() res: Response): Promise<void> {
    const { dto, errors } = await validateForm(PersonFormDto, body);
    if (errors !== null) {
      return this.showForm(res, null, enteredValues(dto), errors);
    }
    try {
      const person = await this.config.add(dto);
      flash(res, { type: 'success', key: this.config.keys.added, params: messageParams(person) });
      res.redirect(this.config.url);
    } catch (e) {
      this.showBusinessError(res, null, enteredValues(dto), e);
    }
  }

  // -----------------------------------------------------------------------
  // GET /doctors/:id/edit
  // ParseIntPipe: if :id is not an integer, NestJS throws a
  // BadRequestException ("invalid request" page, see HtmlExceptionFilter)
  // -----------------------------------------------------------------------
  @Get(':id/edit')
  async editForm(@Param('id', ParseIntPipe) id: number, @Res() res: Response): Promise<void> {
    const person = await this.config.find(id);
    if (person === null) {
      throw new NotFoundException();
    }
    this.showForm(res, id, {
      title: person.title,
      firstName: person.firstName,
      lastName: person.lastName,
      login: person.user?.login ?? '',
      version: person.version,
    });
  }

  // -----------------------------------------------------------------------
  // POST /doctors/:id/edit
  // -----------------------------------------------------------------------
  @Post(':id/edit')
  @HttpCode(200)
  async edit(
    @Param('id', ParseIntPipe) id: number,
    @Body() body: Record<string, unknown>,
    @Res() res: Response,
  ): Promise<void> {
    const { dto, errors } = await validateForm(PersonFormDto, body);
    if (errors !== null) {
      return this.showForm(res, id, enteredValues(dto), errors);
    }
    try {
      const person = await this.config.edit(id, dto);
      flash(res, { type: 'success', key: this.config.keys.edited, params: messageParams(person) });
      res.redirect(this.config.url);
    } catch (e) {
      this.showBusinessError(res, id, enteredValues(dto), e);
    }
  }

  // -----------------------------------------------------------------------
  // POST /doctors/:id/delete
  // (the "Are you sure?" confirmation is requested by the browser before
  // the form is sent, see public/js/app.js)
  // -----------------------------------------------------------------------
  @Post(':id/delete')
  async delete(@Param('id', ParseIntPipe) id: number, @Res() res: Response): Promise<void> {
    const person = await this.config.find(id);
    try {
      await this.config.delete(id);
      flash(res, { type: 'success', key: this.config.keys.deleted, params: messageParams(person) });
    } catch (e) {
      if (!(e instanceof BusinessException)) {
        throw e;
      }
      flash(res, { type: 'danger', key: e.key, params: e.params });
    }
    res.redirect(this.config.url);
  }

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

  // id === null: creation form; otherwise: edit form
  //   errors          : message under each field refused by validation
  //   error           : THE error message of the page, displayed at the bottom of the page
  //                     (see views/partials/errors.hbs)
  //   otherFields     : fields to frame in red WITHOUT their own message (e.g.:
  //                     first + last name of a namesake - the message is at the bottom)
  private showForm(
    res: Response,
    id: number | null,
    values: FormValues,
    errors: FormErrors = {},
    error: string | null = null,
    otherFields: string[] = [],
  ): void {
    const creating = id === null;
    res.render('persons/form', {
      pageTitle: creating ? this.config.keys.add : this.config.keys.edit,
      creating,
      action: creating ? `${this.config.url}/add` : `${this.config.url}/${id}/edit`,
      listUrl: this.config.url,
      values,
      titles: titleOptions(values.title),
      errors,
      invalidFields: invalidFields(errors, otherFields),
      error: error ?? (Object.keys(errors).length > 0 ? 'ERRORS.INVALID_FORM' : null),
    });
  }

  // a business rule refused the write: the form is displayed again,
  // with the user's input and the message of the business layer
  private showBusinessError(res: Response, id: number | null, values: FormValues, e: unknown): void {
    if (!(e instanceof BusinessException)) {
      throw e; // real anomaly -> HtmlExceptionFilter (page 500)
    }
    if (e.key === 'ERRORS.DOCTOR_NOT_FOUND' || e.key === 'ERRORS.PATIENT_NOT_FOUND') {
      // deleted in the meantime by another ADMIN: back to the list
      flash(res, { type: 'danger', key: e.key });
      return res.redirect(this.config.url);
    }
    // the message goes at the bottom of the page; the fields concerned, if any, are
    // framed in red so that the user immediately sees what to correct
    this.showForm(res, id, values, {}, e.key, BUSINESS_ERROR_FIELDS[e.key] ?? []);
  }
}

// what the user entered (password excluded: it is never sent back to the browser)
function enteredValues(dto: PersonFormDto): FormValues {
  return {
    title: dto.title ?? '',
    firstName: typeof dto.firstName === 'string' ? dto.firstName : '',
    lastName: typeof dto.lastName === 'string' ? dto.lastName : '',
    login: dto.login ?? '',
    version: dto.version ?? null,
  };
}

// {name} and {login} of the "added / updated / deleted" messages
function messageParams(person: Person | null): Record<string, string> {
  if (person === null) {
    return { name: '', login: '' };
  }
  return { name: `${person.firstName} ${person.lastName}`, login: person.user?.login ?? '' };
}

The handling of doctors and clients is identical. Rather than writing two similar controllers, we create an abstract class from which both controllers inherit.

  • lines 44–53: the texts specific to each type of person (translation keys);
  • lines 55–63: the configuration provided by each subclass: the base URL, the texts, and the functions to call for listing, searching, adding, modifying, and deleting;
  • line 75: an abstract field: each subclass must define it;
  • lines 82–99: the list. Route decorators applied to a parent class are inherited: [NestJS] finds them by traversing the prototype chain. The model contains, for each person, the URL for their buttons;
  • lines 104–107 and 112–126: the addition, in three steps, as in Example 28: validation, business layer, PRG;
  • lines 133–146 and 151–157: modification. [ParseIntPipe] rejects a non-numeric identifier (error 400);
  • lines 176–189: deletion. The person is retrieved beforehand to display their name in the confirmation message;
  • lines 199–206: the form, for both creation ([id === null]) and modification;
  • lines 223–235: a person who has been deleted in the meantime is returned to the list; other business errors redisplay the form, with the relevant fields highlighted in red;
  • lines 239–247: the values to be redisplayed (never the password).

11.14.4. src/web/controllers/doctors.controller.ts

// =========================================================================
// web/controllers/doctors.controller.ts
// -------------------------------------------------------------------------
// Management of the doctors (ADMIN role only). All the routes are
// inherited from PersonsController (see persons.controller.ts): all that
// remains here is to say WHAT to call and WHICH labels to display.
//
// @Roles('ADMIN') set on the CLASS applies to all its routes,
// including the inherited ones (RolesGuard looks for the metadata on the method,
// then on the class - see auth/roles.guard.ts).
// =========================================================================

import { Controller, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { RolesGuard } from '../../auth/roles.guard';
import { Roles } from '../../auth/roles.decorator';
import { ApplicationModelService } from '../application-model.service';
import { Doctor } from '../../entities/doctor.entity';
import { PersonsConfig, PersonsController } from './persons.controller';

@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
@Controller('doctors')
export class DoctorsController extends PersonsController<Doctor> {
  protected readonly config: PersonsConfig<Doctor> = {
    url: '/doctors',
    keys: {
      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: () => this.application.getAllDoctors(),
    find: (id) => this.application.getDoctorById(id),
    add: (data) => this.application.addDoctor(data),
    edit: (id, data) => this.application.editDoctor(id, data),
    delete: (id) => this.application.deleteDoctor(id),
  };

  // an explicit constructor, so that TypeScript emits on THIS class
  // the type metadata of the parameters, which NestJS needs to
  // know what to inject (the parent class, abstract and without a decorator,
  // has none)
  constructor(application: ApplicationModelService) {
    super(application);
  }
}
  • lines 21–22: reserved for ADMIN. [@Roles] applied to the class applies to all its routes, including inherited ones;
  • lines 25–42: the configuration: the translation keys for doctors, and the cache methods to call;
  • lines 48–50: an explicit constructor, even though it merely calls the parent class’s constructor; this is what allows the compiler to generate, for this class, the type metadata that [NestJS] needs to know what to inject.

11.14.5. src/web/controllers/clients.controller.ts

// =========================================================================
// web/controllers/clients.controller.ts
// -------------------------------------------------------------------------
// Management of the clients (the PATIENTS of the practice), ADMIN role only.
// Same principle as DoctorsController (see its comments).
// =========================================================================

import { Controller, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { RolesGuard } from '../../auth/roles.guard';
import { Roles } from '../../auth/roles.decorator';
import { ApplicationModelService } from '../application-model.service';
import { Client } from '../../entities/client.entity';
import { PersonsConfig, PersonsController } from './persons.controller';

@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
@Controller('clients')
export class ClientsController extends PersonsController<Client> {
  protected readonly config: PersonsConfig<Client> = {
    url: '/clients',
    keys: {
      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: () => this.application.getAllClients(),
    find: (id) => this.application.getClientById(id),
    add: (data) => this.application.addClient(data),
    edit: (id, data) => this.application.editClient(id, data),
    delete: (id) => this.application.deleteClient(id),
  };

  constructor(application: ApplicationModelService) {
    super(application);
  }
}

The same controller, for clients.

11.14.6. src/web/controllers/registration.controller.ts

// =========================================================================
// web/controllers/registration.controller.ts
// -------------------------------------------------------------------------
// "Create a patient account": a patient who does not have an account yet
// creates it themselves. UNPROTECTED routes (one has no account yet, by
// definition):
//
//   POST /register/access     "Create a patient account" button of the
//                             authentication page: checks the captcha of
//                             THAT page, then opens the form
//   GET  /register            the form
//   POST /register            the creation, then back to the login page
//
// The flow is protected against bots 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 typed correctly, the server
//      issues a pass (see auth/registration-pass.service.ts);
//   2. without a valid pass, /register sends back to the
//      authentication page - typing the URL directly is useless;
//   3. the pass is consumed on 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/attempt-limiter.service.ts).
//
// The account created is exactly the one the ADMIN would create from the
// "Clients" page (same business layer method: addClient), except
// that the patient CHOOSES their login and password (required, with
// confirmation).
// =========================================================================

import { Body, Controller, Get, HttpCode, Post, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { ApplicationModelService } from '../application-model.service';
import { CaptchaService } from '../../auth/captcha.service';
import { AttemptLimiterService } from '../../auth/attempt-limiter.service';
import { COOKIE_REGISTRATION_PASS, RegistrationPassService } from '../../auth/registration-pass.service';
import { BusinessException } from '../../business/business.exception';
import { flash } from '../view-context';
import {
  BUSINESS_ERROR_FIELDS,
  FormErrors,
  invalidFields,
  titleOptions,
  validateForm,
} from '../helpers/form.helper';
import { RegistrationFormDto, PASSWORD_MIN } from '../models/person-form.dto';

interface RegistrationValues {
  title: string;
  firstName: string;
  lastName: string;
  login: string;
}

@Controller('register')
export class RegistrationController {
  constructor(
    private readonly application: ApplicationModelService,
    private readonly captchaService: CaptchaService,
    private readonly limiter: AttemptLimiterService,
    private readonly passes: RegistrationPassService,
  ) {}

  // -----------------------------------------------------------------------
  // POST /register/access   fields: captchaId, captcha (those of the
  // authentication page; login and password, also sent, are ignored)
  // -----------------------------------------------------------------------
  @Post('access')
  access(@Body() body: Record<string, unknown>, @Req() req: Request, @Res() res: Response): void {
    const ip = req.ip ?? 'unknown';

    // too many accounts created recently from this address: no point going further
    const minutes = this.limiter.registrationBlockMinutes(ip);
    if (minutes > 0) {
      flash(res, { type: 'danger', key: 'ERRORS.TOO_MANY_ACCOUNTS', params: { minutes: String(minutes) } });
      return res.redirect('/login');
    }

    // captcha missing or wrong: back to the authentication page, with
    // a new image (a failure counts as a login failure)
    if (!this.captchaService.verify(body.captchaId, body.captcha)) {
      this.limiter.loginFailure(ip, '');
      flash(res, { type: 'danger', key: 'ERRORS.CAPTCHA_BEFORE_ACCOUNT' });
      return res.redirect('/login');
    }

    // correct captcha: we issue the pass and open the form
    res.cookie(COOKIE_REGISTRATION_PASS, this.passes.issue(), this.passes.cookieOptions());
    res.redirect('/register');
  }

  // -----------------------------------------------------------------------
  // GET /register
  // -----------------------------------------------------------------------
  @Get()
  form(@Req() req: Request, @Res() res: Response): void {
    if (!this.hasPass(req, res)) {
      return;
    }
    this.show(res, { title: 'Mr', firstName: '', lastName: '', login: '' });
  }

  // -----------------------------------------------------------------------
  // POST /register
  // -----------------------------------------------------------------------
  @Post()
  @HttpCode(200) // if the form is displayed again (see auth.controller.ts)
  async create(@Body() body: Record<string, unknown>, @Req() req: Request, @Res() res: Response): Promise<void> {
    if (!this.hasPass(req, res)) {
      return;
    }
    const ip = req.ip ?? 'unknown';
    const { dto, errors } = await validateForm(RegistrationFormDto, body);
    const values: RegistrationValues = {
      title: dto.title ?? 'Mr',
      firstName: typeof dto.firstName === 'string' ? dto.firstName : '',
      lastName: typeof dto.lastName === 'string' ? dto.lastName : '',
      login: dto.login ?? '',
    };

    // 1. too many accounts created recently from this address?
    const minutes = this.limiter.registrationBlockMinutes(ip);
    if (minutes > 0) {
      return this.show(res, values, {}, 'ERRORS.TOO_MANY_ACCOUNTS', [], { minutes: String(minutes) });
    }

    // 2. all the input errors at once (fields + confirmation):
    //    the user corrects them all in one go
    const allErrors: FormErrors = { ...(errors ?? {}) };
    if (!allErrors.password && !allErrors.confirmation && dto.password !== dto.confirmation) {
      allErrors.confirmation = 'ERRORS.PASSWORDS_DIFFER';
    }
    if (Object.keys(allErrors).length > 0) {
      return this.show(res, values, allErrors);
    }

    // 3. the creation (client + USER account, in a transaction)
    try {
      await this.application.addClient({
        title: dto.title,
        lastName: dto.lastName,
        firstName: dto.firstName,
        login: dto.login,
        password: dto.password,
      });
    } catch (e) {
      if (!(e instanceof BusinessException)) {
        throw e;
      }
      // login or first + last name already taken: the pass remains valid
      return this.show(res, values, {}, e.key, BUSINESS_ERROR_FIELDS[e.key] ?? []);
    }

    // 4. the pass has been used: it will not allow creating a 2nd account
    this.passes.consume(req.cookies?.[COOKIE_REGISTRATION_PASS]);
    res.clearCookie(COOKIE_REGISTRATION_PASS, { path: '/register' });
    this.limiter.registrationSuccess(ip);

    // 5. back to the login page, login pre-filled
    flash(res, { type: 'success', key: 'REGISTRATION.DONE', params: { login: dto.login } });
    res.redirect(`/login?${new URLSearchParams({ login: dto.login }).toString()}`);
  }

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

  // no valid pass: back to the authentication page
  private hasPass(req: Request, res: Response): boolean {
    if (this.passes.isValid(req.cookies?.[COOKIE_REGISTRATION_PASS])) {
      return true;
    }
    flash(res, { type: 'danger', key: 'ERRORS.CAPTCHA_BEFORE_ACCOUNT' });
    res.redirect('/login');
    return false;
  }

  // the form; never any password sent back to the browser
  private show(
    res: Response,
    values: RegistrationValues,
    errors: FormErrors = {},
    error: string | null = null,
    otherFields: string[] = [],
    errorParams?: Record<string, string>,
  ): void {
    res.render('account/registration', {
      pageTitle: 'REGISTRATION.TITLE',
      values,
      titles: titleOptions(values.title),
      errors,
      invalidFields: invalidFields(errors, otherFields),
      error: error ?? (Object.keys(errors).length > 0 ? 'ERRORS.INVALID_FORM' : null),
      errorParams,
      passwordMin: PASSWORD_MIN,
    });
  }
}

Let’s comment on this code:

  • lines 69–91: The “Create a patient account” button on the login page sends the login form to this address (attribute [formaction]; see Example 20). The page’s CAPTCHA is verified; if it’s correct, a pass is stored in a cookie and the browser is redirected to the form;
  • lines 96–102: the form, but only with a valid pass;
  • lines 109–163: account creation: pass, time limit, validation (including verification that the password matches its confirmation), creation by the business layer. The pass is then consumed, and the patient is redirected to the login page with their username pre-filled;
  • lines 168–175: without a pass, return to the login page with a message;
  • lines 178–185: the form. [passwordMin] is used for the help text (“At least 6 characters”).

11.14.7. src/web/controllers/patient-area.controller.ts

// =========================================================================
// web/controllers/patient-area.controller.ts
// -------------------------------------------------------------------------
// 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 the 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 (connectedPatient), never an identifier sent by the browser.
// A patient therefore can neither see, nor edit, nor cancel what belongs
// to another patient, even by crafting their own requests.
//
// Any update 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. Current password
// errors are counted as login failures (same limit).
// =========================================================================

import { Body, Controller, Get, HttpCode, Post, Render, Req, Res, UseGuards } from '@nestjs/common';
import { Request, Response } from 'express';
import { ApplicationModelService } from '../application-model.service';
import { AuthService } from '../../auth/auth.service';
import { AttemptLimiterService } from '../../auth/attempt-limiter.service';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { RolesGuard } from '../../auth/roles.guard';
import { Roles } from '../../auth/roles.decorator';
import { ConnectedUser } from '../../auth/jwt.strategy';
import { COOKIE_JWT, jwtCookieOptions } from '../../auth/auth.constants';
import { BusinessException } from '../../business/business.exception';
import { Client } from '../../entities/client.entity';
import { flash } from '../view-context';
import { formatTime, formatDay } from '../helpers/dates.helper';
import { label, connectedPatient } from '../helpers/patient.helper';
import {
  BUSINESS_ERROR_FIELDS,
  FormErrors,
  invalidFields,
  titleOptions,
  validateForm,
} from '../helpers/form.helper';
import { MyAccountFormDto, PASSWORD_MIN } from '../models/person-form.dto';

type AuthenticatedRequest = Request & { user: ConnectedUser };

interface MyAccountValues {
  title: string;
  firstName: string;
  lastName: string;
  login: string;
  version: number | null;
}

@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('USER')
@Controller()
export class PatientAreaController {
  constructor(
    private readonly application: ApplicationModelService,
    private readonly authService: AuthService,
    private readonly limiter: AttemptLimiterService,
  ) {}

  // -----------------------------------------------------------------------
  // GET /my-area
  // @Res({ passthrough: true }): we access the response (to read the language
  // in res.locals) WITHOUT giving up the normal operation of @Render.
  // -----------------------------------------------------------------------
  @Get('my-area')
  @Render('patient/area')
  async area(@Req() req: AuthenticatedRequest, @Res({ passthrough: true }) res: Response) {
    const patient = await connectedPatient(this.application, req.user);
    const appointments = await this.application.getClientUpcomingAppointments(patient.id);
    return {
      pageTitle: 'AREA.TITLE',
      patient: label(patient),
      appointments: appointments.map((appointment) => ({
        id: appointment.id,
        day: formatDay(appointment.day, res.locals.lang),
        time: `${formatTime(appointment.slot.startHour, appointment.slot.startMinute)} - ${formatTime(appointment.slot.endHour, appointment.slot.endMinute)}`,
        doctor: label(appointment.slot.doctor),
        // link to the doctor's schedule on that day: the patient sees their name there
        scheduleUrl: `/schedule?${new URLSearchParams({ doctorId: String(appointment.slot.doctor.id), day: appointment.day })}`,
      })),
    };
  }

  // -----------------------------------------------------------------------
  // POST /my-area/cancel   field: appointmentId
  // -----------------------------------------------------------------------
  @Post('my-area/cancel')
  async cancelAppointment(
    @Body() body: Record<string, string>,
    @Req() req: AuthenticatedRequest,
    @Res() res: Response,
  ): Promise<void> {
    const patient = await connectedPatient(this.application, req.user);
    const appointment = await this.application.getAppointmentById(Number(body.appointmentId) || 0);
    // an appointment that does not exist, and one of ANOTHER patient, get
    // the same answer: nothing is revealed about other people's appointments
    if (appointment === null || appointment.clientId !== patient.id) {
      flash(res, { type: 'danger', key: 'ERRORS.APPOINTMENT_NOT_FOUND' });
    } else {
      await this.application.deleteAppointment(appointment);
      flash(res, { type: 'success', key: 'SCHEDULE.CANCELLED' });
    }
    res.redirect('/my-area');
  }

  // -----------------------------------------------------------------------
  // GET /my-account
  // -----------------------------------------------------------------------
  @Get('my-account')
  async accountForm(@Req() req: AuthenticatedRequest, @Res() res: Response): Promise<void> {
    const patient = await connectedPatient(this.application, req.user);
    this.show(res, {
      title: patient.title,
      firstName: patient.firstName,
      lastName: patient.lastName,
      login: patient.user.login,
      version: patient.version,
    });
  }

  // -----------------------------------------------------------------------
  // POST /my-account
  // -----------------------------------------------------------------------
  @Post('my-account')
  @HttpCode(200) // if the form is displayed again (see auth.controller.ts)
  async editAccount(
    @Body() body: Record<string, unknown>,
    @Req() req: AuthenticatedRequest,
    @Res() res: Response,
  ): Promise<void> {
    const patient = await connectedPatient(this.application, req.user);
    const { dto, errors } = await validateForm(MyAccountFormDto, body);
    const values: MyAccountValues = {
      title: dto.title ?? patient.title,
      firstName: typeof dto.firstName === 'string' ? dto.firstName : '',
      lastName: typeof dto.lastName === 'string' ? dto.lastName : '',
      login: dto.login ?? '',
      version: dto.version ?? null,
    };

    const allErrors: FormErrors = { ...(errors ?? {}) };
    if (!allErrors.password && dto.password !== undefined && dto.password !== dto.confirmation) {
      allErrors.confirmation = 'ERRORS.PASSWORDS_DIFFER';
    }
    if (Object.keys(allErrors).length > 0) {
      return this.show(res, values, allErrors);
    }

    // the current password
    const passwordError = await this.checkCurrentPassword(req, patient, dto.currentPassword);
    if (passwordError !== null) {
      return this.show(
        res,
        values,
        passwordError.key === 'ERRORS.WRONG_CURRENT_PASSWORD' ? { currentPassword: passwordError.key } : {},
        passwordError.key,
        [],
        passwordError.params,
      );
    }

    try {
      const client = await this.application.editClient(patient.id, {
        title: dto.title,
        lastName: dto.lastName,
        firstName: dto.firstName,
        login: dto.login,
        password: dto.password, // undefined = unchanged
        version: dto.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
      res.cookie(COOKIE_JWT, this.authService.createToken(client.user), jwtCookieOptions());
      flash(res, { type: 'success', key: 'MY_ACCOUNT.UPDATED' });
      res.redirect('/my-area');
    } catch (e) {
      if (!(e instanceof BusinessException)) {
        throw e;
      }
      this.show(res, values, {}, e.key, BUSINESS_ERROR_FIELDS[e.key] ?? []);
    }
  }

  // -----------------------------------------------------------------------
  // POST /my-account/delete   field: deletePassword
  // The account, the client and ALL their appointments disappear together,
  // then the patient is logged out.
  // -----------------------------------------------------------------------
  @Post('my-account/delete')
  async deleteAccount(
    @Body() body: Record<string, string>,
    @Req() req: AuthenticatedRequest,
    @Res() res: Response,
  ): Promise<void> {
    const patient = await connectedPatient(this.application, req.user);
    const passwordError = await this.checkCurrentPassword(req, patient, body.deletePassword);
    if (passwordError !== null) {
      flash(res, { type: 'danger', key: passwordError.key, params: passwordError.params });
      return res.redirect('/my-account');
    }
    await this.application.deleteClient(patient.id);
    res.clearCookie(COOKIE_JWT, { path: '/' });
    flash(res, { type: 'success', key: 'MY_ACCOUNT.DELETED' });
    res.redirect('/login');
  }

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

  // null if the current password is correct; otherwise the key of the message
  private async checkCurrentPassword(
    req: Request,
    patient: Client,
    password: unknown,
  ): Promise<{ key: string; params?: Record<string, string> } | null> {
    const ip = req.ip ?? 'unknown';
    const login = patient.user.login;
    const minutes = this.limiter.loginBlockMinutes(ip, login);
    if (minutes > 0) {
      return { key: 'ERRORS.TOO_MANY_ATTEMPTS', params: { minutes: String(minutes) } };
    }
    const ok =
      typeof password === 'string' &&
      password !== '' &&
      (await this.authService.validateUser(login, password)) !== null;
    if (!ok) {
      this.limiter.loginFailure(ip, login);
      return { key: 'ERRORS.WRONG_CURRENT_PASSWORD' };
    }
    return null;
  }

  private show(
    res: Response,
    values: MyAccountValues,
    errors: FormErrors = {},
    error: string | null = null,
    otherFields: string[] = [],
    errorParams?: Record<string, string>,
  ): void {
    res.render('patient/account', {
      pageTitle: 'MY_ACCOUNT.TITLE',
      values,
      titles: titleOptions(values.title),
      errors,
      invalidFields: invalidFields(errors, otherFields),
      error: error ?? (Object.keys(errors).length > 0 ? 'ERRORS.INVALID_FORM' : null),
      errorParams,
      passwordMin: PASSWORD_MIN,
    });
  }
}

Let’s comment on this code:

  • lines 59–60: reserved for patients;
  • 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 74–91: the “My area” page. [@Res({ passthrough: true })] provides access to the response (to read the language in [res.locals] and format the date) without giving up [@Render] (see Example 05);
  • lines 96–101: canceling an appointment. A nonexistent appointment and another patient’s appointment receive the same response: no information about other patients’ appointments is disclosed;
  • lines 118–128: the account form, pre-filled;
  • lines 133–139: the update: validation, verification of the current password, then the business logic layer. The token contains the login and the display name: after an update, a new token is issued (line 182); otherwise, the navigation bar would retain the old name;
  • Lines 198–203: Delete the account, the record, and all appointments (using the same business layer method as for ADMIN), then log out;
  • lines 219–223: The current password is required for any changes: someone who finds a session that has been left open would be unable to change the password or delete the account. Errors count as login failures;
  • lines 241–248: the form.

11.15. The Views

The views have almost no decisions to make: their template has been prepared by the controllers. Each view begins with a comment [Handlebars] [{{!-- ... --}}] that describes its role; these comments do not appear in the pages sent to the browser.

11.15.1. The template and partial files

11.15.1.1. views/layout.hbs

{{!--
  ==========================================================================
  views/layout.hbs
  --------------------------------------------------------------------------
  The template COMMON to all pages. hbs uses it automatically
  (file named "layout.hbs" at the root of the views folder): the page
  requested by the controller (schedule.hbs, login.hbs...) is inserted at
  the {{{body}}} spot.

  Triple braces {{{ }}}: the content is inserted AS IS, without
  escaping - essential here, since [body] is HTML already built.
  Everywhere else we use {{ }}, which escapes the characters < > & " '
  (protection against HTML injection through an entered name, for example).

  Messages: a success is displayed at the TOP of the page ({{> flash}}), an
  error at the BOTTOM, on a light pink background ({{> errors}}).

  Equivalent of src/index.html + app.html of the Angular client.
  ==========================================================================
--}}
<!doctype html>
<html lang="{{lang}}">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{t pageTitle}} - {{t 'APP.TITLE'}}</title>
  <link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css">
  <link rel="stylesheet" href="/css/styles.css">
</head>
<body>
  <div class="container py-4">
    {{> navbar}}
    {{> flash}}
    {{{body}}}
    {{> errors}}
  </div>
  {{!-- the "Are you sure?" dialog, hidden as long as it is not needed --}}
  {{> confirmation}}
  <script src="/js/app.js"></script>
</body>
</html>
  • line 22: the page’s language (see Example 22);
  • line 26: the title, translated: the key [pageTitle] is provided by each action;
  • lines 32–35: the navigation bar, the success message, the page, and the error area;
  • lines 38–39: the confirmation window and its script (see Example 21).

11.15.1.2. views/partials/navbar.hbs

{{!--
  views/partials/navbar.hbs - the navigation bar (inserted by {{> navbar}}).

  Same content as that of the Angular client (title, FR/EN, role, name,
  logout), plus a menu whose entries depend on the role:
  "Doctors" and "Clients" appear only for the ADMIN. It is only a
  display convenience: the real protection is the guards of the
  controllers (a USER who types /doctors by hand gets a 403 page).
  A patient (USER) also has their "My area" menu.
--}}
<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 currentUser}}
    <ul class="navbar-nav me-auto">
      {{#if currentUser.isPatient}}
        <li class="nav-item"><a class="nav-link {{active currentPath '/my-'}}" href="/my-area">{{t 'APP.MENU_MY_AREA'}}</a></li>
      {{/if}}
      <li class="nav-item"><a class="nav-link {{active currentPath '/schedule'}}" href="/schedule">{{t 'APP.MENU_SCHEDULE'}}</a></li>
      {{#if currentUser.isAdmin}}
        <li class="nav-item"><a class="nav-link {{active currentPath '/doctors'}}" href="/doctors">{{t 'APP.MENU_DOCTORS'}}</a></li>
        <li class="nav-item"><a class="nav-link {{active currentPath '/clients'}}" href="/clients">{{t 'APP.MENU_PATIENTS'}}</a></li>
      {{/if}}
    </ul>
  {{else}}
    <div class="me-auto"></div>
  {{/if}}

  <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 {{#if (eq lang 'fr')}}btn-light{{else}}btn-outline-light{{/if}}" href="/language/fr">FR</a>
      <a class="btn {{#if (eq lang 'en')}}btn-light{{else}}btn-outline-light{{/if}}" href="/language/en">EN</a>
    </div>

    {{#if currentUser}}
      <span class="badge text-bg-light text-primary">{{currentUser.role}}</span>
      <span class="text-white">{{currentUser.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>
    {{/if}}
  </div>
</nav>
  • lines 14–25: 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 controller guards;
  • lines 31–34: the links FR / EN;
  • lines 36–43: the role, name, and logout button in a POST form.

11.15.1.3. views/partials/flash.hbs

{{!--
  views/partials/flash.hbs - the SUCCESS message set by the previous
  action (appointment booked, doctor added...), displayed only
  once, at the top of the page (see src/web/view-context.ts).
  An ERROR flash message, for its part, is displayed at the bottom of the page, by
  views/partials/errors.hbs.
--}}
{{#if (eq flash.type 'success')}}
  <div class="alert alert-success" role="status">
    {{t flash.key name=flash.params.name login=flash.params.login minutes=flash.params.minutes}}
  </div>
{{/if}}

line 10: the message is a key; its possible parameters ([name], [login], [minutes]) are passed to the helper [t].

11.15.1.4. views/partials/errors.hbs

{{!--
  ==========================================================================
  views/partials/errors.hbs - the error zone, at the BOTTOM of each page, on
  a light pink background (.error-zone class, see public/css/styles.css).

  It groups the two possible sources of error:
    - [error]: the error of the displayed page (form refused, login or
      first + last name already used, invalid date, wrong password...);
    - an error flash message, set by the previous action before a
      redirect (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.
  ==========================================================================
--}}
{{#if (or error (eq flash.type 'danger'))}}
  <div class="error-zone" role="alert">
    {{#if error}}
      <div>{{t error minutes=errorParams.minutes}}</div>
    {{/if}}
    {{#if (eq flash.type 'danger')}}
      <div>{{t flash.key name=flash.params.name login=flash.params.login minutes=flash.params.minutes}}</div>
    {{/if}}
  </div>
{{/if}}

Line 16: The area is displayed if there is a page error or a flash error message: the helper [or] combines both conditions, with [eq] used as a sub-expression.

11.15.1.5. views/partials/confirmation.hbs

{{!--
  ==========================================================================
  views/partials/confirmation.hbs - the "Are you sure?" modal dialog.

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

  ONE 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 public/js/app.js script merely shows it, puts in it
  the message specific to the form (data-confirmation attribute), and submits
  the form if the user confirms.

  Same Bootstrap classes as the booking dialog (partials/booking.hbs).
  ==========================================================================
--}}
<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 --}}
          <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 translated labels and accessibility attributes: [role="alertdialog"], and [aria-labelledby] / [aria-describedby], which specify the title and message read by a screen reader.

11.15.1.6. views/partials/captcha.hbs

{{!--
  ==========================================================================
  views/partials/captcha.hbs - the captcha of the authentication page
  (see src/auth/captcha.service.ts). It protects both the login and
  access to account creation ("Create a patient account" button).

  {{{captcha.svg}}}: triple braces, the SVG image is inserted AS IS
  in the page. It is safe here because this SVG is built by the
  server itself (svg-captcha library) and contains nothing that comes
  from the user - we would NEVER do that 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).
  ==========================================================================
--}}
<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'}}">{{{captcha.svg}}}</div>
    <a class="small" href="{{currentPath}}">{{t 'CAPTCHA.NEW_IMAGE'}}</a>
  </div>
  <input type="hidden" name="captchaId" value="{{captcha.id}}">
  <input id="captcha" name="captcha" type="text" maxlength="10" autocomplete="off" autocapitalize="off" spellcheck="false" required
         class="form-control {{#if invalidFields.captcha}}is-invalid{{/if}}">
  {{#if errors.captcha}}
    <div class="invalid-feedback">{{t errors.captcha}}</div>
  {{else}}
    <div class="form-text">{{t 'CAPTCHA.HELP'}}</div>
  {{/if}}
</div>
  • line 20: the image SVG, inserted as-is (triple curly braces): it is generated by the server, with no user input;
  • line 21: “Another image” simply reloads the page;
  • line 23: the CAPTCHA ID, in a hidden field.

11.15.1.7. views/partials/personFields.hbs

{{!--
  views/partials/personFields.hbs - 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 uses the model of the page that inserts it: values, titles,
  errors (message under the field) and invalidFields (red frame).
--}}
<div class="mb-3">
  <label class="form-label" for="title">{{t 'PERSONS.TITLE_LABEL'}}</label>
  <select id="title" name="title" class="form-select {{#if invalidFields.title}}is-invalid{{/if}}">
    {{#each titles}}
      <option value="{{value}}" {{#if selected}}selected{{/if}}>{{value}}</option>
    {{/each}}
  </select>
  {{#if errors.title}}<div class="invalid-feedback">{{t errors.title}}</div>{{/if}}
</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 {{#if invalidFields.firstName}}is-invalid{{/if}}" value="{{values.firstName}}">
    {{#if errors.firstName}}<div class="invalid-feedback">{{t errors.firstName}}</div>{{/if}}
  </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 {{#if invalidFields.lastName}}is-invalid{{/if}}" value="{{values.lastName}}">
    {{#if errors.lastName}}<div class="invalid-feedback">{{t errors.lastName}}</div>{{/if}}
  </div>
</div>

The identity fields, common to three forms (see Example 19). A partial inserted without parameters uses the template of the page that inserts it: [values], [titles], [errors], [invalidFields].

11.15.1.8. views/partials/booking.hbs

{{!--
  ==========================================================================
  views/partials/booking.hbs - 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 Bootstrap's
  JavaScript. 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.
  ==========================================================================
--}}
<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="{{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="{{doctorId}}">
        <input type="hidden" name="day" value="{{day}}">
        <input type="hidden" name="slotId" value="{{booking.slotId}}">

        {{#if booking.patient}}
          {{!-- 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>
            {{#each booking.clients}}
              <option value="{{id}}">{{label}}</option>
            {{/each}}
          </select>
        {{/if}}
      </div>

      <div class="modal-footer">
        <a class="btn btn-secondary" href="{{backUrl}}">{{t 'BOOKING_DIALOG.CANCEL'}}</a>
        <button type="submit" class="btn btn-primary">{{t 'BOOKING_DIALOG.CONFIRM'}}</button>
      </div>
    </form>
  </div>
</div>
  • lines 13–14: the [Bootstrap] classes of a modal window, made visible “hard-coded”: the server decides to display it;
  • line 19: closing the window means following a link to the same page, without the [book] parameter;
  • lines 26–28: the form’s hidden fields;
  • lines 30–34: a patient sees their name; an administrator selects the patient from a list.

11.15.2. The pages

11.15.2.1. views/login.hbs

{{!--
  ==========================================================================
  views/login.hbs - the login form.
  Equivalent of features/login/ of the Angular client. 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 entered, a NEW captcha and [error], displayed
  at the bottom of the page by the layout.
  ==========================================================================
--}}
<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="{{login}}" required {{#unless login}}autofocus{{/unless}}>
        </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 {{#if login}}autofocus{{/if}}>
        </div>

        {{> 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 20: the cursor is placed in the login field, or in the password field if the login is already known;
  • line 28: the CAPTCHA;
  • lines 41–42: the second button submits the same form to [/register/access] ([formaction]). [formnovalidate]: the browser does not require a username and password, as they are not needed to create an account.

11.15.2.2. views/schedule.hbs

{{!--
  ==========================================================================
  views/schedule.hbs - 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/partials/errors.hbs) --}}

{{!-- 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>
        {{#each doctors}}
          <option value="{{id}}" {{#if selected}}selected{{/if}}>{{label}}</option>
        {{/each}}
      </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="{{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 info}}
  <div class="alert alert-info">
    {{t info}} <a href="/my-area" class="alert-link">{{t 'APP.MENU_MY_AREA'}}</a>
  </div>
{{/if}}

{{!-- 2. the schedule --}}
{{#if schedule}}
  <div class="card p-3">
    <h5>{{t 'SCHEDULE.TITLE_PREFIX'}} {{schedule.day}} - {{schedule.doctor}}</h5>

    {{#if schedule.rows.length}}
      <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>
          {{#each schedule.rows}}
            <tr {{#if bookable}}class="free-slot"{{/if}}>
              <td>{{time}}</td>
              <td>
                {{!-- [patient] is set only if the user is allowed
                     to see this name (ADMIN, DOCTOR, or the patient for THEIR
                     appointment) - otherwise, simply "Booked" --}}
                {{#if free}}
                  <span class="badge text-bg-success">{{t 'SCHEDULE.FREE'}}</span>
                {{else if patient}}
                  <span class="badge {{#if mine}}text-bg-primary{{else}}text-bg-secondary{{/if}}">{{patient}}</span>
                {{else}}
                  <span class="badge text-bg-secondary">{{t 'SCHEDULE.BOOKED_BY_OTHER'}}</span>
                {{/if}}
              </td>
              <td>
                {{!-- the buttons displayed were decided by the controller
                     (bookable, appointmentId) according to the user's role --}}
                {{#if 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="{{bookUrl}}">{{t 'SCHEDULE.BOOK'}}</a>
                {{/if}}
                {{#if appointmentId}}
                  {{!-- "Cancel" CHANGES data: so it is a POST.
                       @root: the model of the PAGE (inside an {{#each}},
                       plain names refer to the fields of the current row) --}}
                  <form method="post" action="/schedule/cancel" class="m-0"
                        data-confirmation="{{t 'SCHEDULE.CANCEL_CONFIRM'}}">
                    <input type="hidden" name="appointmentId" value="{{appointmentId}}">
                    <input type="hidden" name="doctorId" value="{{@root.doctorId}}">
                    <input type="hidden" name="day" value="{{@root.day}}">
                    <button type="submit" class="btn btn-sm btn-outline-danger">{{t 'SCHEDULE.CANCEL_APPOINTMENT'}}</button>
                  </form>
                {{/if}}
              </td>
            </tr>
          {{/each}}
        </tbody>
      </table>
    {{else}}
      <p class="text-muted mb-0">{{t 'SCHEDULE.NO_SLOTS'}}</p>
    {{/if}}
  </div>
{{else}}
  <p class="text-muted">{{t 'SCHEDULE.EMPTY_STATE'}}</p>
{{/if}}

{{!-- 3. the booking dialog, only if the URL contains &book=... --}}
{{#if booking}}
  {{> booking}}
{{/if}}
  • Lines 16–35: Selecting a doctor and a date: a form GET (see Example 17). A field [type="date"] sends the date in the format [yyyy-mm-dd];
  • Lines 38–42: A message from a patient who already has an appointment;
  • lines 59–96: one line per time slot. [{{else if patient}}] lists the statuses in sequence: available, booked with a visible name, booked without a name (“Booked”);
  • line 80: “Book” is a link;
  • lines 86–92: “Cancel” is a POST form with confirmation. In the loop, [@root.doctorId] refers to the field in the page template, not the one in the row;
  • lines 108–110: the reservation window, if the controller has prepared it.

11.15.2.3. views/persons/list.hbs

{{!--
  ==========================================================================
  views/persons/list.hbs - the list of doctors OR of clients (ADMIN).
  A single view for both: the labels specific to each arrive in
  [keys] (see DoctorsController / ClientsController).
  ==========================================================================
--}}
<div class="d-flex justify-content-between align-items-center mb-3">
  <h4 class="mb-0">{{t keys.title}}</h4>
  <a class="btn btn-primary" href="{{addUrl}}">{{t keys.add}}</a>
</div>

<div class="card p-3">
  {{#if persons.length}}
    <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>
        {{#each persons}}
          <tr>
            <td>{{title}}</td>
            <td>{{firstName}}</td>
            <td>{{lastName}}</td>
            <td><code>{{login}}</code></td>
            <td class="text-end">
              <div class="d-inline-flex gap-2">
                <a class="btn btn-sm btn-outline-primary" href="{{editUrl}}">{{t 'PERSONS.EDIT'}}</a>
                {{!-- data-confirmation: a confirmation dialog will open
                     before the form is sent (see public/js/app.js) --}}
                <form method="post" action="{{deleteUrl}}" class="m-0"
                      data-confirmation="{{t @root.keys.confirmDelete}}">
                  <button type="submit" class="btn btn-sm btn-outline-danger">{{t 'PERSONS.DELETE'}}</button>
                </form>
              </div>
            </td>
          </tr>
        {{/each}}
      </tbody>
    </table>
  {{else}}
    <p class="text-muted mb-0">{{t keys.empty}}</p>
  {{/if}}
</div>

A single view for doctors and clients: the text is provided as keys ([keys.title], [keys.add]...). Line 38: [@root.keys] is required in the loop.

11.15.2.4. views/persons/form.hbs

{{!--
  ==========================================================================
  views/persons/form.hbs - creation OR update of a doctor
  or a client (ADMIN). [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 entered (values.xxx);
    - the faulty fields framed in red (invalidFields.xxx, Bootstrap
      class "is-invalid");
    - under a field refused by validation, its message (errors.xxx);
    - the error message of the page (error), displayed AT THE BOTTOM of the page
      by the layout (see views/partials/errors.hbs).
  ==========================================================================
--}}
<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 pageTitle}}</h5>

      {{!-- novalidate: we let the SERVER validate (it is the one that
           has the final say); the browser checks (required...) are only a
           convenience, which a user can always get around --}}
      <form method="post" action="{{action}}" novalidate>
        {{#unless creating}}
          <input type="hidden" name="version" value="{{values.version}}">
        {{/unless}}

        {{!-- title, first name, last name: partial common to three forms --}}
        {{> personFields}}

        <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 {{#if invalidFields.login}}is-invalid{{/if}}" value="{{values.login}}">
          {{#if errors.login}}
            <div class="invalid-feedback">{{t errors.login}}</div>
          {{else}}
            <div class="form-text">{{#if creating}}{{t 'PERSONS.LOGIN_HELP_CREATE'}}{{else}}{{t 'PERSONS.LOGIN_HELP_EDIT'}}{{/if}}</div>
          {{/if}}
        </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 {{#if invalidFields.password}}is-invalid{{/if}}">
          {{#if errors.password}}
            <div class="invalid-feedback">{{t errors.password}}</div>
          {{else}}
            <div class="form-text">{{#if creating}}{{t 'PERSONS.PASSWORD_HELP_CREATE'}}{{else}}{{t 'PERSONS.PASSWORD_HELP_EDIT'}}{{/if}}</div>
          {{/if}}
        </div>

        <div class="d-flex gap-2">
          <button type="submit" class="btn btn-primary">{{t 'PERSONS.SAVE'}}</button>
          <a class="btn btn-secondary" href="{{listUrl}}">{{t 'PERSONS.BACK'}}</a>
        </div>
      </form>
    </div>
  </div>
</div>
  • Line 24: The submission address ([action]) is provided by the controller. [novalidate]: The server validates this;
  • Lines 25–27: the version number, for a modification;
  • line 30: the identity fields;
  • lines 35–42: the login, with a help text or error message;
  • lines 46–54: the password, which is never pre-filled.

11.15.2.5. views/account/registration.hbs

{{!--
  ==========================================================================
  views/account/registration.hbs - "Create a patient account" (public page,
  see RegistrationController). Same identity fields as the form of
  the ADMIN (personFields partial), but the login and the password are
  required, with confirmation.
  No captcha HERE: this page can only be reached after typing
  the one of the authentication page ("Create a patient account" button).
  "Back to login" leads back to the authentication page.
  ==========================================================================
--}}
<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>
        {{> personFields}}

        <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 {{#if invalidFields.login}}is-invalid{{/if}}" value="{{values.login}}">
          {{#if errors.login}}
            <div class="invalid-feedback">{{t errors.login}}</div>
          {{else}}
            <div class="form-text">{{t 'REGISTRATION.LOGIN_HELP'}}</div>
          {{/if}}
        </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 {{#if invalidFields.password}}is-invalid{{/if}}">
            {{#if errors.password}}
              <div class="invalid-feedback">{{t errors.password min=passwordMin}}</div>
            {{else}}
              <div class="form-text">{{t 'REGISTRATION.PASSWORD_HELP' min=passwordMin}}</div>
            {{/if}}
          </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 {{#if invalidFields.confirmation}}is-invalid{{/if}}">
            {{#if errors.confirmation}}<div class="invalid-feedback">{{t errors.confirmation}}</div>{{/if}}
          </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 an account. Line 42: The help text has a parameter—the minimum password length—provided by the controller.

11.15.2.6. views/patient/area.hbs

{{!--
  ==========================================================================
  views/patient/area.hbs - "My area": the home page of the patient
  (USER role), displayed right after login (see PatientAreaController).
    - two options: [Manage my account] and [View a schedule];
    - their upcoming appointment, if they have one, with the means to cancel it.
  ==========================================================================
--}}
<h4 class="mb-3">{{t 'AREA.HELLO' name=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 appointments.length}}
    {{!-- normally only one (rule "one upcoming appointment at a time"),
         but the page can display several --}}
    {{#each appointments}}
      <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 py-2 {{#unless @last}}border-bottom{{/unless}}">
        <div>
          <div class="fw-semibold">{{day}}, {{time}}</div>
          <div class="text-muted">{{doctor}} - <a href="{{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="{{id}}">
          <button type="submit" class="btn btn-outline-danger">{{t 'AREA.CANCEL_APPOINTMENT'}}</button>
        </form>
      </div>
    {{/each}}
  {{else}}
    <p class="text-muted mb-0">{{t 'AREA.NO_APPOINTMENT'}}</p>
  {{/if}}
</div>
  • Line 9: “Hello Mr Jean MARTIN”;
  • Lines 13–19: the two options, links;
  • lines 31–43: the upcoming appointment. [{{#unless @last}}]: a border separates the appointments, except after the last one.

11.15.2.7. views/patient/account.hbs

{{!--
  ==========================================================================
  views/patient/account.hbs - "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.
  ==========================================================================
--}}
<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="{{values.version}}">

        {{> personFields}}

        <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 {{#if invalidFields.login}}is-invalid{{/if}}" value="{{values.login}}">
          {{#if errors.login}}<div class="invalid-feedback">{{t errors.login}}</div>{{/if}}
        </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 {{#if invalidFields.password}}is-invalid{{/if}}">
            {{#if errors.password}}
              <div class="invalid-feedback">{{t errors.password min=passwordMin}}</div>
            {{else}}
              <div class="form-text">{{t 'PERSONS.PASSWORD_HELP_EDIT'}}</div>
            {{/if}}
          </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 {{#if invalidFields.confirmation}}is-invalid{{/if}}">
            {{#if errors.confirmation}}<div class="invalid-feedback">{{t errors.confirmation}}</div>{{/if}}
          </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 {{#if invalidFields.currentPassword}}is-invalid{{/if}}">
          {{#if errors.currentPassword}}
            <div class="invalid-feedback">{{t errors.currentPassword}}</div>
          {{else}}
            <div class="form-text">{{t 'MY_ACCOUNT.CURRENT_PASSWORD_HELP'}}</div>
          {{/if}}
        </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 17–65: account modification;
  • lines 72–80: deletion, with confirmation. Two independent forms on the same page: each sends only its own fields.

11.15.2.8. views/error.hbs

{{!--
  views/error.hbs - the error page (403, 404, 400, 500), built by
  HtmlExceptionFilter (src/web/html-exception.filter.ts).
--}}
<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">{{code}}</h1>
      <p class="lead">{{t message}}</p>
      <div><a class="btn btn-primary" href="/">{{t 'ERROR_PAGE.BACK'}}</a></div>
    </div>
  </div>
</div>

The exception filter error page: the code HTTP, and the translated message.

11.16. The dictionaries

11.16.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]);
  • line 80: a text with two parameters, [{name}] and [{login}];
  • lines 98–137: error messages. These are the keys used by the [BusinessException] in the business layer, the form validation decorators, and the exception filter.

11.16.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.17. Static files

11.17.1. public/css/styles.css

/* =========================================================================
   public/css/styles.css - a few settings on top of Bootstrap
   (taken from src/styles.css of the Angular client).
   ========================================================================= */

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/partials/errors.hbs).
   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 modal windows;
  • lines 36–43: the two “My area” options respond to mouseover;
  • lines 46–50: the captcha image frame.

11.17.2. public/js/app.js

// =========================================================================
// public/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/partials/confirmation.hbs).
// 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 completed script for the confirmation window in Example 21:

  • lines 17–19: a page without a confirmation window has nothing to do;
  • line 24: the element that had keyboard focus before the window opened, to which focus is returned when the window closes;
  • lines 26–35: focus is placed on “No, go back”; an accidental press of Enter does not delete anything;
  • lines 37–43: the window is hidden, and focus is restored;
  • 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;
  • lines 71–86: The Esc key closes the window; the Tab key cycles the focus within the window, rather than letting it move to the grayed-out page behind it.