Skip to content

11. Étude de cas : l'application RdvMedecins

11.1. Le problème

L'application RdvMedecins gère les rendez-vous d'un cabinet de médecins. Chaque médecin a des créneaux horaires (de 8 h 00 à 12 h 00 par tranches de 20 minutes, par exemple) ; un rendez-vous associe un jour, un créneau et un client (un patient). C'est la transposition exacte, en [ASP.NET Core MVC], de l'application [NestJS] du cours précédent : même base de données, mêmes règles de gestion, mêmes pages, mêmes dictionnaires de traduction. Les pages HTML sont fabriquées par le serveur, avec des vues [Razor].

Trois rôles d'utilisateurs se partagent l'application :

Rôle
Qui
Ce qu'il peut faire
ADMIN
l'administrateur ([admin])
consulter les agendas, réserver et annuler pour n'importe quel patient ; gérer (lister, ajouter, modifier, supprimer) les médecins et les clients
DOCTOR
les médecins
consulter les agendas, réserver et annuler pour n'importe quel patient
USER
les clients (patients)
consulter les agendas sans voir le nom des autres patients ; réserver pour lui-même ; annuler son rendez-vous ; gérer son compte ; créer son compte

Les règles de gestion principales :

  • pas de rendez-vous dans le passé ;
  • un créneau n'est réservé qu'une fois par jour ;
  • un patient n'a qu'un rendez-vous à venir à la fois ;
  • deux médecins (ou deux clients) ne portent pas les mêmes prénom et nom, et un login n'appartient qu'à un compte ;
  • un médecin qui a des rendez-vous ne peut pas être supprimé ; un client est supprimé avec ses rendez-vous ;
  • la page de connexion est protégée des robots (captcha, limitation des tentatives), et la création d'un compte client exige d'avoir d'abord résolu le captcha de cette page.

11.2. Utilisation de l'application

11.2.1. La page de connexion

Image

La page propose deux boutons : Se connecter, et Créer un compte client. Tous deux exigent d'avoir recopié les caractères de l'image. Les boutons FR / EN changent la langue :

Image

Une erreur (ici un captcha mal recopié) est signalée en bas de la page, sur un fond rose léger :

Image

11.2.2. L'agenda, vu par l'administrateur et les médecins

Une fois connecté, l'administrateur arrive sur la page de l'agenda. Il choisit un médecin et un jour :

Image

Image

Chaque créneau est libre (bouton Réserver) ou occupé (nom du patient, bouton Annuler). Un clic sur Réserver ouvre une fenêtre qui demande le patient :

Image

Un clic sur Annuler demande une confirmation (ici par le docteur Pélissier, dont la barre de navigation n'a pas les menus d'administration) :

Image

11.2.3. L'espace du patient

Un patient (rôle USER) arrive sur Mon espace : son rendez-vous à venir, qu'il peut annuler, et des liens vers la gestion de son compte et vers l'agenda :

Image

Dans l'agenda, il voit son propre rendez-vous, mais pas le nom des autres patients. Ayant déjà un rendez-vous à venir, il ne peut pas en prendre un autre :

Image

Un patient sans rendez-vous peut réserver un créneau libre, pour lui-même :

Image

La gestion de son compte : modifier ses informations, son mot de passe, ou supprimer son compte (et ses rendez-vous). Le mot de passe actuel est exigé pour toute modification :

Image

Un patient qui tente d'ouvrir une page réservée à l'administrateur reçoit une page d'erreur :

Image

11.2.4. La gestion des médecins et des clients

L'administrateur a deux menus supplémentaires, Médecins et Clients :

Image

Image

Le formulaire d'ajout d'un médecin, envoyé avec des erreurs :

Image

Un homonyme d'un médecin existant est refusé :

Image

La suppression d'un client demande une confirmation :

Image

11.2.5. La création d'un compte client

Depuis la page de connexion, le bouton Créer un compte client n'ouvre le formulaire que si le captcha a été correctement recopié :

Image

Image

11.3. Installation et exécution

Les étapes de l'installation ont été données au début du document (section « Exécuter l'étude de cas » de l'introduction) : créer la base avec le script SQL, vérifier la chaîne de connexion de [appsettings.json], puis lancer [dotnet run] dans le dossier de l'application. Le dossier de l'application :

rdvmedecins2-aspnetcore-mvc/
├── Auth/
│   ├── AuthentificationJwt.cs
│   ├── AuthService.cs
│   ├── CaptchaService.cs
│   ├── LimiteurTentativesService.cs
│   ├── PasseInscriptionService.cs
│   ├── README.md
│   ├── SessionInvalideException.cs
│   └── UtilisateurConnecte.cs
├── Config/
│   ├── AuthOptions.cs
│   ├── ProtectionOptions.cs
│   └── README.md
├── Controllers/
│   ├── AgendaController.cs
│   ├── AuthController.cs
│   ├── ClientsController.cs
│   ├── ErreurController.cs
│   ├── EspacePatientController.cs
│   ├── InscriptionController.cs
│   ├── LangueController.cs
│   ├── MedecinsController.cs
│   ├── PersonnesControllerBase.cs
│   └── README.md
├── Data/
│   ├── RdvMedecinsDbContext.cs
│   └── README.md
├── database/
│   ├── dbrdvmedecins2.sql
│   └── README.md
├── Domain/
│   ├── AgendaMedecinJour.cs
│   ├── CreneauMedecinJour.cs
│   └── README.md
├── Entities/
│   ├── AbstractEntity.cs
│   ├── Client.cs
│   ├── Creneau.cs
│   ├── Medecin.cs
│   ├── Personne.cs
│   ├── README.md
│   ├── Role.cs
│   ├── Rv.cs
│   └── User.cs
├── i18n/
│   ├── en.json
│   └── fr.json
├── Metier/
│   ├── Dates.cs
│   ├── IMetier.cs
│   ├── MetierException.cs
│   ├── MetierService.cs
│   └── README.md
├── Models/
│   ├── Formulaires.cs
│   ├── ModelesVues.cs
│   └── README.md
├── Properties/
│   └── launchSettings.json
├── Repositories/
│   ├── CreneauRepository.cs
│   ├── PersonneRepository.cs
│   ├── README.md
│   ├── RvRepository.cs
│   └── UserRepository.cs
├── Views/
│   ├── Agenda/
│   │   ├── _Reservation.cshtml
│   │   └── Agenda.cshtml
│   ├── Auth/
│   │   ├── _Captcha.cshtml
│   │   └── Login.cshtml
│   ├── EspacePatient/
│   │   ├── Compte.cshtml
│   │   └── Espace.cshtml
│   ├── Inscription/
│   │   └── Inscription.cshtml
│   ├── Personnes/
│   │   ├── Formulaire.cshtml
│   │   └── Liste.cshtml
│   ├── Shared/
│   │   ├── _ChampsPersonne.cshtml
│   │   ├── _Confirmation.cshtml
│   │   ├── _Erreurs.cshtml
│   │   ├── _Flash.cshtml
│   │   ├── _Layout.cshtml
│   │   ├── _Navbar.cshtml
│   │   └── Erreur.cshtml
│   ├── _ViewImports.cshtml
│   ├── _ViewStart.cshtml
│   └── README.md
├── Web/
│   ├── ApplicationModel.cs
│   ├── Flash.cs
│   ├── HtmlExceptionFilter.cs
│   ├── Outils.cs
│   ├── PageVue.cs
│   ├── README.md
│   ├── Traducteur.cs
│   └── Validation.cs
├── wwwroot/
│   ├── css/
│   │   └── styles.css
│   ├── js/
│   │   └── app.js
│   └── lib/
│       └── bootstrap/
│           ├── css/
│           │   └── bootstrap.min.css
│           └── LICENSE
├── .gitignore
├── appsettings.Development.json
├── appsettings.json
├── Program.cs
├── RdvMedecins.csproj
└── README.md

Chaque dossier contient un fichier [README.md] qui présente ses fichiers. La console, au démarrage :

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

En mode développement (cf. [Properties/launchSettings.json]), la console affiche en outre chaque ordre SQL envoyé à la base par EF Core. On peut aussi :

  • lancer l'application par [dotnet watch] : elle est recompilée et relancée à chaque modification d'un fichier ;
  • l'exécuter pas à pas dans [Visual Studio Code] : avec l'extension [C# Dev Kit], la touche F5 démarre l'application sous le contrôle du débogueur ; on pose un point d'arrêt d'un clic dans la marge d'une ligne de code.

11.4. L'architecture de l'application

Image

  • [Program.cs] configure l'application : services (injection de dépendances), middlewares (fichiers statiques, pages d'erreur, langue, authentification), contrôleurs ;
  • [Auth] : la connexion, les jetons JWT, les rôles, le captcha, la limitation des tentatives ;
  • [Controllers] : les contrôleurs de la couche web. Ils passent tous par un service unique, [ApplicationModel] (dossier [Web]), qui garde en mémoire les listes de médecins et de clients et relaie les autres demandes à la couche métier ;
  • [Web] : ce que la couche web a en commun : traduction, messages flash, validation, classe mère des vues, filtre d'exceptions ; [Models] : les modèles des formulaires et des vues ;
  • [Metier] : les règles de gestion ;
  • [Repositories] : la couche DAO, une classe par table ; [Data] : le [DbContext] d'EF Core ;
  • [Entities] : les entités EF Core ; [Domain] : les objets de l'agenda, calculés par la couche métier ;
  • [Views] : les vues [Razor] ; [i18n] : les dictionnaires ; [wwwroot] : la feuille de style, le script de la page et [Bootstrap].

La suite commente tous les fichiers, des couches basses (base de données) vers les couches hautes (vues).

11.5. La base de données

La base [dbrdvmedecins2] est créée par le script [database/dbrdvmedecins2.sql], que l'on exécute comme celui de l'exemple 28 (outil [HeidiSQL] de [Laragon], ou commande [mysql -u root < database/dbrdvmedecins2.sql]). C'est, au mot près, le script de l'application [NestJS] : les deux applications peuvent travailler sur la même base, et l'une peut remplacer l'autre sans rien y changer.

11.5.1. database/dbrdvmedecins2.sql

-- =========================================================================
-- Base de données [dbrdvmedecins2] - MySQL 8
--
-- Évolution de [dbrdvmedecins] :
--   1. les tables [medecins] et [clients] reçoivent une colonne [user],
--      clé étrangère vers users(id) : chaque médecin et chaque client est
--      désormais aussi un utilisateur capable de se connecter ;
--   2. un troisième rôle apparaît : DOCTOR (en plus de ADMIN et USER) ;
--   3. le script crée une ligne [users] pour chaque médecin (rôle DOCTOR)
--      et pour chaque client (rôle USER) :
--        - login        = nom en minuscules
--        - mot de passe = nom en minuscules, haché avec bcrypt (10 tours)
--        - nom affiché  = prénom + nom
--
--   4. contraintes d'unicité, toutes NOMMÉES (l'application reconnaît
--      le nom de la contrainte violée dans le message d'erreur MySQL) :
--        - unq_users_login          : un login n'appartient qu'à un seul compte ;
--        - unq_medecins_prenom_nom  : deux médecins ne peuvent pas avoir les
--                                     mêmes prénom et nom ;
--        - unq_clients_prenom_nom   : idem pour les clients.
--      Attention : la collation utf8mb4_unicode_ci compare sans tenir compte
--      des majuscules ni des accents - "Hélène DUPONT" et "helene dupont"
--      sont donc considérés comme identiques.
--
-- La table [users] doit maintenant être créée AVANT [medecins] et [clients]
-- (elles la référencent), et supprimée APRÈS elles.
-- =========================================================================

-- on repart d'une base vide à chaque exécution du script (pratique en développement)
CREATE DATABASE IF NOT EXISTS dbrdvmedecins2 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE dbrdvmedecins2;

-- ordre de suppression : des tables « filles » vers les tables « mères »
DROP TABLE IF EXISTS rv;
DROP TABLE IF EXISTS creneaux;
DROP TABLE IF EXISTS clients;
DROP TABLE IF EXISTS medecins;
DROP TABLE IF EXISTS users;

-- -------------------------------------------------------------------------
-- table [users] : les utilisateurs autorisés à se connecter à l'application
-- -------------------------------------------------------------------------
CREATE TABLE users (
  id       INT AUTO_INCREMENT PRIMARY KEY,
  version  INT NOT NULL DEFAULT 0,
  login    VARCHAR(30) NOT NULL,                 -- identifiant de connexion
  password VARCHAR(100) NOT NULL,                -- mot de passe HACHÉ (bcrypt) - jamais en clair
  nom      VARCHAR(40) NOT NULL,                 -- nom affiché une fois connecté
  role     VARCHAR(10) NOT NULL,                 -- 'ADMIN', 'DOCTOR' ou 'USER'
  -- un login ne peut appartenir qu'à un seul compte
  CONSTRAINT unq_users_login UNIQUE (login)
);

-- -------------------------------------------------------------------------
-- table [medecins] : les médecins du cabinet
-- -------------------------------------------------------------------------
CREATE TABLE medecins (
  id      INT AUTO_INCREMENT PRIMARY KEY,      -- clé primaire (identifiant technique)
  version INT NOT NULL DEFAULT 0,               -- n° de version de la ligne (verrou optimiste)
  titre   VARCHAR(5)  NOT NULL,                 -- Melle, Mme, Mr
  nom     VARCHAR(20) NOT NULL,
  prenom  VARCHAR(20) NOT NULL,
  `user`  INT NOT NULL,                         -- clé étrangère -> users(id)
  CONSTRAINT fk_medecins_user FOREIGN KEY (`user`) REFERENCES users(id),
  -- un compte par médecin, et un médecin par compte
  CONSTRAINT unq_medecins_user UNIQUE (`user`),
  -- deux médecins ne peuvent pas porter les mêmes prénom et nom
  CONSTRAINT unq_medecins_prenom_nom UNIQUE (prenom, nom)
);

-- -------------------------------------------------------------------------
-- table [clients] : les patients des médecins
-- -------------------------------------------------------------------------
CREATE TABLE clients (
  id      INT AUTO_INCREMENT PRIMARY KEY,
  version INT NOT NULL DEFAULT 0,
  titre   VARCHAR(5)  NOT NULL,
  nom     VARCHAR(20) NOT NULL,
  prenom  VARCHAR(20) NOT NULL,
  `user`  INT NOT NULL,                         -- clé étrangère -> users(id)
  CONSTRAINT fk_clients_user FOREIGN KEY (`user`) REFERENCES users(id),
  -- un compte par client, et un client par compte
  CONSTRAINT unq_clients_user UNIQUE (`user`),
  -- deux clients ne peuvent pas porter les mêmes prénom et nom
  CONSTRAINT unq_clients_prenom_nom UNIQUE (prenom, nom)
);

-- -------------------------------------------------------------------------
-- table [creneaux] : les créneaux horaires de chaque médecin
-- -------------------------------------------------------------------------
CREATE TABLE creneaux (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  version    INT NOT NULL DEFAULT 0,
  id_medecin INT NOT NULL,                      -- clé étrangère -> medecins(id)
  hdebut     INT NOT NULL,                       -- heure de début du créneau
  mdebut     INT NOT NULL,                       -- minute de début du créneau
  hfin       INT NOT NULL,                       -- heure de fin du créneau
  mfin       INT NOT NULL,                       -- minute de fin du créneau
  CONSTRAINT fk_creneaux_medecin FOREIGN KEY (id_medecin) REFERENCES medecins(id)
);

-- -------------------------------------------------------------------------
-- table [rv] : les rendez-vous pris par les clients
-- -------------------------------------------------------------------------
CREATE TABLE rv (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  version    INT NOT NULL DEFAULT 0,
  jour       DATE NOT NULL,                      -- jour du rendez-vous
  id_creneau INT NOT NULL,                        -- clé étrangère -> creneaux(id)
  id_client  INT NOT NULL,                        -- clé étrangère -> clients(id)
  CONSTRAINT fk_rv_creneau FOREIGN KEY (id_creneau) REFERENCES creneaux(id),
  CONSTRAINT fk_rv_client  FOREIGN KEY (id_client)  REFERENCES clients(id),
  -- un même créneau, un même jour, ne peut être réservé qu'une seule fois
  CONSTRAINT unq1_rv UNIQUE (jour, id_creneau)
);

-- =========================================================================
-- Données de démonstration
-- =========================================================================

-- les utilisateurs : mots de passe hachés avec bcrypt (10 tours de salage)
--   admin/admin (ADMIN)
--   un compte DOCTOR par médecin, un compte USER par client
--   (le compte de démonstration user/user a disparu : un compte USER est
--   désormais TOUJOURS celui d'un client, qui peut réserver et gérer son compte)
--   (login = mot de passe = nom en minuscules)
INSERT INTO users (login, password, nom, role) VALUES
  ('admin',     '$2b$10$YSNBJXXHX9mp13nPZb6YGu8M1jR6uvPwO1s2X73z0QAqBR7TOQjoq', 'Administrateur',   'ADMIN'),
  -- médecins
  ('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');

-- les médecins : la colonne [user] est retrouvée à partir du login,
-- plutôt que de supposer les valeurs des clés primaires de [users]
INSERT INTO medecins (titre, nom, prenom, `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 (titre, nom, prenom, `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'));

-- créneaux du docteur PELISSIER (id_medecin = 1) : de 8h00 à 12h00 par tranches de 20 minutes
INSERT INTO creneaux (id_medecin, hdebut, mdebut, hfin, mfin) 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);

-- créneaux du docteur TATOU (id_medecin = 2) : de 14h00 à 18h00 par tranches de 20 minutes
INSERT INTO creneaux (id_medecin, hdebut, mdebut, hfin, mfin) 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);

-- un rendez-vous déjà pris, pour voir tout de suite un créneau occupé dans l'agenda
INSERT INTO rv (jour, id_creneau, id_client) VALUES
  (CURDATE(), 2, 3);

Commentons ce script :

  • ligne 30 : la base est créée si elle n'existe pas encore, avec le jeu de caractères [utf8mb4] et la collation [utf8mb4_unicode_ci], comme à l'exemple 28 : les comparaisons de chaînes ignorent majuscules et accents. Cela vaut aussi pour les contraintes d'unicité : [Hélène DUPONT] et [helene dupont] sont, pour [MySQL], deux fois la même personne ;
  • lignes 34-38 : le script repart d'une base vide à chaque exécution. Les tables sont supprimées des « filles » vers les « mères » : une table référencée par une clé étrangère ([users], par exemple, référencée par [medecins] et [clients]) ne peut pas être supprimée tant qu'une autre table la référence encore. Les créations se font, elles, dans l'ordre inverse ;
  • lignes 43-52 : les comptes des utilisateurs. Chaque table commence par une clé primaire [id], attribuée par la base ([AUTO_INCREMENT]), et par une colonne [version], qui vaut 0 à la création de la ligne ;
  • lignes 46-49 : le login, le mot de passe, le nom affiché une fois connecté et le rôle ([ADMIN], [DOCTOR] ou [USER]). La colonne [password] ne contient jamais le mot de passe lui-même, mais son hachage [bcrypt] (cf. exemple 25) : 60 caractères, d'où la taille de la colonne ;
  • ligne 51 : un login n'appartient qu'à un seul compte. Comme à l'exemple 28, la contrainte est nommée : quand [MySQL] refuse une écriture, son message d'erreur cite le nom de la contrainte violée, et la couche métier s'en sert pour choisir le message à afficher (cf. [Metier/MetierService.cs]) ;
  • lignes 57-69 : les médecins : titre, nom, prénom ;
  • ligne 59 : la colonne [version] sert au verrou optimiste (cf. exemple 28) : elle est augmentée à chaque modification de la ligne, ce qui permet de détecter qu'un autre utilisateur a modifié la ligne entre sa lecture et son écriture. Comme à l'exemple 28, c'est l'application qui l'augmente (cf. le [DbContext], plus loin) ;
  • lignes 63-64 : chaque médecin a un compte : la colonne [user] est une clé étrangère vers [users(id)]. La base refuse une valeur qui ne désigne aucun compte, et refuse de supprimer un compte encore référencé par un médecin. [user] étant un mot réservé de [MySQL], le nom de la colonne est écrit entre apostrophes inverses ;
  • ligne 66 : un compte n'appartient qu'à un médecin : la relation entre [medecins] et [users] est une relation « un à un » ;
  • ligne 68 : deux médecins ne peuvent pas porter les mêmes prénom et nom. La contrainte porte sur le couple de colonnes : deux médecins peuvent avoir le même nom, s'ils n'ont pas le même prénom ;
  • lignes 74-86 : les clients (les patients), sur le même modèle : un compte par client (clé étrangère [fk_clients_user], unicité [unq_clients_user]), pas deux clients de mêmes prénom et nom ;
  • lignes 91-100 : les créneaux horaires de chaque médecin (clé étrangère [id_medecin]) : heure et minute de début, heure et minute de fin. Un créneau ne porte pas de date : il se répète tous les jours ;
  • lignes 105-115 : les rendez-vous : un jour (type [DATE], sans heure), un créneau et un client, désignés par deux clés étrangères ;
  • ligne 114 : un même créneau ne peut être réservé qu'une fois par jour. C'est la base elle-même qui garantit cette règle : si deux utilisateurs réservent le même créneau au même instant, le second [INSERT] est refusé, quoi qu'ait vérifié l'application juste avant ;
  • lignes 127-137 : les comptes de démonstration : l'administrateur, trois médecins (rôle [DOCTOR]) et quatre clients (rôle [USER]). Le mot de passe de chacun est son login ([admin] / [admin], [pelissier] / [pelissier]...). Les hachages commencent par [$2b$10$] : l'algorithme [bcrypt], avec un coût de 10 (cf. exemple 25) ; la bibliothèque [BCrypt.Net-Next] de l'application sait les vérifier ;
  • lignes 141-144 : la colonne [user] de chaque médecin est retrouvée par une sous-requête sur le login, plutôt que de supposer les identifiants attribués par la base aux comptes ;
  • lignes 146-150 : les clients, de la même façon ;
  • lignes 153-157 : douze créneaux de 20 minutes, de 8 h 00 à 12 h 00, pour le docteur PELISSIER. Ici, l'identifiant du médecin (1) est écrit en dur : la table [medecins] vient d'être créée, ses identifiants commencent donc à 1 ;
  • lignes 160-164 : douze créneaux, de 14 h 00 à 18 h 00, pour le docteur TATOU. Le docteur DUPONT n'a pas de créneau : son agenda est vide ;
  • lignes 167-168 : un rendez-vous déjà pris, pour Brigitte BISTROU (client n° 3) sur le deuxième créneau du docteur PELISSIER (8 h 20). [CURDATE()] est la date du jour de l'exécution du script : dès la première connexion, l'agenda du jour montre un créneau occupé.

11.6. La configuration du projet

11.6.1. RdvMedecins.csproj

<!--
  =========================================================================
  RdvMedecins.csproj - le fichier projet (l'équivalent de package.json).

  Microsoft.NET.Sdk.Web : le SDK des applications ASP.NET Core. Il apporte
  à lui seul tout le framework web (MVC, vues Razor, authentification,
  localisation, fichiers statiques...) : seules les bibliothèques
  EXTÉRIEURES au framework sont listées ci-dessous (PackageReference), et
  téléchargées depuis nuget.org par [dotnet restore] / [dotnet build].
  =========================================================================
-->
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <!-- .NET 10, version LTS (support long terme) -->
    <TargetFramework>net10.0</TargetFramework>
    <!-- types références "nullables" : le compilateur signale les risques de
         NullReferenceException (l'équivalent de strictNullChecks en TypeScript) -->
    <Nullable>enable</Nullable>
    <!-- using implicites : System, System.Linq, System.Threading.Tasks... -->
    <ImplicitUsings>enable</ImplicitUsings>
    <RootNamespace>RdvMedecins</RootNamespace>
  </PropertyGroup>

  <ItemGroup>
    <!-- Entity Framework Core + son fournisseur (provider) MySQL / MariaDB.
         Pomelo tire lui-même Microsoft.EntityFrameworkCore (et MySqlConnector,
         le pilote ADO.NET) dans la version qui lui correspond. -->
    <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
    <!-- l'authentification par jeton JWT (lecture et vérification du jeton) -->
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
    <!-- le hachage des mots de passe (bcrypt), compatible avec les hachages
         $2b$ du script SQL -->
    <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
  </ItemGroup>

  <ItemGroup>
    <!-- les dictionnaires de traduction sont lus au démarrage. Le SDK Web
         range déjà tous les .json parmi les fichiers à publier (Content) :
         on précise simplement de toujours les recopier (dotnet publish) -->
    <Content Update="i18n\*.json" CopyToPublishDirectory="PreserveNewest" />
    <!-- le script SQL n'a pas à être publié -->
    <Content Remove="database\**" />
    <None Include="database\**" />
  </ItemGroup>

</Project>

Commentons ce fichier :

  • ligne 12 : le SDK des applications web, comme pour tous les exemples (cf. introduction) ;
  • lignes 14-23 : les réglages que les exemples trouvaient dans le fichier commun [exemples/Directory.Build.props] : [.NET] 10, types références « nullables », [using] implicites. L'étude de cas est un projet autonome : elle les écrit elle-même. [RootNamespace] est l'espace de noms racine du projet, celui que [Visual Studio Code] propose pour un nouveau fichier ;
  • ligne 29 : le fournisseur [MySQL] d'EF Core, qui apporte avec lui EF Core et le pilote [MySqlConnector] (cf. exemple 28) ;
  • lignes 31-34 : les deux paquets de l'authentification, déjà utilisés au chapitre 8 : la vérification des jetons JWT, et le hachage des mots de passe ;
  • ligne 41 : les dictionnaires de traduction [i18n/*.json] sont lus sur le disque au démarrage. Quand on prépare l'application pour un serveur de production (commande [dotnet publish]), ils doivent être recopiés avec elle : c'est ce que demande [CopyToPublishDirectory] ;
  • lignes 43-44 : le script SQL, lui, n'est pas un fichier de l'application : on l'exclut des fichiers publiés, comme à l'exemple 28.

11.6.2. appsettings.json

Le fichier [appsettings.json] tient le rôle du fichier [.env] de l'application [NestJS]. Une différence importante : le fichier [.env] n'était jamais partagé, et un fichier [.env.example] en donnait le modèle. Ici, [appsettings.json] fait partie du projet ; les valeurs propres à une machine (un mot de passe, par exemple) peuvent être fournies autrement, sans le modifier (cf. plus bas).

// =========================================================================
// appsettings.json - la configuration de l'application (l'équivalent du
// fichier .env du projet NestJS). ASP.NET Core accepte les commentaires
// dans ce fichier JSON.
//
// Les valeurs sensibles (mot de passe MySQL, secret JWT) peuvent être
// remplacées sans toucher à ce fichier : variables d'environnement ou
// "secrets utilisateur" (cf. README.md, section Configuration).
// =========================================================================
{
  // port d'écoute. Le serveur JSON écoute déjà sur 8080 : on choisit un autre
  // port pour pouvoir faire tourner les deux en même temps.
  "Urls": "http://localhost:8081",

  // connexion à la base MySQL [dbrdvmedecins2] (script : database/dbrdvmedecins2.sql)
  "ConnectionStrings": {
    "RdvMedecins": "Server=localhost;Port=3306;Database=dbrdvmedecins2;User=root;Password=;"
  },
  "MySql": {
    // version du serveur, pour que EF Core génère le bon dialecte SQL :
    // "8.0.0-mysql" pour MySQL 8, "10.11.0-mariadb" pour MariaDB 10.11...
    "ServerVersion": "8.0.0-mysql"
  },

  // authentification (cf. Auth/)
  "Auth": {
    // clé secrète des jetons JWT : 32 caractères au moins (HMAC-SHA256).
    // À changer impérativement en production (longue chaîne aléatoire).
    "JwtSecret": "change-moi-en-une-longue-chaine-aleatoire",
    // durée de validité d'un jeton, en secondes (7200 s = 2 h) ; le cookie
    // qui transporte le jeton a la même durée de vie
    "JwtExpiresInSeconds": 7200,
    // true en production (site servi en HTTPS) : le cookie d'authentification
    // n'est alors jamais envoyé sur une connexion non chiffrée
    "CookieSecure": false
  },

  // protection contre les robots (cf. Auth/LimiteurTentativesService.cs)
  "Protection": {
    // connexion : échecs autorisés pour un même login depuis une même adresse IP...
    "LoginMaxEchecs": 5,
    // ... avant un blocage de cette durée (l'adresse IP entière est aussi
    // bloquée si elle cumule 5 fois plus d'échecs, tous logins confondus)
    "LoginBlocageMinutes": 15,
    // création de compte client : comptes créés au plus par heure et par
    // adresse IP. En salle de cours, tous les postes sortent souvent par la
    // même adresse : augmenter si besoin.
    "InscriptionMaxParHeure": 10
  },

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

Commentons ce fichier :

  • lignes 1-9 : contrairement au JSON standard, [ASP.NET Core] accepte les commentaires dans ses fichiers de configuration ;
  • ligne 13 : l'adresse et le port d'écoute. Les exemples écoutaient sur le port 5000, le port par défaut ; l'application écoute sur le port 8081, comme la version [NestJS] ;
  • lignes 16-18 : la chaîne de connexion à la base [dbrdvmedecins2], avec les valeurs par défaut de [Laragon] (utilisateur [root], sans mot de passe), cf. exemple 28 ;
  • lignes 19-23 : la version du serveur, qui indique au fournisseur [Pomelo] quel dialecte SQL produire (cf. exemple 28) ;
  • lignes 26-36 : la section de l'authentification : la clé secrète qui signe les jetons (au moins 32 caractères, à remplacer impérativement en production), la durée de vie d'un jeton (deux heures), et l'option [Secure] du cookie qui le transporte. En production, le site est servi en HTTPS, et le cookie ne doit jamais voyager sur une connexion non chiffrée : on y passe l'option à [true] ;
  • lignes 39-49 : les seuils de la protection contre les robots (cf. exemple 27) : nombre d'échecs de connexion autorisés et durée du blocage qui suit ; nombre de comptes clients créés par heure depuis une même adresse IP ;
  • lignes 51-57 : les niveaux de journalisation (cf. introduction). Les messages d'EF Core ne sont affichés qu'à partir du niveau [Warning] : les ordres SQL, de niveau [Information], n'apparaissent pas (cf. exemple 28) ;
  • ligne 58 : tous les noms de domaine sont acceptés.

Les sections [Auth] et [Protection] sont lues sous forme de classes typées (cf. [Config/AuthOptions.cs], plus bas). Chaque valeur est désignée par son chemin, les niveaux étant séparés par [:] : [Auth:JwtSecret], [ConnectionStrings:RdvMedecins], [MySql:ServerVersion]. [ASP.NET Core] lit la configuration dans plusieurs sources successives, chacune pouvant remplacer les valeurs des précédentes : [appsettings.json], puis [appsettings.{environnement}.json] (cf. fichier suivant), puis, en développement, les « secrets utilisateur » (des valeurs rangées hors du projet, dans le profil de l'utilisateur), puis les variables d'environnement, et enfin les arguments de la ligne de commande. Un mot de passe peut ainsi être donné par une variable d'environnement, où le [:] s'écrit [__] :

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

Le fichier [README.md] de l'application détaille ces possibilités.

11.6.3. appsettings.Development.json

// Surcharge de appsettings.json en développement (ASPNETCORE_ENVIRONMENT=
// Development, cf. Properties/launchSettings.json) : on affiche dans la
// console les requêtes SQL générées par EF Core - comme le faisait
// l'option logging: true de TypeORM.
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.EntityFrameworkCore.Database.Command": "Information"
    }
  }
}
  • ligne 10 : [ASP.NET Core] connaît l'environnement dans lequel s'exécute l'application : [Development], [Production]... Il le lit dans la variable d'environnement [ASPNETCORE_ENVIRONMENT] (cf. fichier suivant) ; à défaut, c'est [Production]. Après [appsettings.json], il lit le fichier [appsettings.{environnement}.json], s'il existe, et fusionne les deux : les clés présentes dans le second remplacent celles du premier, les autres sont conservées. En développement, on ajoute donc le niveau [Information] pour la catégorie [Microsoft.EntityFrameworkCore.Database.Command] : la règle la plus précise l'emporte sur celle, plus générale, de [appsettings.json] ([Warning] pour tout [Microsoft.EntityFrameworkCore]), et chaque ordre SQL envoyé à la base est affiché dans la console. C'est l'équivalent de l'option [logging] de [TypeORM], que la version [NestJS] activait hors production. Toutes les autres valeurs (connexion, authentification...) restent celles de [appsettings.json].

11.6.4. Properties/launchSettings.json

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

Ce fichier n'est utilisé qu'en développement, par [dotnet run], [dotnet watch] et [Visual Studio Code] ; il n'est pas publié. Commentons-le :

  • lignes 3-4 : un profil de lancement décrit une façon de lancer l'application. Il n'y en a qu'un ici ; [dotnet run] prend le premier ;
  • ligne 5 : l'application est lancée directement, avec son propre serveur web ;
  • lignes 6-7 : [dotnet run] affiche ses messages de compilation, et le navigateur s'ouvre sur l'application quand elle est prête ;
  • ligne 8 : l'adresse d'écoute, la même que la clé [Urls] de [appsettings.json]. Lancée par ce profil, l'application utilise celle-ci ; lancée hors profil (par exemple une fois publiée), celle de [appsettings.json] ;
  • lignes 9-10 : les variables d'environnement données à l'application. [ASPNETCORE_ENVIRONMENT=Development] fixe l'environnement : [appsettings.Development.json] est lu, et les ordres SQL s'affichent. Sur un serveur de production, sans ce fichier ni cette variable, l'environnement est [Production].

11.6.5. Config/AuthOptions.cs

// =========================================================================
// Config/AuthOptions.cs
// -------------------------------------------------------------------------
// La section "Auth" de appsettings.json, sous forme d'une classe typée.
//
// C'est le "modèle d'options" (options pattern) d'ASP.NET Core : dans
// Program.cs, builder.Services.Configure<AuthOptions>(...) relie la section
// à la classe ; une classe qui en a besoin reçoit ensuite IOptions<AuthOptions>
// par injection de dépendances. On évite ainsi de lire des chaînes de
// caractères un peu partout, comme le faisait process.env.JWT_SECRET.
// =========================================================================

namespace RdvMedecins.Config;

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

    // clé secrète utilisée pour signer / vérifier les jetons JWT. L'algorithme
    // HMAC-SHA256 exige une clé d'au moins 256 bits, soit 32 octets.
    public string JwtSecret { get; set; } = "";

    // durée de validité d'un jeton, en secondes ; le cookie qui le transporte
    // a la même durée de vie
    public int JwtExpiresInSeconds { get; set; } = 7200;

    // true en production (HTTPS) : le cookie d'authentification n'est jamais
    // envoyé sur une connexion non chiffrée
    public bool CookieSecure { get; set; }
}

Commentons ce code :

  • ligne 15 : la section [Auth] de [appsettings.json], sous la forme d'une classe ordinaire : une propriété par clé, de même nom. C'est le modèle des options ([options pattern]) d'[ASP.NET Core]. Dans [Program.cs], [Configure<AuthOptions>(...)] lie la section à la classe : le framework crée un objet [AuthOptions] et remplit ses propriétés avec les valeurs de la section, en les convertissant au bon type (["7200"] devient l'entier 7200, [false] le booléen [false]). Une classe qui en a besoin le reçoit par injection de dépendances, sous la forme d'un [IOptions<AuthOptions>], dont la propriété [Value] est l'objet rempli : c'est ce que fait [Auth/AuthService.cs], avec son paramètre [IOptions<AuthOptions> options] et son champ [auth = options.Value]. On évite ainsi de lire des chaînes de caractères un peu partout, comme le faisait [process.env.JWT_SECRET] dans la version [NestJS] : les noms des clés sont vérifiés par le compilateur, et les valeurs sont déjà converties ;
  • ligne 17 : le nom de la section, écrit une seule fois ;
  • lignes 21-29 : les trois valeurs de la section. Les valeurs initiales ([7200], [false] pour un booléen) sont celles qu'on obtient si la clé est absente de la configuration.

11.6.6. Config/ProtectionOptions.cs

// =========================================================================
// Config/ProtectionOptions.cs
// -------------------------------------------------------------------------
// La section "Protection" de appsettings.json : les seuils de la protection
// contre les robots (cf. Auth/LimiteurTentativesService.cs).
// =========================================================================

namespace RdvMedecins.Config;

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

    // connexion : nombre d'échecs (mauvais captcha ou mot de passe) autorisés
    // pour un même login depuis une même adresse IP, avant blocage de ce couple...
    public int LoginMaxEchecs { get; set; } = 5;

    // ... pendant cette durée (une adresse IP est aussi bloquée si elle cumule
    // 5 fois plus d'échecs, tous logins confondus)
    public int LoginBlocageMinutes { get; set; } = 15;

    // création de compte client : nombre maximal de comptes créés par heure
    // depuis une même adresse IP. En salle de cours, tous les postes sortent
    // souvent par la même adresse : augmenter si besoin.
    public int InscriptionMaxParHeure { get; set; } = 10;
}
  • lignes 10-25 : la section [Protection] de [appsettings.json], sur le même modèle. Elle est injectée, sous la forme d'un [IOptions<ProtectionOptions>], dans [Auth/LimiteurTentativesService.cs], qui y lit ses seuils.

11.7. Le démarrage de l'application

11.7.1. Program.cs

Le fichier [Program.cs] tient à la fois le rôle de [main.ts] (le démarrage, les middlewares) et celui de [app.module.ts] (quel service fournit quoi) de la version [NestJS]. Il suit le plan de tous les exemples : d'abord enregistrer les services, ensuite construire le pipeline des middlewares. Nous le découpons en extraits.

11.7.1.1. La configuration

// =========================================================================
// Program.cs
// -------------------------------------------------------------------------
// Le point d'entrée de l'application : l'équivalent à la fois de main.ts
// (démarrage, middlewares) et de app.module.ts (qui fournit quoi).
//
// Deux temps, toujours dans cet ordre :
//
//   1. builder.Services... : on ENREGISTRE les services dans le conteneur
//      d'injection de dépendances (l'équivalent des "providers" des modules
//      NestJS). Chaque service a une DURÉE DE VIE :
//        - Singleton : une seule instance pour toute l'application ;
//        - Scoped    : une instance par requête HTTP (le DbContext, et tout ce
//                      qui s'en sert) ;
//        - Transient : une nouvelle instance à chaque fois qu'on la demande.
//
//   2. app.Use... : on construit le PIPELINE des middlewares, que chaque
//      requête traverse dans l'ordre où ils sont déclarés.
// =========================================================================

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

var builder = WebApplication.CreateBuilder(args);

// =========================================================================
// 1. les services
// =========================================================================

// ---- la configuration typée (sections de appsettings.json, cf. Config/)
builder.Services.Configure<AuthOptions>(builder.Configuration.GetSection(AuthOptions.Section));
builder.Services.Configure<ProtectionOptions>(builder.Configuration.GetSection(ProtectionOptions.Section));
var authOptions = builder.Configuration.GetSection(AuthOptions.Section).Get<AuthOptions>() ?? new AuthOptions();
  • lignes 21-31 : les espaces de noms utilisés, en plus des [using] implicites : ceux du framework (localisation, EF Core, encodage HTML), et ceux de l'application, un par dossier ;
  • ligne 33 : le constructeur de l'application, comme dans tous les exemples. Il a déjà lu la configuration ([appsettings.json], [appsettings.Development.json], variables d'environnement...), accessible par [builder.Configuration] ;
  • lignes 40-41 : le modèle des options (cf. [Config/AuthOptions.cs]) : chaque section est liée à sa classe. [GetSection("Auth")] désigne la section ; [Configure<T>] enregistre le service [IOptions<T>] correspondant, que les services de l'authentification recevront par injection ;
  • ligne 42 : la même section, lue cette fois immédiatement sous forme d'objet ([Get<AuthOptions>()]), ou un objet aux valeurs par défaut si la section est absente. La configuration de [JwtBearer] (plus bas) a besoin de la clé secrète dès maintenant, pendant l'enregistrement des services : à ce moment, le conteneur d'injection de dépendances n'est pas encore construit, et aucun [IOptions<AuthOptions>] ne peut être injecté.

11.7.1.2. La base de données et les couches DAO et métier

// ---- Entity Framework Core : le DbContext, un par requête (Scoped).
// UseMySql : le fournisseur (provider) Pomelo pour MySQL / MariaDB. La
// version du serveur lui dit quel dialecte SQL générer.
var chaineConnexion = builder.Configuration.GetConnectionString("RdvMedecins")
    ?? throw new InvalidOperationException("ConnectionStrings:RdvMedecins manquante dans appsettings.json");
var versionServeur = ServerVersion.Parse(builder.Configuration["MySql:ServerVersion"] ?? "8.0.0-mysql");
builder.Services.AddDbContext<RdvMedecinsDbContext>(options => options.UseMySql(chaineConnexion, versionServeur));

// ---- couche DAO et couche métier : Scoped, comme le DbContext dont elles
// dépendent (un service ne doit jamais vivre PLUS LONGTEMPS que ses dépendances)
builder.Services.AddScoped<MedecinRepository>();
builder.Services.AddScoped<ClientRepository>();
builder.Services.AddScoped<CreneauRepository>();
builder.Services.AddScoped<RvRepository>();
builder.Services.AddScoped<UserRepository>();
// "quand on demande un IMetier, fournir un MetierService" (NestJS :
// { provide: METIER_TOKEN, useClass: MetierService }). En C#, l'interface
// elle-même sert de jeton d'injection.
builder.Services.AddScoped<IMetier, MetierService>();

// ---- le cache (singleton) et sa façade (scoped), cf. Web/ApplicationModel.cs
builder.Services.AddSingleton<CacheListes>();
builder.Services.AddScoped<ApplicationModel>();
  • lignes 4-5 : la chaîne de connexion, comme à l'exemple 28. L'opérateur [??] rend la valeur de gauche si elle n'est pas [null], sinon celle de droite : si la clé est absente, l'application s'arrête aussitôt, avec un message qui dit ce qui manque, plutôt qu'à la première requête avec une erreur obscure ;
  • ligne 6 : la version du serveur [MySQL], avec une valeur par défaut si la clé est absente ;
  • ligne 7 : le [DbContext] de l'application (cf. [Data/RdvMedecinsDbContext.cs]), un par requête ([Scoped]), avec le fournisseur [MySQL] (cf. exemple 28) ;
  • lignes 11-15 : la couche DAO : une classe par table (sauf [users], dont le repository sert à l'authentification). Elle est [Scoped], comme le [DbContext] qu'elle utilise. Un service ne doit jamais vivre plus longtemps que ses dépendances : un DAO [Singleton] garderait pour toujours le [DbContext] de la première requête, alors que celui-ci est détruit à la fin de cette requête (cf. chapitre 6) ;
  • ligne 19 : la couche métier. [AddScoped<IMetier, MetierService>()] se lit : « quand un service demande un [IMetier], lui fournir un [MetierService] ». Ceux qui utilisent la couche métier dépendent de l'interface, pas de la classe : on pourrait remplacer l'implémentation (par une version de test, par exemple) en ne modifiant que cette ligne. La version [NestJS] avait besoin pour cela d'un jeton ([METIER_TOKEN]), les interfaces [TypeScript] disparaissant à la compilation ; en [C#], l'interface elle-même sert de jeton ;
  • lignes 22-23 : le cache de la couche web (les listes des médecins et des clients) est de portée application : [Singleton], une seule instance partagée par toutes les requêtes (cf. exemple 23). Sa façade, [ApplicationModel], par laquelle passent tous les contrôleurs, utilise la couche métier : elle est donc [Scoped]. Le cache lui-même, qui ne dépend de rien, peut vivre plus longtemps qu'elle.

11.7.1.3. L'authentification, la traduction, l'encodage HTML

// ---- authentification
builder.Services.AddScoped<AuthService>();
builder.Services.AddSingleton<CaptchaService>(); // captchas en attente : en mémoire
builder.Services.AddSingleton<LimiteurTentativesService>(); // compteurs : en mémoire
builder.Services.AddSingleton<PasseInscriptionService>(); // laissez-passer : en mémoire
builder.Services.AddAuthentificationJwt(authOptions); // cf. Auth/AuthentificationJwt.cs
builder.Services.AddAuthorization();

// ---- traduction
builder.Services.AddSingleton<Traducteur>();

// ---- l'encodeur HTML de Razor. Par défaut, il remplace tout caractère non
// ASCII par une entité ("é" -> "&#xE9;") : la page s'affiche correctement,
// mais son code source devient illisible. On l'autorise à écrire tels quels
// les caractères de toutes les langues ; < > & " ' restent, eux, toujours échappés.
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
  • ligne 2 : le service de connexion (vérification du login et du mot de passe, fabrication du jeton, cf. exemple 25). Il utilise le repository des comptes : il est [Scoped] ;
  • lignes 3-5 : les captchas en attente, les compteurs d'échecs et les laissez-passer de création de compte (cf. exemple 27). Ces données sont gardées en mémoire, et doivent être les mêmes pour toutes les requêtes : les trois services sont des [Singleton]. La contrepartie : elles sont perdues à chaque redémarrage du serveur ;
  • ligne 6 : la configuration de [JwtBearer] (jeton lu dans le cookie, redirection vers la page de connexion...), vue à l'exemple 25. Elle est rangée dans une méthode d'extension de [IServiceCollection] (cf. [Auth/AuthentificationJwt.cs]), qui s'appelle comme une méthode du framework et allège d'autant [Program.cs] ;
  • ligne 7 : les services de l'autorisation, utilisés par [[Authorize]] et par le middleware [UseAuthorization] ;
  • ligne 10 : le service de traduction, qui lit les dictionnaires une fois pour toutes (cf. exemple 22) ;
  • ligne 16 : l'encodeur HTML de [Razor] laisse passer les caractères de toutes les langues, comme depuis l'exemple 15.

11.7.1.4. MVC et ses options

// ---- MVC : contrôleurs + vues Razor
builder.Services.AddControllersWithViews(options =>
{
    // SessionInvalideException -> retour à la page de connexion
    options.Filters.Add<HtmlExceptionFilter>();

    // avec les types "nullables" activés, MVC considère d'office une propriété
    // string (non nullable) comme obligatoire. On préfère le dire nous-mêmes,
    // explicitement, avec [Required] (cf. Models/Formulaires.cs).
    options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true;

    // les messages des erreurs de LIAISON (un texte là où un nombre est
    // attendu : version="abc"...) sont, eux aussi, des clés de traduction
    var messages = options.ModelBindingMessageProvider;
    messages.SetValueMustNotBeNullAccessor(_ => "ERRORS.INVALID_FORM");
    messages.SetAttemptedValueIsInvalidAccessor((_, _) => "ERRORS.INVALID_FORM");
    messages.SetValueIsInvalidAccessor(_ => "ERRORS.INVALID_FORM");
    messages.SetValueMustBeANumberAccessor(_ => "ERRORS.INVALID_FORM");
    messages.SetUnknownValueIsInvalidAccessor(_ => "ERRORS.INVALID_FORM");
    messages.SetNonPropertyAttemptedValueIsInvalidAccessor(_ => "ERRORS.INVALID_FORM");
    messages.SetNonPropertyUnknownValueIsInvalidAccessor(() => "ERRORS.INVALID_FORM");
    messages.SetNonPropertyValueMustBeANumberAccessor(() => "ERRORS.INVALID_FORM");
    messages.SetMissingBindRequiredValueAccessor(_ => "ERRORS.INVALID_FORM");
    messages.SetMissingKeyOrValueAccessor(() => "ERRORS.INVALID_FORM");
    messages.SetMissingRequestBodyRequiredValueAccessor(() => "ERRORS.INVALID_FORM");
});
  • ligne 2 : les contrôleurs et les vues [Razor]. La fonction passée en paramètre règle les options de MVC ;
  • ligne 5 : un filtre d'exceptions global (cf. exemple 24), qui s'applique à toutes les actions. Il ne traite qu'un cas : une [SessionInvalideException] (le jeton est valide, mais le compte qu'il désigne a été supprimé entre-temps) conduit à la page de connexion. Les autres exceptions continuent leur chemin, jusqu'au middleware [UseExceptionHandler] (plus bas) ;
  • ligne 10 : un piège des types « nullables ». Quand ils sont activés, MVC considère une propriété [string] (non nullable) d'un modèle comme obligatoire, exactement comme si elle portait l'attribut [[Required]], avec un message d'erreur en anglais que l'on ne maîtrise pas. Cette option supprime ce comportement implicite : un champ n'est obligatoire que s'il porte explicitement [[Required]], avec notre propre message (cf. [Models/Formulaires.cs]) ;
  • lignes 14-25 : les messages des erreurs de liaison (cf. exemple 19) : un texte là où un nombre est attendu ([version=abc]), une valeur absente... L'exemple 19 n'en redéfinissait que deux, en français ; l'étude de cas les redéfinit tous, et chacun rend la même clé de traduction, [ERRORS.INVALID_FORM], et non un texte : le message est traduit au moment de l'affichage, dans la langue de l'utilisateur. Le caractère [_] désigne un paramètre qu'on n'utilise pas (le nom du champ, la valeur saisie) : le message est le même quel que soit le champ. Ces erreurs ne devraient jamais arriver avec les formulaires de l'application : elles signalent une requête fabriquée à la main.

11.7.1.5. Le chargement du cache

var app = builder.Build();

// =========================================================================
// chargement du cache (listes des médecins et des clients). Les services
// Scoped n'existent que dans une "portée" (normalement : une requête) : on en
// crée une, le temps de ce chargement. Base inaccessible -> l'application
// s'arrête ici, avec l'exception de EF Core dans la console.
// =========================================================================
using (var portee = app.Services.CreateScope())
{
    await portee.ServiceProvider.GetRequiredService<ApplicationModel>().ChargerAsync();
}
  • ligne 1 : la construction de l'application. Le conteneur d'injection de dépendances est désormais créé : on ne peut plus enregistrer de service, mais on peut en obtenir ;
  • lignes 9-12 : les listes des médecins et des clients sont chargées dans le cache avant que le serveur ne commence à écouter. [ApplicationModel] est un service [Scoped] : il ne peut être créé qu'à l'intérieur d'une portée, et, en dehors d'une requête, il n'y en a pas. [CreateScope()] en crée une ; [GetRequiredService<ApplicationModel>()] y obtient le service (avec la couche métier, les repositories et le [DbContext] dont il dépend), et [ChargerAsync()] remplit le cache, qui est un [Singleton] et survit donc à la portée. L'instruction [using] détruit la portée, et le [DbContext] avec elle, à la sortie du bloc. Si la base est inaccessible, l'exception d'EF Core n'est pas interceptée : l'application s'arrête, avec le message d'erreur dans la console. C'est le premier signe d'une chaîne de connexion erronée ou d'un serveur [MySQL] arrêté.

11.7.1.6. Le pipeline des middlewares

// =========================================================================
// 2. le pipeline des middlewares - l'ORDRE compte
// =========================================================================

// ---- les erreurs deviennent des pages HTML (cf. Web/HtmlExceptionFilter.cs) :
// exception non traitée -> page 500 (l'exception est inscrite dans le journal) ;
// réponse 400 / 403 / 404 sans contenu -> page d'erreur correspondante
app.UseExceptionHandler("/erreur/500");
app.UseStatusCodePagesWithReExecute("/erreur/{0}");

// ---- fichiers statiques du dossier wwwroot/ : /css/..., /js/..., /lib/bootstrap/...
app.UseStaticFiles();

// ---- la langue de chaque requête : lue dans un cookie (cf. LangueController),
// français par défaut. Elle fixe CultureInfo.CurrentCulture (formats des dates)
// et CultureInfo.CurrentUICulture (langue des textes, cf. Web/Traducteur.cs).
var cultures = Traducteur.Langues.Select(l => new CultureInfo(l)).ToList();
var localisation = new RequestLocalizationOptions
{
    DefaultRequestCulture = new RequestCulture(Traducteur.LangueParDefaut),
    SupportedCultures = cultures,
    SupportedUICultures = cultures,
};
// seul le cookie décide (pas la langue du navigateur, ni un paramètre d'URL)
localisation.RequestCultureProviders = [new CookieRequestCultureProvider()];
app.UseRequestLocalization(localisation);

// ---- routage, puis authentification (qui est-ce ? -> HttpContext.User, à
// partir du jeton), puis autorisation (a-t-il le droit ? -> [Authorize])
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

// ---- les contrôleurs, avec leurs routes déclarées par attributs ([HttpGet("...")])
app.MapControllers();

app.Run();

Chaque requête traverse les middlewares dans l'ordre où ils sont déclarés (cf. chapitre 7) : cet ordre est donc important.

  • ligne 8 : le premier middleware enveloppe tous les autres. Si une exception n'a été traitée par personne (une erreur de programmation, une base devenue inaccessible...), il l'inscrit dans le journal et ré-exécute la requête avec l'adresse [/erreur/500] : l'utilisateur voit une page d'erreur de l'application, et non un message technique (cf. chapitre 7) ;
  • ligne 9 : une réponse d'erreur sans contenu (400, 403, 404) est transformée de la même façon en page, par l'action [/erreur/{code}] du contrôleur [ErreurController] (cf. exemples 24 et 26). La requête ré-exécutée traverse à nouveau les middlewares suivants : la page d'erreur est donc traduite, et connaît l'utilisateur connecté ;
  • ligne 12 : les fichiers statiques du dossier [wwwroot] (cf. exemple 15) : la feuille de style, le script de la page et [Bootstrap], rangé ici dans [wwwroot/lib/bootstrap]. Placé tôt dans le pipeline, ce middleware répond aux demandes de fichiers sans les faire passer par la localisation ni l'authentification ;
  • lignes 17-23 : la configuration de la localisation, comme à l'exemple 22. Les langues acceptées et la langue par défaut sont celles du service de traduction ([Traducteur.Langues], [Traducteur.LangueParDefaut]) : elles ne sont écrites qu'à un seul endroit ;
  • ligne 25 : seul le cookie déposé par [LangueController] décide de la langue (cf. exemple 22). La syntaxe [[...]] est une expression de collection : ici une liste d'un seul élément ;
  • ligne 26 : le middleware de localisation, qui fixe la langue de chaque requête ;
  • lignes 30-32 : le routage, puis l'authentification, puis l'autorisation. Le routage choisit l'action qui traitera la requête, d'après les routes déclarées par attributs. Les exemples ne l'appelaient pas : [WebApplication] l'ajoute alors de lui-même en tête du pipeline. L'appeler explicitement rend l'ordre visible : l'autorisation doit venir après le routage, car c'est l'action choisie qui porte l'attribut [[Authorize]] à vérifier ; et après l'authentification, qui établit, à partir du jeton, qui est l'utilisateur (cf. exemple 25) ;
  • ligne 35 : les contrôleurs et leurs routes, déclarées par attributs ([[HttpGet("agenda")]]...) ;
  • ligne 37 : le serveur démarre et attend les requêtes, sur le port 8081.

11.8. Les entités

Les entités EF Core font correspondre les tables de la base et des classes [C#] (cf. exemple 28). Elles reprennent une à une les entités [TypeORM] de la version [NestJS] : les attributs [[Table]], [[Column]], [[Key]], [[ForeignKey]] y jouent le rôle des décorateurs [@Entity], [@Column], [@PrimaryGeneratedColumn], [@JoinColumn]. La base n'est pas créée par EF Core, mais par le script SQL [database/dbrdvmedecins2.sql] : les entités doivent simplement décrire fidèlement les tables qui existent déjà.

11.8.1. Entities/AbstractEntity.cs

// =========================================================================
// Entities/AbstractEntity.cs
// -------------------------------------------------------------------------
// Les deux champs communs à toutes les entités : l'identifiant [id] et le
// numéro de version [version] (verrou optimiste).
//
// Comme avec TypeORM (et @MappedSuperclass en JPA), il suffit qu'une classe
// ABSTRAITE soit la mère des classes entités : EF Core ne crée pas de table
// pour elle, mais ajoute ses propriétés aux tables des classes filles.
//
// Les attributs [Key], [Column]... viennent de l'espace de noms standard
// System.ComponentModel.DataAnnotations : EF Core les lit pour construire
// son "modèle" (quelle classe va dans quelle table, quelle propriété dans
// quelle colonne). Ils jouent le rôle des décorateurs @PrimaryGeneratedColumn,
// @Column de TypeORM.
// =========================================================================

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

namespace RdvMedecins.Entities;

public abstract class AbstractEntity
{
    // [Key] : la clé primaire. Une clé entière est, par convention, générée
    // par la base (colonne AUTO_INCREMENT) : EF Core relit sa valeur après
    // chaque INSERT.
    [Key]
    [Column("id")]
    public int Id { get; set; }

    // [ConcurrencyCheck] : le verrou optimiste. Chaque UPDATE généré par EF
    // Core porte la condition "WHERE id = ... AND version = <valeur lue>".
    // Si quelqu'un a modifié la ligne entre-temps, aucune ligne n'est mise à
    // jour et EF Core lève une DbUpdateConcurrencyException.
    //
    // Différence avec @VersionColumn de TypeORM : EF Core n'INCRÉMENTE pas
    // lui-même un numéro de version entier. C'est notre DbContext qui le fait,
    // juste avant chaque enregistrement (cf. Data/RdvMedecinsDbContext.cs).
    [Column("version")]
    [ConcurrencyCheck]
    public int Version { get; set; }
}
  • ligne 23 : une classe abstraite, sans attribut [[Table]] : elle ne correspond à aucune table, mais ses propriétés sont ajoutées à celles des tables de ses classes filles. Toutes les entités de l'application en héritent, et ont donc un identifiant et une version ;
  • lignes 28-30 : la clé primaire ([[Key]]), colonne [id]. Une clé entière est, par convention d'EF Core, générée par la base ([AUTO_INCREMENT]) : EF Core relit sa valeur après chaque [INSERT] ;
  • lignes 40-42 : la version de la ligne, pour le verrou optimiste (cf. exemple 28). [[ConcurrencyCheck]] ajoute à chaque [UPDATE] la condition [AND version = <valeur lue>]. Contrairement à [@VersionColumn] de [TypeORM], EF Core n'augmente pas lui-même la version : c'est le [DbContext] qui s'en charge (cf. plus bas).

11.8.2. Entities/Personne.cs

// =========================================================================
// Entities/Personne.cs
// -------------------------------------------------------------------------
// Classe mère (sans table) des entités [Medecin] et [Client] : un titre, un
// nom, un prénom - et le compte utilisateur associé (colonne [user]).
//
// Une relation déclarée dans une classe abstraite est héritée par les
// classes filles exactement comme les colonnes : EF Core ajoute donc la
// relation [User] à la table [medecins] ET à la table [clients].
// =========================================================================

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

namespace RdvMedecins.Entities;

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

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

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

    // -----------------------------------------------------------------------
    // La relation vers le compte utilisateur, en DEUX propriétés - c'est la
    // façon habituelle de faire avec EF Core :
    //   - IdUser : la clé étrangère elle-même (colonne [user]). On peut la
    //     lire sans charger le User complet (TypeORM : @RelationId) ;
    //   - User   : la "propriété de navigation", l'objet User lui-même. Elle
    //     n'est remplie que si on la demande dans la requête (.Include(...),
    //     cf. Repositories/), sinon elle vaut null.
    // [ForeignKey] relie les deux. Le caractère "un pour un" de la relation
    // (colonne [user] UNIQUE) est précisé dans RdvMedecinsDbContext.
    // -----------------------------------------------------------------------
    [Column("user")]
    public int IdUser { get; set; }

    // "= null!" : on promet au compilateur de ne lire cette propriété que
    // lorsqu'elle a été chargée (Include) ou affectée
    [ForeignKey(nameof(IdUser))]
    public User User { get; set; } = null!;
}
  • ligne 17 : la classe mère, abstraite elle aussi, de [Medecin] et [Client], elle-même fille de [AbstractEntity]. Médecins et clients ont les mêmes colonnes : on les écrit une seule fois ;
  • lignes 19-29 : le titre (Mme, Mr...), le nom et le prénom. [[MaxLength]] donne la taille de la colonne. Ces propriétés sont de type [string] (et non [string?]) : elles ne doivent jamais valoir [null], et le compilateur exige donc une valeur initiale, ici la chaîne vide [""] ;
  • lignes 42-43 : la clé étrangère vers le compte utilisateur, colonne [user]. On peut lire l'identifiant du compte sans charger le compte lui-même : c'est ce que faisait [@RelationId] dans la version [NestJS] ;
  • lignes 47-48 : la propriété de navigation : l'objet [User] lui-même. [[ForeignKey(nameof(IdUser))]] indique quelle propriété contient la clé étrangère ([nameof(IdUser)] vaut la chaîne ["IdUser"], mais le compilateur vérifie que la propriété existe). Une propriété de navigation n'est remplie que si la requête le demande ([Include], cf. la couche DAO) ; sinon elle vaut [null]. L'écriture [= null!] fait taire l'avertissement du compilateur : on promet de ne lire la propriété que lorsqu'elle a été chargée. Le caractère « un pour un » de la relation est précisé dans le [DbContext]. Une relation déclarée dans une classe mère est héritée, comme les colonnes : les tables [medecins] et [clients] ont chacune leur relation vers [users].

11.8.3. Entities/Medecin.cs

// =========================================================================
// Entities/Medecin.cs
// -------------------------------------------------------------------------
// Un médecin du cabinet (table [medecins]).
//
// Contrainte d'unicité SQL : unq_medecins_prenom_nom (deux médecins ne
// peuvent pas porter les mêmes prénom et nom) - créée par le script SQL,
// reconnue par son nom dans la couche métier.
// =========================================================================

using System.ComponentModel.DataAnnotations.Schema;

namespace RdvMedecins.Entities;

[Table("medecins")]
public class Medecin : Personne
{
    // relation inverse (facultative) : les créneaux horaires de ce médecin.
    // Elle montre la relation "1 médecin -> plusieurs créneaux" dans les deux
    // sens ; comme toute navigation, elle n'est remplie que sur demande.
    public List<Creneau> Creneaux { get; set; } = [];
}
  • ligne 15 : la classe [Medecin] correspond à la table [medecins]. Elle hérite de [Personne] toutes ses propriétés. La contrainte d'unicité [unq_medecins_prenom_nom], citée dans le commentaire, est créée par le script SQL ; la couche métier la reconnaît à son nom (cf. exemple 28) ;
  • ligne 21 : la relation inverse de celle de [Creneau] vers son médecin : les créneaux de ce médecin. EF Core associe de lui-même les deux navigations ([Medecin.Creneaux] et [Creneau.Medecin]), qui décrivent la même relation « un médecin, plusieurs créneaux » vue de chaque côté. Comme toute navigation, elle n'est remplie que sur demande ; l'application ne s'en sert d'ailleurs pas : elle lit les créneaux par la couche DAO. [[]] est une expression de collection : ici, une liste vide.

11.8.4. Entities/Client.cs

// =========================================================================
// Entities/Client.cs
// -------------------------------------------------------------------------
// Attention : [Client] désigne ici un PATIENT du cabinet médical, pas un
// client HTTP ! C'est le vocabulaire du domaine métier original, conservé
// tel quel (table [clients]).
//
// Contrainte d'unicité SQL : unq_clients_prenom_nom (cf. Medecin.cs).
// =========================================================================

using System.ComponentModel.DataAnnotations.Schema;

namespace RdvMedecins.Entities;

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

Un client, c'est-à-dire un patient du cabinet (et non un client HTTP) : une [Personne] de la table [clients] (ligne 15), sans propriété supplémentaire.

11.8.5. Entities/Creneau.cs

// =========================================================================
// Entities/Creneau.cs
// -------------------------------------------------------------------------
// Un créneau horaire d'un médecin (table [creneaux]) : 8h00 - 8h20, etc.
// =========================================================================

using System.ComponentModel.DataAnnotations.Schema;

namespace RdvMedecins.Entities;

[Table("creneaux")]
public class Creneau : AbstractEntity
{
    [Column("hdebut")] public int Hdebut { get; set; } // heure de début du créneau
    [Column("mdebut")] public int Mdebut { get; set; } // minute de début du créneau
    [Column("hfin")] public int Hfin { get; set; } // heure de fin du créneau
    [Column("mfin")] public int Mfin { get; set; } // minute de fin du créneau

    // plusieurs créneaux pour un même médecin : clé étrangère [id_medecin]
    // + navigation [Medecin] (même technique que Personne.IdUser / User)
    [Column("id_medecin")]
    public int IdMedecin { get; set; }

    [ForeignKey(nameof(IdMedecin))]
    public Medecin Medecin { get; set; } = null!;
}
  • lignes 14-17 : l'heure et la minute de début et de fin du créneau. L'attribut et la propriété sont écrits sur la même ligne : c'est permis, et plus compact pour ces quatre colonnes semblables ;
  • lignes 21-25 : la relation « plusieurs à un » vers le médecin : plusieurs créneaux pour un médecin. Même technique que dans [Personne] : la clé étrangère [IdMedecin] (colonne [id_medecin]) et la navigation [Medecin], reliées par [[ForeignKey]]. EF Core n'a besoin d'aucune autre précision : une référence d'un côté, une liste de l'autre, c'est une relation « un à plusieurs ».

11.8.6. Entities/Rv.cs

// =========================================================================
// Entities/Rv.cs
// -------------------------------------------------------------------------
// Un rendez-vous (table [rv]) : un client, sur un créneau, un jour donné.
//
// Contrainte d'unicité SQL : unq1_rv (jour, id_creneau) - un créneau n'est
// réservé qu'une fois par jour. Créée par le script SQL, reconnue par son
// nom dans la couche métier.
// =========================================================================

using System.ComponentModel.DataAnnotations.Schema;

namespace RdvMedecins.Entities;

[Table("rv")]
public class Rv : AbstractEntity
{
    // DateOnly : une date SANS heure, qui correspond exactement au type SQL
    // DATE de la colonne [jour]. (Le projet NestJS utilisait une chaîne
    // "yyyy-MM-dd" : JavaScript n'a pas de type "date sans heure".)
    // Deux DateOnly se comparent directement : jour < aujourdhui...
    [Column("jour")]
    public DateOnly Jour { get; set; }

    [Column("id_client")]
    public int IdClient { get; set; }

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

    [Column("id_creneau")]
    public int IdCreneau { get; set; }

    [ForeignKey(nameof(IdCreneau))]
    public Creneau Creneau { get; set; } = null!;
}
  • ligne 15 : la table des rendez-vous. Sa contrainte d'unicité [unq1_rv (jour, id_creneau)] garantit qu'un créneau n'est réservé qu'une fois par jour ;
  • lignes 22-23 : le jour du rendez-vous. Le type [DateOnly] (une date sans heure) correspond exactement au type SQL [DATE] : pas d'heure, donc pas de problème de fuseau horaire. La version [NestJS] manipulait une chaîne [aaaa-mm-jj], faute d'un tel type en [JavaScript]. Deux [DateOnly] se comparent directement avec [<], [>=]..., en [C#] comme dans les requêtes [LINQ] ;
  • lignes 25-35 : le client et le créneau du rendez-vous : deux relations « plusieurs à un », écrites chacune avec une clé étrangère et une navigation. Pour créer un rendez-vous, il suffit de renseigner les clés étrangères [IdClient] et [IdCreneau] : c'est ce que fait la couche métier.

11.8.7. Entities/Role.cs

// =========================================================================
// Entities/Role.cs
// -------------------------------------------------------------------------
// Les trois rôles d'un utilisateur (colonne users.role) :
//   - ADMIN  : l'administrateur - rendez-vous + gestion des médecins et clients ;
//   - DOCTOR : un médecin - réserve et annule des rendez-vous ;
//   - USER   : un client (patient) - réserve pour lui-même, gère son compte.
//
// En C#, une énumération (enum) plutôt qu'un type union de chaînes
// ('ADMIN' | 'DOCTOR' | 'USER') : le compilateur refuse toute autre valeur.
// EF Core la range en base sous forme de TEXTE ('ADMIN'...), comme le fait
// le script SQL (cf. HasConversion<string>() dans RdvMedecinsDbContext).
// =========================================================================

namespace RdvMedecins.Entities;

public enum Role
{
    ADMIN,
    DOCTOR,
    USER,
}
  • lignes 17-21 : les trois rôles, sous la forme d'une énumération ([enum]) : une variable de type [Role] ne peut valoir que [Role.ADMIN], [Role.DOCTOR] ou [Role.USER], et le compilateur refuse toute autre valeur. C'est l'équivalent du type union de chaînes ['ADMIN' | 'DOCTOR' | 'USER'] de la version [NestJS]. Par défaut, EF Core rangerait une énumération en base sous forme de nombre (0, 1, 2) ; la colonne [role] contient du texte : la conversion est demandée dans le [DbContext].

11.8.8. Entities/User.cs

// =========================================================================
// Entities/User.cs
// -------------------------------------------------------------------------
// Un utilisateur autorisé à se connecter à l'application (table [users]).
// Chaque médecin et chaque client possède SON compte utilisateur (colonne
// [user] des tables [medecins] et [clients], cf. Personne.cs).
//
// Contrainte d'unicité SQL : unq_users_login (un login n'appartient qu'à un
// seul compte). Elle est créée par le script database/dbrdvmedecins2.sql ;
// la couche métier la reconnaît par son NOM (cf. Metier/MetierService.cs).
// =========================================================================

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

namespace RdvMedecins.Entities;

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

    // le mot de passe HACHÉ (bcrypt) - jamais en clair
    [Column("password")]
    [MaxLength(100)]
    public string Password { get; set; } = "";

    // le nom affiché une fois connecté - pour un médecin ou un client :
    // "prénom NOM" (ex : "Marie PELISSIER")
    [Column("nom")]
    [MaxLength(40)]
    public string Nom { get; set; } = "";

    // ADMIN, DOCTOR ou USER, rangé en base sous forme de texte
    [Column("role")]
    public Role Role { get; set; }
}
  • ligne 18 : les comptes des utilisateurs autorisés à se connecter : l'administrateur, chaque médecin et chaque client. La contrainte [unq_users_login] interdit deux comptes de même login ;
  • lignes 21-23 : le login ;
  • lignes 26-28 : le hachage [bcrypt] du mot de passe, jamais le mot de passe lui-même (cf. exemple 25) ;
  • lignes 32-34 : le nom affiché dans la barre de navigation une fois connecté (« Marie PELISSIER ») ;
  • lignes 37-38 : le rôle, de type [Role]. Comme la classe [User] ne déclare pas de navigation vers le médecin ou le client qui la possède, la relation n'est connue que du côté de [Personne].

11.9. Le DbContext

Le [DbContext] de l'application reprend celui de l'exemple 28, avec cinq tables au lieu d'une, et quelques précisions sur le modèle que les attributs ne savent pas exprimer.

11.9.1. Data/RdvMedecinsDbContext.cs

// =========================================================================
// Data/RdvMedecinsDbContext.cs
// -------------------------------------------------------------------------
// Le DbContext est la pièce centrale d'Entity Framework Core. Il joue à la
// fois le rôle :
//   - de la DataSource de TypeORM : la connexion à la base ;
//   - des Repository<T> de TypeORM : un DbSet<T> par table, que l'on
//     interroge en LINQ (db.Medecins.Where(...)...) ;
//   - d'une "unité de travail" (unit of work) : il SUIT les entités qu'il a
//     chargées (change tracking). Quand on modifie une propriété, rien ne
//     part en base ; c'est SaveChangesAsync() qui envoie, en une fois, les
//     INSERT / UPDATE / DELETE nécessaires.
//
// Durée de vie : un DbContext par requête HTTP (enregistrement "scoped",
// cf. Program.cs). Tous les repositories d'une même requête partagent donc
// le MÊME DbContext - et par conséquent la même connexion, et la même
// transaction quand la couche métier en ouvre une.
//
// La correspondance classes <-> tables est décrite surtout par les attributs
// des entités ([Table], [Column], [ForeignKey]...) ; OnModelCreating complète
// ce que les attributs ne savent pas dire.
// =========================================================================

using Microsoft.EntityFrameworkCore;
using RdvMedecins.Entities;

namespace RdvMedecins.Data;

public class RdvMedecinsDbContext(DbContextOptions<RdvMedecinsDbContext> options) : DbContext(options)
{
    // une "table" par entité. Set<T>() renvoie le DbSet que EF Core a créé
    // pour le type T.
    public DbSet<Medecin> Medecins => Set<Medecin>();
    public DbSet<Client> Clients => Set<Client>();
    public DbSet<Creneau> Creneaux => Set<Creneau>();
    public DbSet<Rv> Rvs => Set<Rv>();
    public DbSet<User> Users => Set<User>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // un médecin (un client) a UN compte, et un compte appartient à au plus
        // UNE personne (colonne [user] UNIQUE dans le script SQL) : relation
        // "un pour un", dont la clé étrangère est portée par medecins.user
        // (clients.user). Sans cette précision, EF Core supposerait "plusieurs
        // médecins pour un même compte" - ce qui fonctionnerait aussi, mais
        // décrirait mal le modèle.
        modelBuilder.Entity<Medecin>().HasOne(m => m.User).WithOne().HasForeignKey<Medecin>(m => m.IdUser);
        modelBuilder.Entity<Client>().HasOne(c => c.User).WithOne().HasForeignKey<Client>(c => c.IdUser);

        // le rôle est une énumération C#, rangée en base sous forme de texte
        // ('ADMIN', 'DOCTOR', 'USER') et non de nombre (0, 1, 2)
        modelBuilder.Entity<User>().Property(u => u.Role).HasConversion<string>();
    }

    // -----------------------------------------------------------------------
    // Le verrou optimiste (cf. AbstractEntity.Version) : juste avant chaque
    // enregistrement, on incrémente la version de toute entité MODIFIÉE.
    // EF Core compare l'ANCIENNE valeur (celle lue en base) dans la clause
    // WHERE de l'UPDATE, et écrit la nouvelle :
    //     UPDATE medecins SET nom = ..., version = 4 WHERE id = 1 AND version = 3
    //
    // ChangeTracker : le "suivi des modifications" du DbContext. Il sait, pour
    // chaque entité chargée, si elle a été ajoutée (Added), modifiée
    // (Modified), supprimée (Deleted) ou laissée telle quelle (Unchanged).
    //
    // Toutes les variantes de SaveChanges(Async) aboutissent à cette méthode.
    // -----------------------------------------------------------------------
    public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
    {
        foreach (var entree in ChangeTracker.Entries<AbstractEntity>())
        {
            if (entree.State == EntityState.Modified)
            {
                entree.Entity.Version++;
            }
        }
        return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
    }
}

Commentons ce code :

  • ligne 29 : comme à l'exemple 28, le [DbContext] reçoit ses options (fournisseur [MySQL], chaîne de connexion) de [AddDbContext], dans [Program.cs]. Il est enregistré avec la durée de vie [Scoped] : un [DbContext] par requête, partagé par tous les repositories de cette requête, donc une seule connexion et, le cas échéant, une seule transaction. Il tient ainsi à la fois le rôle de la [DataSource] et des [Repository<T>] de [TypeORM] ;
  • lignes 33-37 : un [DbSet] par table. Il n'y en a ni pour [AbstractEntity] ni pour [Personne] : ces classes abstraites n'ont pas de table. EF Core ne les intègre pas au modèle comme entités ; il se contente d'ajouter leurs propriétés à celles des classes filles ;
  • ligne 39 : [OnModelCreating] est appelée une fois, quand EF Core construit son modèle (la description des tables et des relations). Elle complète les attributs des entités, par une API fluide : des appels de méthodes enchaînés ;
  • lignes 47-48 : la relation entre un médecin (un client) et son compte est « un pour un » : [HasOne(m => m.User)] (un médecin a un compte), [WithOne()] (un compte a au plus un médecin ; les parenthèses vides indiquent que [User] n'a pas de navigation en retour), [HasForeignKey<Medecin>(m => m.IdUser)] (la clé étrangère est dans la table [medecins]). Sans ces lignes, EF Core supposerait une relation « plusieurs à un » (plusieurs médecins pour un même compte), ce qui fonctionnerait aussi, mais décrirait mal le modèle : dans la base, la colonne [user] est unique ;
  • ligne 52 : [HasConversion<string>()] range le rôle en base sous forme de texte (['ADMIN']...), et le reconvertit en [Role] à la lecture ;
  • lignes 68-77 : l'augmentation de la version de chaque entité modifiée, juste avant l'enregistrement, comme à l'exemple 28. [ChangeTracker.Entries<AbstractEntity>()] parcourt toutes les entités suivies dont la classe dérive de [AbstractEntity] : grâce à la classe mère commune, cette seule boucle vaut pour les cinq tables. On redéfinit la variante de [SaveChangesAsync] à deux paramètres, parce que c'est elle qu'appelle finalement [SaveChangesAsync()] sans paramètre. EF Core compare l'ancienne version dans la clause [WHERE] de l'[UPDATE], et écrit la nouvelle : si la ligne a changé entre-temps, aucune ligne n'est modifiée, et EF Core lève une [DbUpdateConcurrencyException].

11.10. Le domaine : l'agenda

Le dossier [Domain] contient deux classes qui ne correspondent à aucune table : elles décrivent l'agenda d'un médecin pour un jour, calculé par la couche métier ([MetierService.GetAgendaMedecinJourAsync]) et affiché par le contrôleur de l'agenda.

11.10.1. Domain/CreneauMedecinJour.cs

// =========================================================================
// Domain/CreneauMedecinJour.cs
// -------------------------------------------------------------------------
// Un objet "métier" (sans table) qui associe un créneau horaire au
// rendez-vous éventuellement pris sur ce créneau, un jour donné.
//
// Un "record" C# est une classe dont les propriétés sont fixées une fois
// pour toutes par le constructeur (objet immuable) : exactement ce qu'il faut
// pour un objet calculé, que personne ne modifie ensuite.
// =========================================================================

using RdvMedecins.Entities;

namespace RdvMedecins.Domain;

// Rv? : null si le créneau est libre ce jour-là
public record CreneauMedecinJour(Creneau Creneau, Rv? Rv);
  • ligne 17 : un créneau, et le rendez-vous pris sur ce créneau ce jour-là, [null] si le créneau est libre (d'où le type [Rv?]). Un [record] écrit ainsi, avec ses paramètres entre parenthèses (record positionnel), déclare en une ligne une classe, son constructeur et ses propriétés [Creneau] et [Rv], qu'on ne peut plus modifier après la construction : c'est l'équivalent de la syntaxe [constructor(public creneau: Creneau, ...)] de la version [NestJS]. Nous avons déjà rencontré les records pour les données transmises à la couche métier (cf. exemple 28).

11.10.2. Domain/AgendaMedecinJour.cs

// =========================================================================
// Domain/AgendaMedecinJour.cs
// -------------------------------------------------------------------------
// L'agenda complet d'un médecin pour un jour donné : la liste de ses
// créneaux horaires, chacun libre ou occupé par un rendez-vous. Calculé par
// MetierService.GetAgendaMedecinJourAsync, transformé en tableau HTML par
// AgendaController.
// =========================================================================

using RdvMedecins.Entities;

namespace RdvMedecins.Domain;

public record AgendaMedecinJour(Medecin Medecin, DateOnly Jour, List<CreneauMedecinJour> CreneauxMedecinJour);

L'agenda (ligne 14) : un médecin, un jour, et la liste de ses créneaux, libres ou occupés, dans l'ordre chronologique.

11.11. La couche DAO

Une classe par table, comme à l'exemple 28 : des méthodes courtes, sans règle de gestion, qui ne font qu'interroger ou modifier la base. On les appelle ici des repositories (« dépôts »), comme dans la version [NestJS]. Deux différences avec celle-ci :

  • les requêtes sont écrites en [LINQ], qu'EF Core traduit en SQL : on navigue d'une entité à l'autre par les propriétés de navigation, et EF Core écrit lui-même les jointures ;
  • les méthodes n'ont pas de paramètre [manager] pour les transactions : tous les repositories d'une requête partagent le même [DbContext], et une transaction ouverte sur ce [DbContext] par la couche métier englobe automatiquement toutes leurs requêtes.

Toutes les méthodes sont asynchrones et rendent une [Task] : l'appelant l'attend avec [await]. Beaucoup sont écrites sous la forme [=> expression] : elles rendent directement la [Task] de la requête, sans [async] ni [await], puisqu'elles n'ont rien à faire après.

11.11.1. Repositories/PersonneRepository.cs

Dans la version [NestJS], les repositories des médecins et des clients étaient identiques, à une ligne près. En [C#], une classe générique les écrit une seule fois.

// =========================================================================
// Repositories/PersonneRepository.cs
// -------------------------------------------------------------------------
// La couche DAO commune aux médecins et aux clients.
//
// Les deux DAO du projet NestJS (MedecinRepository, ClientRepository)
// étaient identiques à une ligne près. En C#, une classe GÉNÉRIQUE les écrit
// une seule fois : PersonneRepository<T>, où T vaut Medecin ou Client.
// "where T : Personne" garantit que T possède Nom, Prenom, User...
//
// Transactions : les méthodes n'ont plus besoin du paramètre EntityManager
// facultatif de TypeORM. Tous les repositories d'une requête partagent le
// MÊME DbContext (cf. Data/RdvMedecinsDbContext.cs) : quand la couche métier
// ouvre une transaction sur ce DbContext, toutes leurs requêtes en font
// automatiquement partie.
// =========================================================================

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

namespace RdvMedecins.Repositories;

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

    // la "table" des personnes de type T (db.Medecins ou db.Clients)
    protected DbSet<T> Table => db.Set<T>();

    // liste triée par nom puis prénom, compte utilisateur compris.
    // Include(p => p.User) : ramène AUSSI le compte, par une jointure
    // (SELECT ... FROM medecins INNER JOIN users ...). Sans lui, p.User
    // vaudrait null.
    // AsNoTracking() : ces listes sont gardées en mémoire par le cache
    // (Web/ApplicationModel.cs), bien au-delà de la requête HTTP : inutile
    // (et coûteux) que le DbContext les suive.
    public Task<List<T>> FindAllAsync() =>
        Table.AsNoTracking().Include(p => p.User).OrderBy(p => p.Nom).ThenBy(p => p.Prenom).ToListAsync();

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

    // la personne dont le compte utilisateur est [idUser], ou null
    public Task<T?> FindByUserIdAsync(int idUser) =>
        Table.Include(p => p.User).FirstOrDefaultAsync(p => p.IdUser == idUser);

    // la personne qui porte ces prénom et nom, s'il y en a une. La comparaison
    // est faite par MySQL, donc avec la collation de la base
    // (utf8mb4_unicode_ci : sans tenir compte des majuscules ni des accents) -
    // exactement la même règle que celle de la contrainte d'unicité SQL.
    public Task<T?> FindByPrenomNomAsync(string prenom, string nom) =>
        Table.FirstOrDefaultAsync(p => p.Prenom == prenom && p.Nom == nom);

    // création (Id = 0 : pas encore en base) ou modification (entité déjà
    // suivie par le DbContext : SaveChanges détecte lui-même ce qui a changé)
    public async Task<T> SaveAsync(T personne)
    {
        if (personne.Id == 0)
        {
            Table.Add(personne);
        }
        await db.SaveChangesAsync();
        return personne;
    }

    // ExecuteDeleteAsync (EF Core 7+) : un DELETE envoyé directement à la base,
    // sans charger les entités d'abord :  DELETE FROM medecins WHERE id = ?
    public Task DeleteByIdAsync(int id) => Table.Where(p => p.Id == id).ExecuteDeleteAsync();
}

// les deux DAO concrets : tout est hérité
public class MedecinRepository(RdvMedecinsDbContext db) : PersonneRepository<Medecin>(db);

public class ClientRepository(RdvMedecinsDbContext db) : PersonneRepository<Client>(db)
{
    // -----------------------------------------------------------------------
    // lit un client en posant un VERROU sur sa ligne jusqu'à la fin de la
    // transaction en cours (SELECT ... FOR UPDATE). Une seconde transaction
    // qui demande le même verrou ATTEND que la première soit terminée.
    // Utilisé pour la prise de rendez-vous (cf. MetierService.AjouterRvAsync) :
    // deux réservations simultanées pour le même patient sont ainsi traitées
    // l'une APRÈS l'autre, et la seconde voit le rendez-vous créé par la première.
    //
    // LINQ ne sait pas exprimer "FOR UPDATE" : on écrit la requête SQL
    // nous-mêmes. FromSqlInterpolated transforme {id} en PARAMÈTRE SQL (pas
    // de concaténation de chaînes, donc pas d'injection SQL possible).
    // -----------------------------------------------------------------------
    public async Task<Client?> VerrouillerAsync(int id)
    {
        var clients = await Table.FromSqlInterpolated($"SELECT * FROM clients WHERE id = {id} FOR UPDATE").ToListAsync();
        return clients.FirstOrDefault();
    }
}

Commentons ce code :

  • ligne 24 : [PersonneRepository<T>] est une classe générique : [T] est un paramètre de type, remplacé par un type réel ([Medecin] ou [Client]) quand on utilise la classe, comme [T] dans [List<T>]. La contrainte [where T : Personne] n'accepte pour [T] que [Personne] ou ses classes filles : le compilateur sait alors qu'un [T] possède [Id], [Nom], [Prenom], [IdUser], [User]... et accepte [p.Nom] dans les requêtes. La classe est abstraite : on n'utilise que ses deux classes filles, plus bas ;
  • ligne 26 : le paramètre [db] du constructeur principal n'est visible que dans cette classe. On le recopie dans un champ [protected], pour que les classes filles ([ClientRepository]) y aient accès ;
  • ligne 29 : la table des personnes de type [T]. [db.Set<T>()] rend le [DbSet] de ce type : [db.Medecins] pour [T = Medecin], [db.Clients] pour [T = Client] ;
  • lignes 38-39 : toutes les personnes, triées par nom puis prénom ([OrderBy], puis [ThenBy] pour départager les noms égaux). [Include(p => p.User)] charge aussi le compte de chaque personne, par une jointure ([INNER JOIN users]) : sans lui, [p.User] vaudrait [null]. Le compte sert à afficher le login dans les listes. [AsNoTracking()] (cf. exemple 28) : ces listes sont gardées en mémoire par [ApplicationModel] bien après la fin de la requête ; il est inutile que le [DbContext] les suive. Les expressions comme [p => p.Nom] ne sont pas exécutées en [C#] : EF Core les analyse pour les traduire en SQL ([ORDER BY nom, prenom]) ;
  • lignes 41-42 : une personne et son compte, ou [null]. Ici, pas de [AsNoTracking()] : la couche métier modifie la personne lue (et son compte), puis appelle [SaveAsync] ; le [DbContext], qui suit les deux objets, sait ce qui a changé ;
  • lignes 45-46 : la personne dont le compte est [idUser]. C'est ainsi qu'un patient connecté, dont le jeton contient l'identifiant du compte, retrouve sa fiche de client. Le filtre porte sur la clé étrangère elle-même ([WHERE clients.user = ...]) ; la jointure avec [users] ne sert qu'à charger le compte ([Include]) ;
  • lignes 52-53 : un homonyme éventuel. L'opérateur [==] est traduit en [=] SQL : la comparaison est faite par [MySQL], selon la collation de la base, sans tenir compte des majuscules ni des accents, exactement comme la contrainte d'unicité (cf. exemple 28). Une comparaison faite en [C#] tiendrait compte des majuscules ;
  • lignes 57-64 : la création ou la modification, comme à l'exemple 28 : une personne nouvelle ([Id] à 0) est ajoutée au [DbSet], une personne déjà suivie n'a besoin de rien. Attention : [SaveChangesAsync] enregistre toutes les modifications en attente du [DbContext], pas seulement celles de [personne] : le compte modifié en même temps part dans le même appel ;
  • ligne 69 : la suppression par [ExecuteDeleteAsync] (cf. exemple 28) : un [DELETE] envoyé directement à la base, sans lecture préalable. Il ne passe pas par le suivi des modifications, ni donc par le verrou optimiste ;
  • ligne 73 : le repository des médecins : [PersonneRepository<Medecin>], sans rien de plus. Il transmet le [DbContext] reçu au constructeur de sa classe mère ; le point-virgule final remplace un corps de classe vide ;
  • ligne 75 : le repository des clients, avec une méthode supplémentaire ;
  • lignes 89-92 : la lecture d'un client avec verrou : [SELECT ... FOR UPDATE] pose un verrou sur la ligne jusqu'à la fin de la transaction en cours ; une autre transaction qui demande le même verrou attend que la première soit terminée. La prise de rendez-vous ([MetierService.AjouterRvAsync]) s'en sert pour traiter l'une après l'autre deux réservations simultanées du même patient : la seconde voit le rendez-vous créé par la première, et respecte ainsi la règle « un seul rendez-vous à venir ». [LINQ] ne sait pas exprimer [FOR UPDATE] : on écrit la requête SQL soi-même avec [FromSqlInterpolated]. La chaîne [$"..."] est une chaîne interpolée ; [FromSqlInterpolated] ne la recopie pas telle quelle : chaque valeur entre accolades ([{id}]) devient un paramètre SQL. Il n'y a donc pas de concaténation de chaînes, et pas d'injection SQL possible. La requête est exécutée telle quelle ([ToListAsync]), et le premier client pris dans la liste obtenue ([FirstOrDefault], en mémoire) : un opérateur [LINQ] ajouté à la suite de [FromSqlInterpolated] amènerait EF Core à envelopper notre SQL dans une sous-requête.

11.11.2. Repositories/CreneauRepository.cs

// =========================================================================
// Repositories/CreneauRepository.cs
// -------------------------------------------------------------------------
// Couche DAO de l'entité [Creneau].
// =========================================================================

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

namespace RdvMedecins.Repositories;

public class CreneauRepository(RdvMedecinsDbContext db)
{
    public Task<Creneau?> FindByIdAsync(int id) =>
        db.Creneaux.Include(c => c.Medecin).FirstOrDefaultAsync(c => c.Id == id);

    // les créneaux d'un médecin, dans l'ordre chronologique
    public Task<List<Creneau>> GetAllCreneauxAsync(int idMedecin) =>
        db.Creneaux
            .Where(c => c.IdMedecin == idMedecin)
            .OrderBy(c => c.Hdebut)
            .ThenBy(c => c.Mdebut)
            .ToListAsync();

    // supprime tous les créneaux d'un médecin (suppression d'un médecin) :
    //   DELETE FROM creneaux WHERE id_medecin = ?
    public Task DeleteByMedecinAsync(int idMedecin) =>
        db.Creneaux.Where(c => c.IdMedecin == idMedecin).ExecuteDeleteAsync();
}
  • lignes 15-16 : un créneau, avec son médecin ([Include]) ;
  • lignes 19-24 : les créneaux d'un médecin, dans l'ordre chronologique : [Where] filtre, [OrderBy] et [ThenBy] trient (par heure, puis par minute de début). La requête se construit méthode par méthode, comme avec le QueryBuilder de [TypeORM], et n'est exécutée qu'à l'appel de [ToListAsync] : [SELECT ... FROM creneaux WHERE id_medecin = @p ORDER BY hdebut, mdebut]. La valeur [idMedecin] est, elle aussi, transmise comme paramètre SQL ;
  • lignes 28-29 : la suppression des créneaux d'un médecin, par un seul [DELETE]. La couche métier l'appelle avant de supprimer le médecin : sans cela, la clé étrangère [creneaux.id_medecin] interdirait la suppression.

11.11.3. Repositories/RvRepository.cs

// =========================================================================
// Repositories/RvRepository.cs
// -------------------------------------------------------------------------
// Couche DAO de l'entité [Rv] (un rendez-vous).
//
// Les requêtes sont écrites en LINQ : EF Core les traduit en SQL. On navigue
// d'une entité à l'autre par les propriétés de navigation (rv.Creneau.IdMedecin),
// et EF Core écrit lui-même les jointures nécessaires.
// =========================================================================

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

namespace RdvMedecins.Repositories;

public class RvRepository(RdvMedecinsDbContext db)
{
    public Task<Rv?> FindByIdAsync(int id) =>
        db.Rvs.Include(rv => rv.Client).Include(rv => rv.Creneau).FirstOrDefaultAsync(rv => rv.Id == id);

    public async Task<Rv> SaveAsync(Rv rv)
    {
        if (rv.Id == 0)
        {
            db.Rvs.Add(rv);
        }
        await db.SaveChangesAsync();
        return rv;
    }

    public Task DeleteByIdAsync(int id) => db.Rvs.Where(rv => rv.Id == id).ExecuteDeleteAsync();

    // les rendez-vous d'un médecin un jour donné, client et créneau compris.
    // rv.Creneau.IdMedecin : la table [rv] ne référence pas directement le
    // médecin ; EF Core passe par la jointure avec [creneaux].
    public Task<List<Rv>> GetRvMedecinJourAsync(int idMedecin, DateOnly jour) =>
        db.Rvs
            .Include(rv => rv.Client)
            .Include(rv => rv.Creneau)
            .Where(rv => rv.Creneau.IdMedecin == idMedecin && rv.Jour == jour)
            .ToListAsync();

    // les rendez-vous d'un client à partir du jour [depuis] (inclus), dans
    // l'ordre chronologique, avec le créneau ET le médecin de ce créneau
    // (Include puis ThenInclude : rv -> creneau -> medecin)
    public Task<List<Rv>> FindAVenirByClientAsync(int idClient, DateOnly depuis) =>
        db.Rvs
            .Include(rv => rv.Creneau).ThenInclude(c => c.Medecin)
            .Where(rv => rv.IdClient == idClient && rv.Jour >= depuis)
            .OrderBy(rv => rv.Jour)
            .ThenBy(rv => rv.Creneau.Hdebut)
            .ThenBy(rv => rv.Creneau.Mdebut)
            .ToListAsync();

    // nombre de rendez-vous d'un client à partir du jour [depuis] (inclus)
    public Task<int> CountAVenirByClientAsync(int idClient, DateOnly depuis) =>
        db.Rvs.CountAsync(rv => rv.IdClient == idClient && rv.Jour >= depuis);

    // supprime TOUS les rendez-vous (passés et futurs) d'un client :
    //   DELETE FROM rv WHERE id_client = ?
    public Task DeleteByClientAsync(int idClient) =>
        db.Rvs.Where(rv => rv.IdClient == idClient).ExecuteDeleteAsync();

    // nombre de rendez-vous (passés ou futurs) d'un médecin :
    //   SELECT COUNT(*) FROM rv INNER JOIN creneaux c ON ... WHERE c.id_medecin = ?
    public Task<int> CountByMedecinAsync(int idMedecin) =>
        db.Rvs.CountAsync(rv => rv.Creneau.IdMedecin == idMedecin);
}

Commentons ce code :

  • lignes 19-20 : un rendez-vous avec son client et son créneau : deux [Include], donc deux jointures ;
  • lignes 22-29 : même schéma que pour les personnes. En pratique, il ne sert qu'à la création : la couche métier fabrique un [Rv] dont seules les clés étrangères sont renseignées ; après [SaveChangesAsync], [rv.Id] contient l'identifiant attribué par la base ;
  • ligne 32 : l'annulation d'un rendez-vous ;
  • lignes 37-42 : les rendez-vous d'un médecin pour un jour, chacun avec son client et son créneau (l'équivalent des [leftJoinAndSelect] de la version [NestJS]). La table [rv] ne référence pas directement les médecins : le filtre [rv.Creneau.IdMedecin == idMedecin] passe par la navigation [Creneau], et EF Core ajoute la jointure avec [creneaux]. Il faut bien distinguer les deux usages d'une navigation : dans [Where], elle sert à filtrer ; seul [Include] charge l'objet dans le résultat ;
  • lignes 47-54 : les rendez-vous d'un client à partir du jour [depuis], avec leur créneau et le médecin de ce créneau : [Include(rv => rv.Creneau)] charge le créneau, puis [ThenInclude(c => c.Medecin)] charge, à partir du créneau, son médecin (deux jointures en cascade). Le tri porte sur le jour, puis sur l'heure du créneau. C'est ce qu'affiche « Mon espace » ;
  • lignes 57-58 : le nombre de ces rendez-vous ([SELECT COUNT(*)]), pour la règle « un seul rendez-vous à venir ». [CountAsync] accepte directement la condition, comme [FirstOrDefaultAsync] ;
  • lignes 62-63 : la suppression de tous les rendez-vous d'un client, passés et futurs : la couche métier l'appelle avant de supprimer le client, que la clé étrangère [rv.id_client] protégerait sinon ;
  • lignes 67-68 : le nombre de rendez-vous d'un médecin, pour refuser la suppression d'un médecin qui en a. Là encore, la navigation [rv.Creneau] produit une jointure ([INNER JOIN creneaux]), sans rien charger : c'est le [innerJoin] de la version [NestJS].

11.11.4. Repositories/UserRepository.cs

// =========================================================================
// Repositories/UserRepository.cs
// -------------------------------------------------------------------------
// Couche DAO de l'entité [User]. Créer (ou supprimer) un médecin ou un
// client crée (ou supprime) aussi son compte - cf. Metier/MetierService.cs.
// =========================================================================

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

namespace RdvMedecins.Repositories;

public class UserRepository(RdvMedecinsDbContext db)
{
    // null si aucun utilisateur ne porte ce login
    public Task<User?> FindByLoginAsync(string login) => db.Users.FirstOrDefaultAsync(u => u.Login == login);

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

    public Task DeleteByIdAsync(int id) => db.Users.Where(u => u.Id == id).ExecuteDeleteAsync();
}
  • ligne 17 : le compte qui porte ce login, ou [null]. Il sert à la connexion ([AuthService]) et à vérifier qu'un login est libre avant de l'attribuer ;
  • lignes 19-26 : la création ou la modification d'un compte. Créer un médecin ou un client, c'est d'abord créer son compte : la personne a besoin de l'identifiant du compte pour sa clé étrangère ;
  • ligne 29 : la suppression d'un compte. L'ordre est inverse de celui de la création : la couche métier supprime d'abord la personne, qui référence le compte, puis le compte.

11.12. La couche métier

La couche métier rassemble les règles de gestion de l'application : pas de rendez-vous dans le passé, un seul rendez-vous à venir par patient, unicité des logins et des personnes, suppression d'un médecin ou d'un client. Elle s'appuie sur la couche DAO (dossier [Repositories]) pour lire et écrire, et ne sait rien du HTML ni de la langue de l'interface. C'est, en plus complet, la couche [PersonnesMetier] de l'exemple 28.

11.12.1. Metier/Dates.cs

// =========================================================================
// Metier/Dates.cs
// -------------------------------------------------------------------------
// La date du jour. Elle sert à des RÈGLES DE GESTION (pas de réservation
// dans le passé, un seul rendez-vous À VENIR par patient) : elle vit donc
// dans la couche métier. La couche web la réutilise.
// =========================================================================

namespace RdvMedecins.Metier;

public static class Dates
{
    // la date du jour, dans le fuseau horaire du serveur
    public static DateOnly Aujourdhui() => DateOnly.FromDateTime(DateTime.Now);
}
  • ligne 11 : une classe [static] ne s'instancie pas : elle ne fait que regrouper des méthodes, qu'on appelle par le nom de la classe ([Dates.Aujourdhui()]) ;
  • ligne 14 : la date du jour, dans le fuseau horaire du serveur. [DateTime.Now] donne la date et l'heure ; [DateOnly.FromDateTime] n'en garde que la date (le type [DateOnly] a été vu à l'exemple 12 ; c'est celui de la colonne [rv.jour]). Deux [DateOnly] se comparent directement avec [<] ou [>=] ; la version [NestJS], qui manipulait des chaînes [aaaa-mm-jj], devait fabriquer elle-même ce format. La date du jour sert à des règles de gestion, elle vit donc dans la couche métier ; la couche web la réutilise (par exemple dans [AgendaController], pour savoir si un jour est encore réservable).

11.12.2. Metier/MetierException.cs

// =========================================================================
// Metier/MetierException.cs
// -------------------------------------------------------------------------
// L'exception levée par la couche métier quand une RÈGLE DE GESTION est
// violée (login déjà pris, médecin qui a encore des rendez-vous, créneau
// déjà réservé...). Elle ne transporte pas un texte, mais une CLÉ de
// traduction (ex : "ERRORS.LOGIN_TAKEN") : c'est la couche web qui la
// traduira en français ou en anglais au moment d'afficher la page
// (cf. i18n/fr.json, i18n/en.json).
//
// Une MetierException n'est pas une anomalie : c'est une réponse normale
// ("ce login est déjà pris"), affichée à l'utilisateur. Toute autre
// exception est une vraie anomalie, qui aboutit à la page d'erreur 500.
// =========================================================================

namespace RdvMedecins.Metier;

public class MetierException(string cle, IReadOnlyDictionary<string, string>? parametres = null) : Exception(cle)
{
    // clé de traduction du message
    public string Cle { get; } = cle;

    // paramètres éventuels du message ({minutes}...)
    public IReadOnlyDictionary<string, string> Parametres { get; } = parametres ?? new Dictionary<string, string>();
}
  • ligne 18 : l'exception levée quand une règle de gestion refuse une opération (cf. exemple 28). La classe utilise un constructeur principal (les paramètres [cle] et [parametres] écrits après le nom de la classe) ; [: Exception(cle)] transmet la clé au constructeur de la classe mère [Exception], qui en fait la propriété [Message] (utile dans les traces d'erreur). Différence avec l'exemple 28 : l'exception ne transporte pas un texte en français, mais une clé de traduction ([ERRORS.LOGIN_TAKEN]). La couche métier ignore ainsi la langue de l'interface : c'est la couche web qui traduira la clé, avec les dictionnaires [i18n/fr.json] et [i18n/en.json] (cf. chapitre 5) ;
  • ligne 21 : la clé de traduction, en lecture seule ([get] sans [set]) ;
  • ligne 24 : les paramètres éventuels du message, qui remplaceront des marques comme [{minutes}] dans le texte traduit. [IReadOnlyDictionary] est un dictionnaire qu'on ne peut que lire. L'opérateur [??] fournit un dictionnaire vide quand aucun paramètre n'a été donné : la couche web n'a jamais à tester [null]. Les exceptions levées par [MetierService] n'utilisent en fait que des clés sans paramètre.

Une [MetierException] n'est pas une anomalie : c'est une réponse normale (« ce login est déjà pris »), que les contrôleurs interceptent pour l'afficher à l'utilisateur, dans la page ou dans un message flash. Toute autre exception est une vraie anomalie, qui aboutit à la page d'erreur 500.

11.12.3. Metier/IMetier.cs

// =========================================================================
// Metier/IMetier.cs
// -------------------------------------------------------------------------
// Le contrat de la couche métier : lectures, agenda, rendez-vous, gestion
// des médecins et des clients.
//
// Convention .NET : une méthode asynchrone renvoie une Task<T> (l'équivalent
// d'une Promise<T>) et son nom se termine par "Async".
// =========================================================================

using RdvMedecins.Domain;
using RdvMedecins.Entities;

namespace RdvMedecins.Metier;

// les données saisies dans le formulaire "médecin" ou "client"
public record DonneesPersonne
{
    public required string Titre { get; init; } // Mr, Mme, Melle
    public required string Nom { get; init; }
    public required string Prenom { get; init; }

    // création : facultatif, vaut par défaut le nom en minuscules
    // modification : facultatif, le login est inchangé s'il est absent
    public string? Login { get; init; }

    // création : facultatif, vaut par défaut le login
    // modification : facultatif, le mot de passe est inchangé s'il est absent
    public string? Password { get; init; }

    // modification : la version de la fiche au moment où le formulaire a été
    // affiché (verrou optimiste, cf. AbstractEntity.Version)
    public int? Version { get; init; }
}

public interface IMetier
{
    // ------------------------------------------------ lectures
    Task<List<Client>> GetAllClientsAsync();
    Task<List<Medecin>> GetAllMedecinsAsync();
    Task<List<Creneau>> GetAllCreneauxAsync(int idMedecin);
    Task<List<Rv>> GetRvMedecinJourAsync(int idMedecin, DateOnly jour);
    Task<Client?> GetClientByIdAsync(int id);
    Task<Medecin?> GetMedecinByIdAsync(int id);
    Task<Rv?> GetRvByIdAsync(int id);
    Task<Creneau?> GetCreneauByIdAsync(int id);
    Task<AgendaMedecinJour?> GetAgendaMedecinJourAsync(int idMedecin, DateOnly jour);

    /// <summary>le client (patient) dont le compte utilisateur est [idUser], ou null</summary>
    Task<Client?> GetClientByUserIdAsync(int idUser);

    /// <summary>les rendez-vous d'un client, d'aujourd'hui ou futurs, avec créneau et médecin</summary>
    Task<List<Rv>> GetRvAVenirClientAsync(int idClient);

    // ------------------------------------------------ rendez-vous (ADMIN, DOCTOR, USER)
    /// <summary>
    /// lève une MetierException si :
    ///  - le jour est passé ("ERRORS.PAST_DATE") ;
    ///  - le patient a déjà un rendez-vous à venir ("ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT") ;
    ///  - le créneau est déjà pris ce jour-là ("ERRORS.SLOT_ALREADY_BOOKED").
    /// </summary>
    Task<Rv> AjouterRvAsync(DateOnly jour, Creneau creneau, Client client);

    Task SupprimerRvAsync(Rv rv);

    // ------------------------------------------------ médecins (ADMIN)
    /// <summary>crée le médecin ET son compte utilisateur (rôle DOCTOR)</summary>
    Task<Medecin> AjouterMedecinAsync(DonneesPersonne donnees);

    /// <summary>modifie le médecin ET son compte utilisateur</summary>
    Task<Medecin> ModifierMedecinAsync(int id, DonneesPersonne donnees);

    /// <summary>supprime le médecin, ses créneaux ET son compte - refusé s'il a des rendez-vous</summary>
    Task SupprimerMedecinAsync(int id);

    // ------------------------------------------------ clients
    // (l'ADMIN ; AjouterClientAsync sert aussi à l'inscription d'un patient,
    // ModifierClientAsync et SupprimerClientAsync au patient pour son propre compte)
    /// <summary>crée le client ET son compte utilisateur (rôle USER)</summary>
    Task<Client> AjouterClientAsync(DonneesPersonne donnees);

    Task<Client> ModifierClientAsync(int id, DonneesPersonne donnees);

    /// <summary>
    /// supprime le client, son compte ET tous ses rendez-vous (passés et futurs).
    /// Utilisée par l'ADMIN (page "Clients") et par le patient lui-même ("Gérer mon compte").
    /// </summary>
    Task SupprimerClientAsync(int id);
}
  • lignes 17-33 : les données d'un médecin ou d'un client à créer ou modifier, telles que la couche web les transmet (cf. exemple 28). [required] oblige à fournir titre, nom et prénom ; [init] interdit de les modifier ensuite (cf. exemple 18). Login et mot de passe sont facultatifs ([string?]) : à la création, ils valent par défaut le nom en minuscules ; à la modification, ils ne changent que s'ils ont été saisis. [Version] est la version de la fiche lue lors de l'affichage du formulaire de modification : elle sert au verrou optimiste (cf. plus bas) ;
  • ligne 36 : le contrat de la couche métier : ce que les autres couches peuvent lui demander, sans dire comment c'est fait (les interfaces ont été vues à l'exemple 06). Par convention [.NET], une méthode asynchrone rend une [Task<T>] (l'équivalent d'une [Promise<T>] de [JavaScript]) et son nom se termine par [Async] ;
  • lignes 39-53 : les lectures. Un type suivi de [?] ([Task<Client?>]) signale que la méthode peut rendre [null] : personne ne porte cet identifiant. Les commentaires [/// <summary>] sont des commentaires de documentation : [Visual Studio Code] les affiche quand on survole la méthode ;
  • lignes 57-64 : les rendez-vous. Le commentaire de [AjouterRvAsync] énumère les clés d'erreur possibles : c'est une partie du contrat, que les contrôleurs doivent connaître ;
  • lignes 68-74 : la gestion des médecins (réservée à l'ADMIN). Chaque médecin est créé, modifié et supprimé avec son compte utilisateur ;
  • lignes 80-88 : la gestion des clients, avec la même logique. [AjouterClientAsync] sert aussi à l'inscription d'un patient, [ModifierClientAsync] et [SupprimerClientAsync] au patient qui gère son propre compte.

L'implémentation est enregistrée dans [Program.cs] par la ligne [builder.Services.AddScoped<IMetier, MetierService>()] : « quand un constructeur demande un [IMetier], lui fournir un [MetierService] », un par requête ([Scoped]), comme le [DbContext] et les DAO qu'il utilise (cf. exemple 28 et chapitre 6). Avec [NestJS], une interface [TypeScript] disparaissait à la compilation : il fallait un jeton ([METIER_TOKEN]) et [@Inject(METIER_TOKEN)]. En [C#], les interfaces existent à l'exécution : l'interface elle-même sert de jeton. Le seul service qui reçoit un [IMetier] est [ApplicationModel] (dossier [Web]), qui implémente lui aussi [IMetier] : les contrôleurs voient donc le même contrat, enrichi d'une mémoire des listes de médecins et de clients.

11.12.4. Metier/MetierService.cs

C'est le fichier le plus riche de l'application. Nous le montrons en cinq extraits successifs.

11.12.4.1. Les descripteurs

// =========================================================================
// Metier/MetierService.cs
// -------------------------------------------------------------------------
// Implémentation de la couche métier.
//
// La gestion des médecins et des clients obéit à trois règles de gestion :
//
//   1. une personne (médecin ou client) et son compte utilisateur vont
//      TOUJOURS ensemble : on les crée, modifie et supprime ensemble, dans
//      une même TRANSACTION - soit tout réussit, soit rien n'est écrit ;
//   2. le login est unique dans toute l'application, et deux médecins (ou
//      deux clients) ne peuvent pas porter les mêmes prénom et nom ;
//   3. suppression :
//      - un MÉDECIN qui a des rendez-vous ne peut pas être supprimé (ses
//        patients perdraient leur rendez-vous sans être prévenus) : il faut
//        d'abord les annuler. Ses créneaux horaires disparaissent avec lui ;
//      - un CLIENT, lui, disparaît avec TOUS ses rendez-vous (passés et
//        futurs) - que la suppression vienne de l'ADMIN ou du patient
//        lui-même.
//
// Et deux règles pour la prise de rendez-vous, quel que soit le rôle de
// celui qui réserve (ADMIN, DOCTOR ou le patient lui-même) :
//   4. pas de rendez-vous dans le passé ;
//   5. un patient n'a qu'UN rendez-vous à venir à la fois.
//
// Médecins et clients suivent exactement les mêmes règles : le code commun
// est écrit UNE fois (méthodes privées génériques XxxPersonneAsync<T>), et
// paramétré par un petit "descripteur" qui dit à quel type de personne on a
// affaire.
// =========================================================================

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

namespace RdvMedecins.Metier;

public class MetierService : IMetier
{
    // nombre de tours de salage bcrypt - le même que dans le script SQL ($2b$10$...)
    private const int ToursBcrypt = 10;

    // tout ce qui distingue un médecin d'un client pour les règles de gestion.
    // "where T : Personne, new()" : T est une Personne, et on peut écrire new T().
    private record DescripteurPersonne<T>(
        PersonneRepository<T> Dao,
        Role Role, // DOCTOR ou USER
        string CleIntrouvable, // message si la personne n'existe pas
        string CleExisteDeja, // message si les mêmes prénom et nom existent déjà
        // facultatif : si présent, la suppression est REFUSÉE tant que la
        // personne a des rendez-vous (CompterRv > 0), avec le message CleAvecRv
        Func<int, Task<int>>? CompterRv = null,
        string? CleAvecRv = null,
        // facultatif : ce qu'il faut supprimer AVANT la personne (les lignes qui
        // la référencent par une clé étrangère)
        Func<int, Task>? AvantSuppression = null) where T : Personne, new();

    private readonly RdvMedecinsDbContext db;
    private readonly MedecinRepository medecinRepository;
    private readonly ClientRepository clientRepository;
    private readonly CreneauRepository creneauRepository;
    private readonly RvRepository rvRepository;
    private readonly UserRepository userRepository;
    private readonly DescripteurPersonne<Medecin> medecinDescripteur;
    private readonly DescripteurPersonne<Client> clientDescripteur;

    // toutes les dépendances sont fournies par l'injection de dépendances
    // d'ASP.NET Core (cf. Program.cs). Le DbContext ne sert ici QU'À ouvrir des
    // transactions - les requêtes, elles, restent dans la couche DAO.
    public MetierService(
        RdvMedecinsDbContext db,
        MedecinRepository medecinRepository,
        ClientRepository clientRepository,
        CreneauRepository creneauRepository,
        RvRepository rvRepository,
        UserRepository userRepository)
    {
        this.db = db;
        this.medecinRepository = medecinRepository;
        this.clientRepository = clientRepository;
        this.creneauRepository = creneauRepository;
        this.rvRepository = rvRepository;
        this.userRepository = userRepository;

        medecinDescripteur = new DescripteurPersonne<Medecin>(
            medecinRepository,
            Role.DOCTOR,
            "ERRORS.DOCTOR_NOT_FOUND",
            "ERRORS.DOCTOR_ALREADY_EXISTS",
            CompterRv: rvRepository.CountByMedecinAsync,
            CleAvecRv: "ERRORS.DOCTOR_HAS_APPOINTMENTS",
            // un médecin sans rendez-vous peut encore avoir des créneaux horaires :
            // ils n'ont plus de sens sans lui, on les supprime avec lui (sans quoi
            // la clé étrangère creneaux.id_medecin interdirait la suppression)
            AvantSuppression: creneauRepository.DeleteByMedecinAsync);

        clientDescripteur = new DescripteurPersonne<Client>(
            clientRepository,
            Role.USER,
            "ERRORS.PATIENT_NOT_FOUND",
            "ERRORS.PATIENT_ALREADY_EXISTS",
            // pas de CompterRv : la suppression d'un client n'est jamais refusée ;
            // ses rendez-vous (passés et futurs) sont supprimés avec lui (sans quoi
            // la clé étrangère rv.id_client interdirait la suppression)
            AvantSuppression: rvRepository.DeleteByClientAsync);
    }
  • lignes 6-29 : le commentaire d'en-tête résume les cinq règles de gestion appliquées par la classe ;
  • ligne 34 : le pilote [MySQL] (cf. chapitre 9) : la classe en utilise l'exception [MySqlException], pour reconnaître les violations de contraintes d'unicité ;
  • ligne 42 : la classe implémente l'interface : elle doit en fournir toutes les méthodes ;
  • ligne 45 : le coût de [bcrypt] (cf. exemple 25), le même que celui des mots de passe du script SQL (préfixe [$2b$10$]) ;
  • lignes 49-60 : tout ce qui distingue un médecin d'un client pour les règles de gestion : son DAO, le rôle de son compte ([DOCTOR] ou [USER]), ses messages d'erreur, et ce qui se passe à sa suppression. Médecins et clients obéissant aux mêmes règles, le code de ces règles est écrit une seule fois, dans des méthodes génériques (paramétrées par un type [T]) qui reçoivent un descripteur. La contrainte [where T : Personne, new()] exige que [T] hérite de [Personne] (on pourra lire [Nom], [Prenom], [User]...) et possède un constructeur sans paramètre (on pourra écrire [new T()]). [Func<int, Task<int>>] est le type d'une fonction qui reçoit un [int] et rend une [Task<int>] ; [Func<int, Task>], celui d'une fonction asynchrone qui ne rend rien. Ces paramètres ont une valeur par défaut ([= null]) : ils sont facultatifs ;
  • lignes 62-69 : les champs de la classe : le [DbContext], les cinq DAO et les deux descripteurs. [readonly] interdit de les modifier après le constructeur ;
  • lignes 74-87 : toutes les dépendances sont fournies par l'injection de dépendances. Le [DbContext] ne sert ici qu'à ouvrir des transactions : les requêtes restent dans la couche DAO. Comme à l'exemple 28, DAO et couche métier reçoivent, pendant une requête, le même [DbContext], donc la même connexion et la même transaction ;
  • lignes 89-99 : le descripteur des médecins. Les arguments nommés ([CompterRv:], [CleAvecRv:]...) désignent un paramètre par son nom, ce qui permet de sauter des paramètres facultatifs (le descripteur des clients, plus bas, omet ainsi [CompterRv] et [CleAvecRv]). [rvRepository.CountByMedecinAsync] n'est pas appelée ici : on transmet la méthode elle-même, qui sera appelée plus tard (une méthode peut ainsi servir de valeur de type [Func]). La suppression d'un médecin sera refusée tant qu'il a des rendez-vous ; ses créneaux, eux, seront supprimés avant lui ;
  • lignes 101-109 : le descripteur des clients. Pas de [CompterRv] : la suppression d'un client n'est jamais refusée, et ses rendez-vous (passés et futurs) sont supprimés avant lui, sans quoi la clé étrangère [rv.id_client] interdirait la suppression.

11.12.4.2. Les lectures et l'agenda

    // =======================================================================
    // lectures
    // =======================================================================

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

    public Task<List<Medecin>> GetAllMedecinsAsync() => medecinRepository.FindAllAsync();

    public Task<List<Creneau>> GetAllCreneauxAsync(int idMedecin) => creneauRepository.GetAllCreneauxAsync(idMedecin);

    public Task<List<Rv>> GetRvMedecinJourAsync(int idMedecin, DateOnly jour) =>
        rvRepository.GetRvMedecinJourAsync(idMedecin, jour);

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

    public Task<Medecin?> GetMedecinByIdAsync(int id) => medecinRepository.FindByIdAsync(id);

    public Task<Rv?> GetRvByIdAsync(int id) => rvRepository.FindByIdAsync(id);

    public Task<Creneau?> GetCreneauByIdAsync(int id) => creneauRepository.FindByIdAsync(id);

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

    public Task<List<Rv>> GetRvAVenirClientAsync(int idClient) =>
        rvRepository.FindAVenirByClientAsync(idClient, Dates.Aujourdhui());

    // même algorithme que le serveur JSON (et que le projet Spring d'origine)
    public async Task<AgendaMedecinJour?> GetAgendaMedecinJourAsync(int idMedecin, DateOnly jour)
    {
        var medecin = await GetMedecinByIdAsync(idMedecin);
        if (medecin is null)
        {
            return null;
        }
        var creneauxHoraires = await GetAllCreneauxAsync(idMedecin);
        var reservations = await GetRvMedecinJourAsync(idMedecin, jour);
        // n° de créneau -> rendez-vous pris sur ce créneau
        var hReservations = reservations.ToDictionary(resa => resa.IdCreneau);
        var creneauxMedecinJour = creneauxHoraires
            .Select(creneau => new CreneauMedecinJour(creneau, hReservations.GetValueOrDefault(creneau.Id)))
            .ToList();
        return new AgendaMedecinJour(medecin, jour, creneauxMedecinJour);
    }
  • lignes 5-22 : les lectures délèguent simplement à la couche DAO. La syntaxe [=> expression] (un corps d'expression) remplace un bloc [{ return expression; }]. Ces méthodes ne sont pas [async] : elles rendent directement la [Task] du DAO, sans l'attendre ;
  • lignes 24-25 : les rendez-vous à venir d'un patient : la couche métier fournit la date du jour au DAO ;
  • lignes 28-42 : le calcul de l'agenda d'un médecin pour un jour, même algorithme que la version [NestJS]. Un médecin inconnu donne [null] ; sinon on lit ses créneaux et les rendez-vous du jour ;
  • ligne 38 : [ToDictionary] range les rendez-vous dans un dictionnaire dont la clé est l'identifiant de leur créneau (l'équivalent de la [Map] de la version [NestJS]). Le dictionnaire évite de parcourir la liste des rendez-vous pour chaque créneau ;
  • lignes 39-41 : chaque créneau est associé à son rendez-vous, ou à [null] ([GetValueOrDefault] rend la valeur par défaut, [null], quand la clé est absente). [Select] transforme chaque élément d'une liste (cf. [LINQ], chapitre 9). Le résultat est un objet [AgendaMedecinJour] du dossier [Domain], que la vue de l'agenda affichera.

11.12.4.3. La prise de rendez-vous

    // =======================================================================
    // rendez-vous
    // =======================================================================

    // -----------------------------------------------------------------------
    // prise de rendez-vous, dans une transaction :
    //   1. pas de rendez-vous dans le passé ;
    //   2. on VERROUILLE la ligne du patient (SELECT ... FOR UPDATE) : si le
    //      même patient réserve deux créneaux au même instant (deux onglets,
    //      double clic...), la seconde transaction attend la fin de la
    //      première - et voit alors le rendez-vous qu'elle vient de créer ;
    //   3. un seul rendez-vous à venir par patient ;
    //   4. enfin l'insertion. Si le créneau vient d'être pris par un AUTRE
    //      patient, c'est la contrainte SQL unq1_rv (jour, id_creneau) qui
    //      refuse l'insertion - traduite en MetierException par TransactionAsync.
    // -----------------------------------------------------------------------
    public Task<Rv> AjouterRvAsync(DateOnly jour, Creneau creneau, Client client) =>
        TransactionAsync(async () =>
        {
            var aujourdhui = Dates.Aujourdhui();
            if (jour < aujourdhui)
            {
                throw new MetierException("ERRORS.PAST_DATE");
            }
            if (await clientRepository.VerrouillerAsync(client.Id) is null)
            {
                throw new MetierException("ERRORS.PATIENT_NOT_FOUND");
            }
            if (await rvRepository.CountAVenirByClientAsync(client.Id, aujourdhui) > 0)
            {
                throw new MetierException("ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT");
            }
            // on renseigne les clés étrangères : inutile de rattacher les objets
            // Creneau et Client eux-mêmes pour écrire la ligne [rv]
            var rv = new Rv { Jour = jour, IdCreneau = creneau.Id, IdClient = client.Id };
            return await rvRepository.SaveAsync(rv);
        });

    public Task SupprimerRvAsync(Rv rv) => rvRepository.DeleteByIdAsync(rv.Id);
  • lignes 17-18 : toute la réservation se passe dans une transaction : la méthode passe à [TransactionAsync] (cf. plus bas) une fonction anonyme asynchrone, [async () => { ... }], qui fait le travail ;
  • lignes 20-23 : pas de rendez-vous dans le passé. La règle est vérifiée ici quel que soit le rôle de celui qui réserve (ADMIN, DOCTOR ou le patient), même si la couche web a déjà masqué les boutons Réserver des jours passés : une requête peut être fabriquée à la main ;
  • lignes 25-27 : la ligne du patient est verrouillée : [VerrouillerAsync] exécute [SELECT * FROM clients WHERE id = ... FOR UPDATE] (cf. [Repositories/PersonneRepository.cs]). C'est un verrou pessimiste : il est posé avant de travailler, en supposant le conflit possible, et il dure jusqu'à la fin de la transaction. Sans lui, deux réservations du même patient envoyées au même instant (deux onglets, un double clic) pourraient passer toutes deux le test suivant, avant que l'une ou l'autre n'ait écrit son rendez-vous. Avec lui, la seconde transaction attend la fin de la première, puis voit le rendez-vous qu'elle a créé. Un patient introuvable (supprimé entre-temps) est refusé ;
  • lignes 29-31 : un seul rendez-vous à venir par patient ;
  • lignes 35-36 : l'insertion. On ne renseigne que les clés étrangères [IdCreneau] et [IdClient] : inutile de rattacher les objets [Creneau] et [Client] pour écrire la ligne [rv]. Si un autre patient vient de prendre le même créneau, le verrou ne l'a pas arrêté (il porte sur la ligne du patient) : c'est la contrainte d'unicité [unq1_rv (jour, id_creneau)] de la base qui refuse l'insertion, et [TransactionAsync] traduit l'erreur en [MetierException] avec la clé [ERRORS.SLOT_ALREADY_BOOKED] ;
  • ligne 39 : l'annulation d'un rendez-vous : un simple [DELETE], sans règle particulière.

11.12.4.4. La gestion des médecins et des clients

    // =======================================================================
    // médecins et clients - les méthodes publiques délèguent aux méthodes
    // privées communes, avec le bon descripteur
    // =======================================================================

    public Task<Medecin> AjouterMedecinAsync(DonneesPersonne donnees) => AjouterPersonneAsync(medecinDescripteur, donnees);

    public Task<Medecin> ModifierMedecinAsync(int id, DonneesPersonne donnees) =>
        ModifierPersonneAsync(medecinDescripteur, id, donnees);

    public Task SupprimerMedecinAsync(int id) => SupprimerPersonneAsync(medecinDescripteur, id);

    public Task<Client> AjouterClientAsync(DonneesPersonne donnees) => AjouterPersonneAsync(clientDescripteur, donnees);

    public Task<Client> ModifierClientAsync(int id, DonneesPersonne donnees) =>
        ModifierPersonneAsync(clientDescripteur, id, donnees);

    // le client, son compte ET tous ses rendez-vous (ADMIN, ou le patient
    // lui-même depuis "Gérer mon compte" : même méthode, même règle)
    public Task SupprimerClientAsync(int id) => SupprimerPersonneAsync(clientDescripteur, id);

    // -----------------------------------------------------------------------
    // création : le compte utilisateur d'abord (la personne le référence),
    // puis la personne - le tout dans UNE transaction.
    // -----------------------------------------------------------------------
    private Task<T> AjouterPersonneAsync<T>(DescripteurPersonne<T> d, DonneesPersonne donnees) where T : Personne, new()
    {
        var nom = donnees.Nom.Trim().ToUpperInvariant();
        var prenom = donnees.Prenom.Trim();
        // les règles de la base dbrdvmedecins2 : login = nom en minuscules,
        // mot de passe = login - sauf si l'ADMIN en a saisi d'autres
        var login = (string.IsNullOrEmpty(donnees.Login) ? nom : donnees.Login).ToLowerInvariant();
        var password = string.IsNullOrEmpty(donnees.Password) ? login : donnees.Password;

        return TransactionAsync(async () =>
        {
            await VerifierPersonneUniqueAsync(d, prenom, nom, null);
            await VerifierLoginLibreAsync(login);

            var user = new User
            {
                Login = login,
                Password = BCrypt.Net.BCrypt.HashPassword(password, ToursBcrypt),
                Nom = $"{prenom} {nom}",
                Role = d.Role,
            };
            await userRepository.SaveAsync(user);

            var personne = new T { Titre = donnees.Titre, Nom = nom, Prenom = prenom, User = user };
            return await d.Dao.SaveAsync(personne);
        });
    }

    // -----------------------------------------------------------------------
    // modification : la personne ET son compte (le nom affiché "prénom NOM"
    // doit suivre ; le login et le mot de passe ne changent que s'ils ont été
    // saisis).
    // -----------------------------------------------------------------------
    private Task<T> ModifierPersonneAsync<T>(DescripteurPersonne<T> d, int id, DonneesPersonne donnees)
        where T : Personne, new() =>
        TransactionAsync(async () =>
        {
            var personne = await d.Dao.FindByIdAsync(id) ?? throw new MetierException(d.CleIntrouvable);

            // verrou optimiste : si la version en base n'est plus celle du
            // formulaire, quelqu'un a modifié cette fiche entre-temps - on refuse
            // plutôt que d'écraser silencieusement ses modifications.
            // (Si la modification concurrente a lieu APRÈS cette lecture, c'est
            // la clause "WHERE version = ..." de l'UPDATE qui la détecte.)
            if (donnees.Version is not null && donnees.Version != personne.Version)
            {
                throw new MetierException("ERRORS.CONCURRENT_UPDATE");
            }

            // on se contente de modifier les objets : le DbContext, qui les suit,
            // enverra les UPDATE nécessaires au prochain SaveChangesAsync
            personne.Titre = donnees.Titre;
            personne.Nom = donnees.Nom.Trim().ToUpperInvariant();
            personne.Prenom = donnees.Prenom.Trim();
            await VerifierPersonneUniqueAsync(d, personne.Prenom, personne.Nom, id);

            var user = personne.User;
            user.Nom = $"{personne.Prenom} {personne.Nom}";
            if (!string.IsNullOrEmpty(donnees.Login) && donnees.Login.ToLowerInvariant() != user.Login)
            {
                var login = donnees.Login.ToLowerInvariant();
                await VerifierLoginLibreAsync(login);
                user.Login = login;
            }
            if (!string.IsNullOrEmpty(donnees.Password))
            {
                user.Password = BCrypt.Net.BCrypt.HashPassword(donnees.Password, ToursBcrypt);
            }
            await userRepository.SaveAsync(user);
            return await d.Dao.SaveAsync(personne);
        });

    // -----------------------------------------------------------------------
    // suppression, dans UNE transaction, dans l'ordre imposé par les clés
    // étrangères :
    //   1. refus éventuel s'il reste des rendez-vous (médecin) ;
    //   2. ce qui référence la personne (créneaux d'un médecin, rendez-vous
    //      d'un client) ;
    //   3. la personne (elle référence son compte) ;
    //   4. son compte.
    // -----------------------------------------------------------------------
    private Task SupprimerPersonneAsync<T>(DescripteurPersonne<T> d, int id) where T : Personne, new() =>
        TransactionAsync(async () =>
        {
            var personne = await d.Dao.FindByIdAsync(id) ?? throw new MetierException(d.CleIntrouvable);
            if (d.CompterRv is not null && await d.CompterRv(id) > 0)
            {
                throw new MetierException(d.CleAvecRv ?? "ERRORS.INVALID_FORM");
            }
            if (d.AvantSuppression is not null)
            {
                await d.AvantSuppression(id);
            }
            await d.Dao.DeleteByIdAsync(id);
            await userRepository.DeleteByIdAsync(personne.IdUser);
            return true; // TransactionAsync attend un résultat : on n'en a pas besoin ici
        });

    // refuse des prénom et nom déjà portés par une AUTRE personne du même type
    // (idCourant : la personne en cours de modification, qui a évidemment le
    // droit de garder ses propres prénom et nom ; null pour une création)
    private static async Task VerifierPersonneUniqueAsync<T>(DescripteurPersonne<T> d, string prenom, string nom, int? idCourant)
        where T : Personne, new()
    {
        var homonyme = await d.Dao.FindByPrenomNomAsync(prenom, nom);
        if (homonyme is not null && homonyme.Id != idCourant)
        {
            throw new MetierException(d.CleExisteDeja);
        }
    }

    private async Task VerifierLoginLibreAsync(string login)
    {
        if (await userRepository.FindByLoginAsync(login) is not null)
        {
            throw new MetierException("ERRORS.LOGIN_TAKEN");
        }
    }
  • lignes 6-20 : les six méthodes publiques délèguent aux trois méthodes génériques privées, avec le bon descripteur. Le compilateur déduit [T] du descripteur : [Medecin] pour [medecinDescripteur], [Client] pour [clientDescripteur] ;
  • lignes 26-50 : la création d'une personne et de son compte ;
  • lignes 28-33 : la préparation des données, hors transaction. [Trim] retire les espaces en début et fin de chaîne ; le nom est mis en majuscules. Le login vaut par défaut le nom, et il est toujours mis en minuscules ; le mot de passe vaut par défaut le login : ce sont les règles des comptes du script SQL ;
  • lignes 37-38 : dans la transaction, les deux règles d'unicité : pas d'homonyme, login libre. Elles sont vérifiées avant l'écriture pour donner un message clair (cf. exemple 28) ;
  • lignes 40-47 : le compte utilisateur est créé d'abord, car la personne le référence (clé étrangère). Le mot de passe n'est jamais enregistré en clair : [BCrypt.Net.BCrypt.HashPassword] en calcule le hachage (cf. exemple 25). [$"{prenom} {nom}"] est une chaîne interpolée : le nom affiché du compte ;
  • lignes 49-50 : puis la personne, grâce à la contrainte [new()] : [new T] fabrique un [Medecin] ou un [Client]. Si cette seconde écriture échoue, la transaction annule aussi la première : on n'obtient jamais un compte sans personne ;
  • lignes 59-95 : la modification de la personne et de son compte, dans une transaction ;
  • ligne 63 : une personne introuvable est une [MetierException], avec la clé du descripteur. [?? throw] a été vu à l'exemple 28 ;
  • lignes 70-72 : le verrou optimiste (cf. exemple 28) : le formulaire a transporté la version de la fiche lue à son affichage ; si la version en base est différente, quelqu'un a modifié la fiche entre-temps, et on refuse plutôt que d'écraser silencieusement son travail. Contrairement au verrou pessimiste de la réservation, rien n'est bloqué : on suppose que les conflits sont rares, et on se contente de les détecter. Si la modification concurrente a lieu après cette lecture, c'est la condition [WHERE version = ...] de l'[UPDATE] qui la détectera (cf. [TraduireErreurBase], plus bas) ;
  • lignes 77-80 : on modifie simplement l'objet lu : le [DbContext], qui le suit, enverra les [UPDATE] nécessaires au prochain [SaveChangesAsync]. La vérification des homonymes transmet l'identifiant de la personne : elle a évidemment le droit de garder ses propres prénom et nom ;
  • lignes 82-88 : le compte suit la personne (propriété de navigation [User], chargée par le DAO) : son nom affiché est recalculé. Le login ne change que s'il a été saisi et diffère de l'actuel ; il doit alors être libre ;
  • lignes 90-92 : le mot de passe ne change que s'il a été saisi ; il est haché à son tour ;
  • lignes 94-95 : l'enregistrement du compte et de la personne. Notre [DbContext] augmente la version de chaque entité modifiée (cf. [Data/RdvMedecinsDbContext.cs]) ;
  • lignes 107-121 : la suppression, dans une transaction, dans l'ordre imposé par les clés étrangères ;
  • lignes 111-113 : le refus éventuel : un médecin qui a des rendez-vous ne peut pas être supprimé (ses patients perdraient leur rendez-vous sans être prévenus). [d.CompterRv(id)] appelle la fonction rangée dans le descripteur ;
  • lignes 115-117 : la suppression de ce qui référence la personne : les créneaux d'un médecin, les rendez-vous d'un client ;
  • lignes 119-120 : la personne, puis son compte, que la personne référençait ;
  • ligne 121 : [TransactionAsync] attend une fonction qui rend un résultat : on en rend un, dont personne ne se servira ;
  • lignes 127-133 : un homonyme est refusé, sauf s'il s'agit de la personne en cours de modification ([idCourant], [null] pour une création). La méthode est [static] : elle n'utilise aucun champ de l'objet ;
  • lignes 137-141 : un login déjà pris, par un médecin, un client ou l'administrateur, est refusé.

11.12.4.5. Les transactions et les contraintes d'unicité

    // -----------------------------------------------------------------------
    // exécute [travail] dans une transaction :
    //   BeginTransactionAsync -> START TRANSACTION ;
    //   CommitAsync            -> COMMIT, si tout s'est bien passé ;
    //   si une exception est levée (MetierException comprise), on ne valide
    //   pas : "await using" referme la transaction à la sortie du bloc, et une
    //   transaction refermée sans COMMIT est annulée (ROLLBACK).
    //
    // Les contrôles "login libre" et "prénom + nom uniques" sont faits AVANT
    // l'écriture, pour donner un message clair. Mais deux ADMIN pourraient
    // enregistrer le même login (ou le même médecin) au même instant : dans ce
    // cas rare, c'est une contrainte d'unicité SQL qui refuse la seconde
    // écriture - on traduit alors cette erreur SQL en la même MetierException.
    // -----------------------------------------------------------------------
    private async Task<R> TransactionAsync<R>(Func<Task<R>> travail)
    {
        await using var transaction = await db.Database.BeginTransactionAsync();
        try
        {
            var resultat = await travail();
            await transaction.CommitAsync();
            return resultat;
        }
        catch (Exception e)
        {
            // en cas d'échec, le DbContext "oublie" tout ce qu'il suivait : une
            // entité restée à l'état Added serait sinon renvoyée en base au
            // prochain SaveChangesAsync de la même requête
            db.ChangeTracker.Clear();
            if (TraduireErreurBase(e) is { } erreurMetier)
            {
                throw erreurMetier;
            }
            throw; // "throw;" relance l'exception d'origine, avec sa pile d'appels intacte
        }
    }

    // -----------------------------------------------------------------------
    // Erreurs de la base -> MetierException
    //
    // - DbUpdateConcurrencyException : l'UPDATE "WHERE id = ... AND version = ..."
    //   n'a modifié aucune ligne (verrou optimiste, cf. AbstractEntity) ;
    // - DbUpdateException dont la cause (InnerException) est l'erreur MySQL
    //   ER_DUP_ENTRY (n° 1062) : une contrainte UNIQUE a refusé l'écriture. Son
    //   message cite le NOM de la contrainte :
    //     Duplicate entry 'marie-pelissier' for key 'medecins.unq_medecins_prenom_nom'
    //   (MySQL 8 préfixe le nom par celui de la table, MariaDB non). C'est pour
    //   cela que le script SQL NOMME toutes ses contraintes.
    // Toute autre erreur est une vraie anomalie : on renvoie null (non traduite).
    // -----------------------------------------------------------------------
    private static readonly Dictionary<string, string> MessagesContraintes = new()
    {
        ["unq_users_login"] = "ERRORS.LOGIN_TAKEN",
        ["unq_medecins_prenom_nom"] = "ERRORS.DOCTOR_ALREADY_EXISTS",
        ["unq_clients_prenom_nom"] = "ERRORS.PATIENT_ALREADY_EXISTS",
        ["unq1_rv"] = "ERRORS.SLOT_ALREADY_BOOKED",
    };

    private static MetierException? TraduireErreurBase(Exception e)
    {
        // attention à l'ordre : DbUpdateConcurrencyException hérite de DbUpdateException
        if (e is DbUpdateConcurrencyException)
        {
            return new MetierException("ERRORS.CONCURRENT_UPDATE");
        }
        // "filtrage par motif" (pattern matching) : e est-il une DbUpdateException
        // dont l'InnerException est une MySqlException de code DuplicateKeyEntry ?
        if (e is DbUpdateException { InnerException: MySqlException { ErrorCode: MySqlErrorCode.DuplicateKeyEntry } erreurSql })
        {
            // "... for key 'medecins.unq_medecins_prenom_nom'" -> "unq_medecins_prenom_nom"
            var correspondance = Regex.Match(erreurSql.Message, @"for key '(?:[^'.]*\.)?([^']+)'");
            if (correspondance.Success && MessagesContraintes.TryGetValue(correspondance.Groups[1].Value, out var cle))
            {
                return new MetierException(cle);
            }
        }
        return null;
    }
}
  • ligne 15 : toutes les écritures à plusieurs étapes passent par cette méthode générique : [R] est le type du résultat du travail ([Rv], [Medecin], [Client], [bool]), et [Func<Task<R>>] le type d'une fonction asynchrone sans paramètre qui rend un [R]. C'est la méthode [TransactionAsync] de l'exemple 28, complétée ;
  • ligne 17 : [BeginTransactionAsync] ouvre une transaction ([START TRANSACTION]). [await using var] déclare une variable qui sera refermée automatiquement à la sortie du bloc qui la contient, quoi qu'il arrive (fin normale ou exception), la version [await] permettant une fermeture asynchrone. Une transaction refermée sans avoir été validée est annulée ([ROLLBACK]) ;
  • lignes 20-22 : on exécute le travail ; s'il réussit, [CommitAsync] valide la transaction ([COMMIT]). S'il lève une exception, [MetierException] comprise, on n'atteint jamais [CommitAsync] : tout ce qui a été écrit est annulé ;
  • ligne 24 : l'exemple 28 interceptait deux types d'exceptions précis ; ici on intercepte toutes les exceptions, pour faire le ménage avant de les relancer ;
  • ligne 29 : le ménage. La base a tout annulé, mais le [DbContext] suit encore des entités qui ne correspondent plus à rien : par exemple le médecin dont l'insertion a échoué reste à l'état [Added]. Le [DbContext] vivant toute la requête, il serait renvoyé en base au prochain [SaveChangesAsync] de cette requête. [ChangeTracker.Clear()] fait oublier au [DbContext] toutes les entités qu'il suivait ;
  • lignes 30-32 : si l'erreur est une erreur de la base connue, on lève à sa place la [MetierException] correspondante. [is { } erreurMetier] est un filtrage par motif : il teste que la valeur n'est pas [null] et la range dans la variable [erreurMetier] ;
  • ligne 34 : sinon, l'exception d'origine est relancée telle quelle. [throw;] seul, dans un [catch], conserve sa pile d'appels intacte (celle qui dit où l'erreur s'est produite), alors que [throw e;] la réinitialiserait. Une [MetierException] levée par le travail repasse ainsi, inchangée, jusqu'au contrôleur ; toute autre erreur est une anomalie, qui produira la page d'erreur 500 ;
  • lignes 51-56 : à chaque contrainte d'unicité nommée du script SQL, sa clé de traduction. C'est pour cela que le script SQL nomme toutes ses contraintes. [new()] sans nom de type est permis quand le type est déjà écrit à gauche ; [["cle"] = valeur] remplit le dictionnaire ;
  • lignes 59-77 : la traduction d'une erreur de la base en [MetierException], ou [null] si l'erreur n'est pas reconnue ;
  • lignes 62-64 : l'[UPDATE ... WHERE id = ... AND version = ...] n'a modifié aucune ligne : EF Core lève une [DbUpdateConcurrencyException] (verrou optimiste, cf. exemple 28). Ce test vient en premier, car [DbUpdateConcurrencyException] hérite de [DbUpdateException] : placé après, le test suivant l'aurait intercepté ;
  • ligne 68 : une violation de contrainte d'unicité. L'erreur [MySQL] (code [DuplicateKeyEntry], n° 1062) arrive enveloppée dans une [DbUpdateException], dans sa propriété [InnerException]. Le filtrage par motif vérifie tout en une expression, sur deux niveaux : [e] est une [DbUpdateException], dont l'[InnerException] est une [MySqlException], dont l'[ErrorCode] vaut [DuplicateKeyEntry] ; cette [MySqlException] est rangée dans [erreurSql]. La version [NestJS] testait de même le code [ER_DUP_ENTRY] d'une [QueryFailedError] ;
  • ligne 71 : le message de l'erreur cite le nom de la contrainte, par exemple [Duplicate entry 'marie-pelissier' for key 'medecins.unq_medecins_prenom_nom']. L'expression régulière l'extrait : [(?:[^'.]*\.)?] saute le préfixe facultatif [medecins.] (écrit par [MySQL] 8, pas par [MariaDB]), et le groupe [([^']+)] capture le nom jusqu'à l'apostrophe. Le [@] devant la chaîne en fait une chaîne verbatim, où [\] n'est pas un caractère d'échappement ;
  • lignes 72-74 : si le nom a été trouvé et figure dans le dictionnaire, [TryGetValue] range la clé de traduction dans [cle], et l'erreur devient une [MetierException]. Toute autre erreur, y compris une violation d'une contrainte absente du dictionnaire, est rendue non traduite : c'est une vraie anomalie.

11.13. L'authentification

Le dossier [Auth] reprend, en plus complet, les exemples 25 à 27 : jeton JWT dans un cookie, rôles, captcha, limitation des tentatives. S'y ajoute un « laissez-passer » pour la création d'un compte client. Le contrôleur [AuthController], commenté à la fin de cette section, assemble ces pièces pour la page de connexion.

11.13.1. Auth/UtilisateurConnecte.cs

// =========================================================================
// Auth/UtilisateurConnecte.cs
// -------------------------------------------------------------------------
// Ce que le jeton JWT contient, et comment le relire.
//
// Dans ASP.NET Core, l'utilisateur de la requête est un ClaimsPrincipal
// (propriété User d'un contrôleur, HttpContext.User ailleurs) : une liste de
// "claims" (revendications), des couples type / valeur - exactement les
// champs du jeton JWT une fois vérifié. Le middleware d'authentification le
// remplit à CHAQUE requête (cf. Program.cs, UseAuthentication).
//
// Les claims de nos jetons (cf. AuthService.CreerJeton) :
//   sub   : l'id du compte utilisateur (convention JWT : "subject")
//   login : le login
//   nom   : le nom affiché ("Marie PELISSIER"), pour la barre de navigation
//   role  : ADMIN, DOCTOR ou USER
// =========================================================================

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

namespace RdvMedecins.Auth;

public static class ClaimsJwt
{
    public const string Sub = "sub";
    public const string Login = "login";
    public const string Nom = "nom";
    public const string Role = "role";
}

// l'utilisateur connecté, sous une forme typée
public record UtilisateurConnecte(int UserId, string Login, string Nom, Role Role);

// -------------------------------------------------------------------------
// Une "méthode d'extension" : on l'appelle comme si elle faisait partie de
// ClaimsPrincipal (User.Utilisateur()), alors qu'elle est définie ici.
// -------------------------------------------------------------------------
public static class ClaimsPrincipalExtensions
{
    // l'utilisateur connecté, ou null si la requête n'est pas authentifiée
    public static UtilisateurConnecte? Utilisateur(this ClaimsPrincipal principal)
    {
        if (principal.Identity?.IsAuthenticated != true
            || !int.TryParse(principal.FindFirstValue(ClaimsJwt.Sub), out var userId)
            || !Enum.TryParse<Role>(principal.FindFirstValue(ClaimsJwt.Role), out var role))
        {
            return null;
        }
        return new UtilisateurConnecte(
            userId,
            principal.FindFirstValue(ClaimsJwt.Login) ?? "",
            principal.FindFirstValue(ClaimsJwt.Nom) ?? "",
            role);
    }
}
  • lignes 24-29 : les noms des claims du jeton, réunis dans des constantes : une faute de frappe dans [ClaimsJwt.Nom] est signalée par le compilateur, alors qu'une faute dans la chaîne ["nom"] ne serait découverte qu'à l'exécution ;
  • ligne 33 : l'utilisateur connecté sous une forme typée : un identifiant entier, un rôle de type [Role] (l'énumération ADMIN, DOCTOR, USER du dossier [Entities]), au lieu de la liste de couples de chaînes que sont les claims. C'est l'équivalent de l'objet [req.user] fabriqué par la stratégie [Passport] du projet [NestJS] ;
  • lignes 39-42 : une méthode d'extension (cf. exemple 06) sur [ClaimsPrincipal], le type de la propriété [User] des contrôleurs et des vues (cf. exemple 25). Les pages écrivent simplement [User.Utilisateur()] ;
  • lignes 44-48 : la méthode rend [null] si la requête n'est pas authentifiée, ou si les claims ne sont pas ceux attendus. [Identity?.IsAuthenticated] vaut [null] si [Identity] est absente : la comparaison [!= true] traite ce cas comme « non authentifié ». [int.TryParse] et [Enum.TryParse<Role>] tentent une conversion et rendent [false] en cas d'échec, au lieu de lever une exception ; le résultat est rangé dans la variable déclarée par [out var] ;
  • lignes 50-54 : [FindFirstValue] rend la valeur du premier claim du type demandé, ou [null] ; l'opérateur [??] la remplace alors par une chaîne vide.

11.13.2. Auth/AuthService.cs

// =========================================================================
// Auth/AuthService.cs
// -------------------------------------------------------------------------
// La logique d'authentification :
//   - ValiderUtilisateurAsync() : vérifie un couple login / mot de passe ;
//   - DeposerJeton()           : fabrique le jeton JWT d'un utilisateur validé
//                                et le range dans un cookie ;
//   - EffacerJeton()           : supprime ce cookie (déconnexion).
//
// Où le jeton JWT voyage-t-il ?
// Avec le client Angular, c'était le code JavaScript du client qui gardait le
// jeton et l'ajoutait lui-même à chaque requête (entête "Authorization:
// Bearer ..."). Un navigateur qui affiche des pages HTML ordinaires ne sait
// pas faire cela : pour qu'il présente le jeton tout seul à chaque requête,
// il faut le ranger dans un COOKIE. Les options de ce cookie comptent pour
// la sécurité :
//   - HttpOnly : le JavaScript de la page ne peut pas lire le cookie (un
//     script injecté ne pourrait donc pas voler le jeton) ;
//   - SameSite=Lax : le navigateur n'envoie PAS le cookie avec un POST venant
//     d'un autre site. Comme toutes les actions qui modifient des données sont
//     des POST, une page piégée sur un autre site ne peut pas réserver ou
//     supprimer quoi que ce soit en se faisant passer pour l'utilisateur
//     (attaque dite CSRF) ;
//   - Secure : en production (HTTPS), le cookie ne circule jamais en clair ;
//   - MaxAge : le cookie disparaît en même temps que le jeton expire.
// =========================================================================

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

namespace RdvMedecins.Auth;

public class AuthService(UserRepository userRepository, IOptions<AuthOptions> options)
{
    // le nom du cookie qui transporte le jeton
    public const string CookieJwt = "rdvmedecins_jwt";

    private readonly AuthOptions auth = options.Value;

    // la clé de signature des jetons, fabriquée à partir du secret de
    // appsettings.json. Utilisée aussi pour VÉRIFIER les jetons (cf. Program.cs).
    public static SymmetricSecurityKey CleDeSignature(AuthOptions auth)
    {
        var octets = Encoding.UTF8.GetBytes(auth.JwtSecret);
        if (octets.Length < 32)
        {
            // HMAC-SHA256 exige une clé de 256 bits au moins : on le dit
            // clairement au démarrage plutôt qu'à la première connexion
            throw new InvalidOperationException("Auth:JwtSecret doit comporter au moins 32 caractères (cf. appsettings.json).");
        }
        return new SymmetricSecurityKey(octets);
    }

    // null si le login n'existe pas ou si le mot de passe est faux.
    // BCrypt.Verify hache le mot de passe saisi avec le "sel" contenu dans le
    // hachage enregistré, puis compare les deux résultats.
    public async Task<User?> ValiderUtilisateurAsync(string login, string password)
    {
        var user = await userRepository.FindByLoginAsync(login);
        if (user is null)
        {
            return null;
        }
        return BCrypt.Net.BCrypt.Verify(password, user.Password) ? user : null;
    }

    // -----------------------------------------------------------------------
    // fabrique le jeton JWT de [user] et le dépose dans un cookie.
    // Le jeton est SIGNÉ (HMAC-SHA256) avec la clé secrète : le navigateur
    // peut le lire (il n'est pas chiffré), mais pas le modifier - toute
    // modification rendrait la signature invalide.
    // -----------------------------------------------------------------------
    public void DeposerJeton(HttpResponse response, User user)
    {
        var descripteur = new SecurityTokenDescriptor
        {
            Claims = new Dictionary<string, object>
            {
                [ClaimsJwt.Sub] = user.Id,
                [ClaimsJwt.Login] = user.Login,
                [ClaimsJwt.Nom] = user.Nom,
                [ClaimsJwt.Role] = user.Role.ToString(),
            },
            Expires = DateTime.UtcNow.AddSeconds(auth.JwtExpiresInSeconds),
            SigningCredentials = new SigningCredentials(CleDeSignature(auth), SecurityAlgorithms.HmacSha256),
        };
        var jeton = new JsonWebTokenHandler().CreateToken(descripteur);

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

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

Commentons ce code :

  • ligne 38 : le service reçoit par injection le DAO des comptes utilisateurs ([UserRepository], commenté avec la couche DAO) et la configuration de l'authentification. [IOptions<AuthOptions>] donne la section [Auth] de [appsettings.json] sous la forme d'un objet [AuthOptions] (dossier [Config]) : sa propriété [Value] contient la clé secrète, la durée de vie du jeton et l'option [CookieSecure]. Le service est enregistré [Scoped] dans [Program.cs], comme le DAO dont il se sert (cf. chapitre 6) ;
  • ligne 41 : le nom du cookie qui transporte le jeton ;
  • lignes 47-56 : la clé de signature (cf. exemple 25). Nouveauté : une clé trop courte est refusée par une exception claire. La méthode est statique : elle ne dépend que de son paramètre, et elle est appelée au démarrage de l'application, par [AuthentificationJwt] (fichier suivant), à un moment où aucun service n'existe encore. Comme la vérification de longueur a lieu à cet instant, une mauvaise configuration empêche l'application de démarrer, au lieu de faire échouer la première connexion ;
  • lignes 62-69 : le compte si le login et le mot de passe sont corrects, [null] sinon (cf. exemple 25). La méthode est asynchrone : la recherche du compte est une requête à la base de données (cf. chapitre 9) ;
  • lignes 78-92 : le jeton, fabriqué comme dans l'exemple 25. Il contient l'identifiant du compte, le login, le nom affiché dans la barre de navigation et le rôle ; sa durée de vie vient de la configuration (deux heures par défaut) ;
  • lignes 94-100 : le cookie, avec les options de l'exemple 25 ([HttpOnly], [SameSite = Lax]) et deux de plus : [Secure], activé en production (site servi en [HTTPS]), interdit au navigateur d'envoyer le cookie sur une connexion non chiffrée ; [Path = "/"] le fait accompagner toutes les requêtes du site. [MaxAge] fait disparaître le cookie en même temps que le jeton expire ;
  • lignes 104-105 : la déconnexion efface le cookie. Pour que le navigateur reconnaisse le cookie à effacer, le chemin ([Path]) doit être celui du dépôt. La méthode est statique : elle sert aussi au gestionnaire [JwtBearer] (fichier suivant) et au filtre d'exceptions [HtmlExceptionFilter] (dossier [Web]), qui n'ont pas d'instance de [AuthService] sous la main.

11.13.3. Auth/AuthentificationJwt.cs

// =========================================================================
// Auth/AuthentificationJwt.cs
// -------------------------------------------------------------------------
// La configuration de l'authentification par jeton JWT, appelée depuis
// Program.cs (builder.Services.AddAuthentificationJwt(...)).
//
// Le "gestionnaire" JwtBearer d'ASP.NET Core fait, à chaque requête, ce que
// faisaient la stratégie Passport 'jwt' et JwtAuthGuard dans NestJS :
//   1. trouver le jeton ;
//   2. vérifier sa signature et sa date d'expiration ;
//   3. en tirer l'utilisateur (HttpContext.User, cf. UtilisateurConnecte.cs).
//
// Trois adaptations à une application HTML, grâce aux "événements" du
// gestionnaire (des fonctions qu'il appelle à des moments précis) :
//   - OnMessageReceived : le jeton est lu dans un COOKIE, et non dans
//     l'entête "Authorization: Bearer ..." ;
//   - OnChallenge : quand une page protégée est demandée sans jeton valide,
//     JwtBearer répondrait 401 - ce qui convient à un client JavaScript. Un
//     navigateur, lui, doit être RENVOYÉ vers la page de connexion ;
//   - (403 : rôle insuffisant) rien à faire ici : la réponse 403, vide, est
//     transformée en page d'erreur par UseStatusCodePagesWithReExecute
//     (cf. Program.cs et Controllers/ErreurController.cs).
// =========================================================================

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

namespace RdvMedecins.Auth;

public static class AuthentificationJwt
{
    public static IServiceCollection AddAuthentificationJwt(this IServiceCollection services, AuthOptions auth)
    {
        services
            .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(options =>
            {
                // on garde les noms de claims du jeton tels quels ("role", "sub"...)
                // au lieu de les traduire en noms longs à la mode Microsoft
                options.MapInboundClaims = false;

                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateIssuerSigningKey = true,
                    IssuerSigningKey = AuthService.CleDeSignature(auth),
                    ValidateLifetime = true,
                    // un jeton expiré est refusé tout de suite (par défaut, une
                    // tolérance de 5 minutes est accordée pour les écarts d'horloge)
                    ClockSkew = TimeSpan.Zero,
                    // nos jetons n'ont ni émetteur (iss) ni destinataire (aud)
                    ValidateIssuer = false,
                    ValidateAudience = false,
                    // quel claim contient le nom, et lequel le rôle :
                    // [Authorize(Roles = "ADMIN")] compare le claim "role"
                    NameClaimType = ClaimsJwt.Nom,
                    RoleClaimType = ClaimsJwt.Role,
                };

                options.Events = new JwtBearerEvents
                {
                    // 1. le jeton est dans le cookie
                    OnMessageReceived = context =>
                    {
                        context.Token = context.Request.Cookies[AuthService.CookieJwt];
                        return Task.CompletedTask;
                    },

                    // 2. page protégée sans jeton valide -> page de connexion
                    OnChallenge = context =>
                    {
                        // HandleResponse() : "je m'occupe de la réponse", JwtBearer
                        // n'écrira donc pas son 401
                        context.HandleResponse();
                        // un cookie était présent mais n'est plus valide : la session a expiré
                        if (context.Request.Cookies.ContainsKey(AuthService.CookieJwt))
                        {
                            AuthService.EffacerJeton(context.Response);
                            Flash.Deposer(context.HttpContext, TypeFlash.Danger, "ERRORS.SESSION_EXPIRED");
                        }
                        context.Response.Redirect("/login");
                        return Task.CompletedTask;
                    },
                };
            });

        return services;
    }
}

Dans l'exemple 25, la configuration de [JwtBearer] était écrite dans [Program.cs]. Elle est ici rangée dans une méthode d'extension, que [Program.cs] appelle en une ligne : [builder.Services.AddAuthentificationJwt(authOptions)]. Commentons ce code :

  • lignes 32-34 : la méthode d'extension (cf. exemple 06). Elle reçoit directement un objet [AuthOptions], lu dans la configuration par [Program.cs] : au moment où l'on enregistre les services, l'injection de dépendances n'est pas encore disponible, on ne peut donc pas demander un [IOptions<AuthOptions>] ;
  • lignes 37-42 : le schéma d'authentification [JwtBearer], qui garde les noms de claims du jeton (cf. exemple 25) ;
  • lignes 44-54 : les vérifications faites sur chaque jeton : la signature, avec la clé fabriquée par [AuthService.CleDeSignature], et la date d'expiration. Nouveauté par rapport à l'exemple 25 : [ClockSkew = TimeSpan.Zero]. Par défaut, [JwtBearer] accepte encore un jeton pendant cinq minutes après son expiration, pour tolérer un léger désaccord entre les horloges du serveur qui fabrique le jeton et de celui qui le vérifie. Ici, c'est le même serveur : un jeton expiré est refusé tout de suite ;
  • lignes 57-58 : le claim [nom] fournit [User.Identity.Name], le claim [role] est celui que vérifient [[Authorize(Roles = "ADMIN")]] et [User.IsInRole] (cf. exemple 26) ;
  • lignes 64-67 : le jeton est lu dans le cookie, et non dans l'entête [Authorization] (cf. exemple 25) ;
  • lignes 71-82 : une page protégée demandée sans jeton valide renvoie vers la page de connexion (cf. exemple 25). Nouveauté (lignes 77-80) : si la requête portait un cookie de jeton, c'est que l'utilisateur s'était connecté, mais que son jeton a expiré (ou a été falsifié). On efface ce cookie devenu inutile, et on dépose un message flash (cf. exemple 20 ; la classe [Flash] est commentée avec le dossier [Web]) : la page de connexion affichera « votre session a expiré », au lieu de laisser l'utilisateur se demander pourquoi il se retrouve là. Le message est une clé de traduction (cf. exemple 22).

Le refus pour rôle insuffisant (réponse 403) n'a pas besoin d'événement : comme dans l'exemple 26, la réponse vide est transformée en page d'erreur par [UseStatusCodePagesWithReExecute]. Dans le projet [NestJS], ce travail était partagé entre la stratégie [Passport] [jwt] (lire et vérifier le jeton), la garde [JwtAuthGuard] (exiger un jeton) et le filtre d'exceptions (transformer le refus en redirection) ; ici, le gestionnaire [JwtBearer], ses événements et l'attribut [[Authorize]] suffisent.

11.13.4. Auth/SessionInvalideException.cs

// =========================================================================
// Auth/SessionInvalideException.cs
// -------------------------------------------------------------------------
// Le jeton est valide, mais le compte qu'il désigne n'existe plus : il a été
// supprimé depuis la connexion (par le patient lui-même dans un autre
// onglet, ou par l'ADMIN). On traite ce cas comme une session expirée :
// HtmlExceptionFilter efface le cookie et renvoie vers la page de connexion
// (l'équivalent de l'UnauthorizedException de NestJS).
// =========================================================================

namespace RdvMedecins.Auth;

public class SessionInvalideException() : Exception("le compte de l'utilisateur connecté n'existe plus");
  • ligne 13 : un jeton peut être valide (bien signé, non expiré) alors que le compte qu'il désigne a été supprimé depuis la connexion. Les pages du patient le découvrent en cherchant le client correspondant au compte (méthode [PatientConnecteAsync] du fichier [Web/Outils.cs]) et lèvent alors cette exception ; le filtre d'exceptions [HtmlExceptionFilter] (cf. exemple 24 pour les filtres) l'intercepte, efface le cookie et renvoie vers la page de connexion, avec le message « session expirée ». La classe a un constructeur principal sans paramètre, qui transmet son message au constructeur de la classe mère [Exception].

11.13.5. Auth/CaptchaService.cs

// =========================================================================
// Auth/CaptchaService.cs
// -------------------------------------------------------------------------
// Un CAPTCHA ("test pour distinguer un humain d'un robot") fabriqué par le
// serveur lui-même, sans service extérieur : quelques caractères déformés,
// brouillés par des traits, dans une image SVG. Un humain les lit sans
// peine ; un robot qui se contente d'envoyer des formulaires en boucle ne
// sait pas le faire.
//
// Le projet NestJS utilisait la bibliothèque svg-captcha. Ici, pas de
// bibliothèque : l'image est dessinée par la classe DessinCaptcha (plus bas)
// à partir d'une petite "police" vectorielle écrite à la main. Point
// important : chaque caractère est dessiné par des TRAITS (<path>), jamais
// écrit comme du texte (<text>) - sinon un robot n'aurait qu'à lire le code
// source de l'image.
//
// Fonctionnement :
//   1. Generer() tire un texte au hasard, en fait une image, et range la
//      bonne réponse EN MÉMOIRE DU SERVEUR, sous un identifiant aléatoire ;
//   2. la page affiche l'image et place l'identifiant dans un champ caché du
//      formulaire (la réponse, elle, ne quitte jamais le serveur) ;
//   3. Verifier(id, saisie) compare la saisie à la réponse attendue, puis
//      OUBLIE ce captcha : chaque image ne sert qu'une fois, un robot ne peut
//      pas rejouer une réponse déjà trouvée.
//
// Service SINGLETON (une seule instance pour tout le serveur, cf.
// Program.cs), utilisé par plusieurs requêtes EN PARALLÈLE : d'où le
// ConcurrentDictionary, un dictionnaire que plusieurs threads peuvent lire
// et modifier en même temps sans le corrompre.
//
// Limite assumée : la mémoire d'un seul processus. Avec plusieurs serveurs
// derrière un répartiteur de charge, il faudrait un stockage partagé.
// =========================================================================

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

namespace RdvMedecins.Auth;

// Id : à placer dans le champ caché "captchaId" du formulaire
// Svg : l'image, à insérer telle quelle dans la page
public record Captcha(string Id, string Svg);

public class CaptchaService
{
    // durée de validité d'une image : on a 10 minutes pour remplir le formulaire
    private static readonly TimeSpan DureeVie = TimeSpan.FromMinutes(10);

    // garde-fou mémoire : au-delà, on oublie les captchas les plus anciens
    private const int MaxCaptchas = 10_000;

    // id -> (réponse attendue, date d'expiration)
    private readonly ConcurrentDictionary<string, (string Texte, DateTime Expire)> enAttente = new();

    public Captcha Generer()
    {
        Purger();
        var texte = DessinCaptcha.TexteAleatoire(5); // 5 caractères
        var id = Guid.NewGuid().ToString();
        enAttente[id] = (texte, DateTime.UtcNow + DureeVie);
        return new Captcha(id, DessinCaptcha.Svg(texte));
    }

    // vrai si [saisie] est la bonne réponse au captcha [id] (sans tenir compte
    // des majuscules). Le captcha est consommé dans tous les cas.
    // TryRemove est ATOMIQUE : si deux requêtes présentent le même captcha au
    // même instant, une seule le retire - l'autre échoue.
    public bool Verifier(string? id, string? saisie)
    {
        if (id is null || saisie is null || !enAttente.TryRemove(id, out var attendu))
        {
            return false;
        }
        return attendu.Expire > DateTime.UtcNow
            && string.Equals(attendu.Texte, saisie.Trim(), StringComparison.OrdinalIgnoreCase);
    }

    private void Purger()
    {
        var maintenant = DateTime.UtcNow;
        foreach (var (id, captcha) in enAttente)
        {
            if (captcha.Expire <= maintenant || enAttente.Count >= MaxCaptchas)
            {
                enAttente.TryRemove(id, out _);
            }
        }
    }
}

// -------------------------------------------------------------------------
// Le dessin de l'image SVG (150 x 50 pixels).
// -------------------------------------------------------------------------
internal static class DessinCaptcha
{
    private const int Largeur = 150;
    private const int Hauteur = 50;

    // La "police" : chaque caractère est une liste de traits, chaque trait une
    // suite de points (x, y) dans une grille de 4 x 6 (y vers le bas).
    // Caractères trop faciles à confondre entre eux : exclus (0 O, 1 I L).
    private static readonly Dictionary<char, double[][]> Police = new()
    {
        ['A'] = [[0, 6, 2, 0, 4, 6], [0.8, 3.8, 3.2, 3.8]],
        ['B'] = [[0, 0, 0, 6, 3, 6, 4, 5, 4, 4, 3, 3, 0, 3], [0, 0, 3, 0, 4, 1, 4, 2, 3, 3]],
        ['C'] = [[4, 1, 3, 0, 1, 0, 0, 1, 0, 5, 1, 6, 3, 6, 4, 5]],
        ['D'] = [[0, 0, 0, 6, 2.5, 6, 4, 4.5, 4, 1.5, 2.5, 0, 0, 0]],
        ['E'] = [[4, 0, 0, 0, 0, 6, 4, 6], [0, 3, 3, 3]],
        ['F'] = [[4, 0, 0, 0, 0, 6], [0, 3, 3, 3]],
        ['G'] = [[4, 1, 3, 0, 1, 0, 0, 1, 0, 5, 1, 6, 3, 6, 4, 5, 4, 3.5, 2.5, 3.5]],
        ['H'] = [[0, 0, 0, 6], [4, 0, 4, 6], [0, 3, 4, 3]],
        ['J'] = [[4, 0, 4, 5, 3, 6, 1, 6, 0, 5]],
        ['K'] = [[0, 0, 0, 6], [4, 0, 0, 3.5], [1.3, 2.6, 4, 6]],
        ['M'] = [[0, 6, 0, 0, 2, 3.5, 4, 0, 4, 6]],
        ['N'] = [[0, 6, 0, 0, 4, 6, 4, 0]],
        ['P'] = [[0, 6, 0, 0, 3, 0, 4, 1, 4, 2, 3, 3, 0, 3]],
        ['Q'] = [[1, 0, 3, 0, 4, 1, 4, 5, 3, 6, 1, 6, 0, 5, 0, 1, 1, 0], [2.5, 4.5, 4, 6.3]],
        ['R'] = [[0, 6, 0, 0, 3, 0, 4, 1, 4, 2, 3, 3, 0, 3], [2, 3, 4, 6]],
        ['S'] = [[4, 1, 3, 0, 1, 0, 0, 1, 0, 2, 1, 3, 3, 3, 4, 4, 4, 5, 3, 6, 1, 6, 0, 5]],
        ['T'] = [[0, 0, 4, 0], [2, 0, 2, 6]],
        ['U'] = [[0, 0, 0, 5, 1, 6, 3, 6, 4, 5, 4, 0]],
        ['V'] = [[0, 0, 2, 6, 4, 0]],
        ['W'] = [[0, 0, 1, 6, 2, 2.5, 3, 6, 4, 0]],
        ['X'] = [[0, 0, 4, 6], [4, 0, 0, 6]],
        ['Y'] = [[0, 0, 2, 3, 4, 0], [2, 3, 2, 6]],
        ['Z'] = [[0, 0, 4, 0, 0, 6, 4, 6]],
        ['2'] = [[0, 1, 1, 0, 3, 0, 4, 1, 4, 2, 0, 6, 4, 6]],
        ['3'] = [[0, 1, 1, 0, 3, 0, 4, 1, 4, 2, 3, 3, 1.5, 3], [3, 3, 4, 4, 4, 5, 3, 6, 1, 6, 0, 5]],
        ['4'] = [[3, 6, 3, 0, 0, 4, 4, 4]],
        ['5'] = [[4, 0, 0, 0, 0, 3, 3, 3, 4, 4, 4, 5, 3, 6, 1, 6, 0, 5]],
        ['6'] = [[4, 1, 3, 0, 1, 0, 0, 1, 0, 5, 1, 6, 3, 6, 4, 5, 4, 4, 3, 3, 0, 3]],
        ['7'] = [[0, 0, 4, 0, 1.5, 6]],
        ['8'] = [[1, 3, 0, 2, 0, 1, 1, 0, 3, 0, 4, 1, 4, 2, 3, 3, 1, 3, 0, 4, 0, 5, 1, 6, 3, 6, 4, 5, 4, 4, 3, 3]],
        ['9'] = [[4, 3, 1, 3, 0, 2, 0, 1, 1, 0, 3, 0, 4, 1, 4, 5, 3, 6, 1, 6, 0, 5]],
    };

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

    // quelques couleurs foncées, bien lisibles sur le fond clair
    private static readonly string[] Couleurs = ["#1f4e79", "#7b2d26", "#2e6b30", "#5b2c83", "#8a5a00", "#23395d", "#9c2a5b"];

    // RandomNumberGenerator : un générateur de nombres aléatoires "cryptographique",
    // imprévisible (contrairement à Random, dont la suite pourrait être devinée)
    public static string TexteAleatoire(int longueur) =>
        new(Enumerable.Range(0, longueur).Select(_ => Alphabet[RandomNumberGenerator.GetInt32(Alphabet.Length)]).ToArray());

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

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

        // les caractères : chacun agrandi, penché, décalé, et chaque point
        // légèrement déplacé au hasard
        var pas = (Largeur - 20) / (double)texte.Length;
        for (var i = 0; i < texte.Length; i++)
        {
            var echelle = Hasard(4.6, 5.6); // taille d'une unité de la grille, en pixels
            var angle = Hasard(-0.35, 0.35); // rotation, en radians (environ ±20°)
            var cx = 10 + pas * (i + 0.5) + Hasard(-3, 3); // centre du caractère
            var cy = Hauteur / 2.0 + Hasard(-4, 4);
            var couleur = Couleurs[RandomNumberGenerator.GetInt32(Couleurs.Length)];
            var d = new StringBuilder();
            foreach (var trait in Police[texte[i]])
            {
                for (var k = 0; k < trait.Length; k += 2)
                {
                    // point de la grille, centré sur (2, 3), déformé, tourné, placé
                    var x = (trait[k] - 2 + Hasard(-0.2, 0.2)) * echelle;
                    var y = (trait[k + 1] - 3 + Hasard(-0.2, 0.2)) * echelle;
                    var px = cx + x * Math.Cos(angle) - y * Math.Sin(angle);
                    var py = cy + x * Math.Sin(angle) + y * Math.Cos(angle);
                    d.Append(k == 0 ? 'M' : 'L').Append(Nombre(px)).Append(' ').Append(Nombre(py)).Append(' ');
                }
            }
            svg.Append($"<path d=\"{d.ToString().TrimEnd()}\" fill=\"none\" stroke=\"{couleur}\" stroke-width=\"{Nombre(Hasard(2.2, 3))}\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>");
        }

        // 3 traits de brouillage : des courbes qui traversent l'image
        for (var n = 0; n < 3; n++)
        {
            var couleur = Couleurs[RandomNumberGenerator.GetInt32(Couleurs.Length)];
            svg.Append($"<path d=\"M{Nombre(Hasard(0, 20))} {Nombre(Hasard(5, 45))} " +
                       $"C{Nombre(Hasard(30, 60))} {Nombre(Hasard(0, 50))} {Nombre(Hasard(90, 120))} {Nombre(Hasard(0, 50))} " +
                       $"{Nombre(Hasard(130, 150))} {Nombre(Hasard(5, 45))}\" fill=\"none\" stroke=\"{couleur}\" stroke-width=\"1.3\"/>");
        }

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

    // un nombre avec un point décimal, quelle que soit la langue du serveur
    // (en français, ToString() écrirait "12,5" - illisible pour SVG)
    private static string Nombre(double valeur) => valeur.ToString("0.#", CultureInfo.InvariantCulture);
}

Le fichier contient deux classes : le service, identique à celui de l'exemple 27 à un garde-fou près, et la classe [DessinCaptcha] qui fabrique l'image, déjà commentée dans l'exemple 27 (fichier [Auth/DessinCaptcha.cs]). Commentons ce code :

  • ligne 44 : un captcha, tel que la page le reçoit : l'identifiant, à placer dans un champ caché du formulaire, et l'image [SVG] ;
  • lignes 49-52 : une image est valable dix minutes, et le nombre de captchas en attente est plafonné. Chaque affichage de la page de connexion ajoute un captcha en mémoire ; sans plafond, un robot qui demanderait cette page des millions de fois sans jamais répondre finirait par épuiser la mémoire du serveur ;
  • ligne 55 : identifiant → (réponse attendue, date d'expiration). Le couple est un tuple dont les éléments sont nommés ([Texte], [Expire]). Le service est un singleton, utilisé par des requêtes simultanées : d'où le [ConcurrentDictionary] (cf. exemple 27) ;
  • lignes 57-63 : le ménage, puis un texte de cinq caractères tiré au hasard, mémorisé sous un identifiant aléatoire ([Guid.NewGuid()], impossible à deviner), et dessiné ;
  • lignes 70-77 : la vérification, identique à celle de l'exemple 27 : le captcha est consommé dans tous les cas ([TryRemove]), puis on vérifie qu'il n'a pas expiré et que la saisie, débarrassée de ses espaces, est la bonne, sans tenir compte des majuscules. [TryRemove] est atomique : si deux requêtes présentent le même captcha au même instant, une seule le retire ;
  • lignes 80-87 : les captchas expirés sont oubliés ; si le plafond est atteint, on retire aussi des captchas encore valides, jusqu'à repasser sous le plafond. Un [ConcurrentDictionary] peut être modifié pendant qu'on le parcourt, ce qu'interdit un [Dictionary] ordinaire (cf. [LimiteurTentativesService], plus bas). En revanche, il ne conserve pas l'ordre d'insertion : les captchas ainsi retirés ne sont pas forcément les plus anciens, alors que la [Map] du projet [NestJS] permettait d'oublier d'abord les plus anciens. Le cas ne se produit qu'en cas d'attaque : l'approximation suffit ;
  • ligne 96 : le dessin de l'image. [internal] : la classe n'est visible que dans l'application elle-même ;
  • lignes 104-136 : la « police » : pour chaque caractère, une liste de traits, chaque trait étant une suite de coordonnées (x, y) dans une grille de 4 × 6. Les crochets [[...]] sont des expressions de collection ([C#] 12), une écriture compacte des tableaux ;
  • ligne 139 : les caractères utilisables, c'est-à-dire les clés de la police. [..] étale une collection dans une autre ;
  • lignes 146-147 et lignes 149-150 : le tirage au hasard utilise [RandomNumberGenerator], un générateur cryptographique, dont les tirages sont imprévisibles ;
  • lignes 152-194 : l'image est un texte [SVG], assemblé dans un [StringBuilder] (une chaîne qu'on allonge sans la recopier à chaque ajout). Chaque caractère (lignes 160-181) devient un chemin [<path>] : chaque point de la grille est légèrement déplacé, agrandi, tourné et placé à son rang dans l'image, avec une couleur et une épaisseur de trait tirées au hasard. Les caractères sont des traits, jamais du texte : un robot ne peut pas les lire dans le code de l'image. Trois courbes de brouillage traversent ensuite l'image (lignes 185-190) ;
  • ligne 199 : les nombres du [SVG] s'écrivent avec un point décimal. [CultureInfo.InvariantCulture] impose cette écriture quelle que soit la langue du serveur : en français, [ToString()] écrirait une virgule (cf. chapitre 5).

11.13.6. Auth/LimiteurTentativesService.cs

// =========================================================================
// Auth/LimiteurTentativesService.cs
// -------------------------------------------------------------------------
// Second rempart contre les robots (le premier est le captcha) : on COMPTE
// les tentatives, et au-delà d'un seuil on BLOQUE pendant un moment.
//
//   - connexion : après LoginMaxEchecs échecs (mauvais captcha ou mauvais
//     mot de passe) pour un même login depuis une même adresse IP, ce couple
//     est bloqué LoginBlocageMinutes minutes. Une même adresse IP est aussi
//     bloquée si elle cumule 5 fois plus d'échecs, tous logins confondus (un
//     robot qui essaie des milliers de logins différents) ;
//   - création de compte : au plus InscriptionMaxParHeure comptes créés par
//     heure depuis une même adresse IP.
//
// Attention en salle de cours : tous les postes sortent souvent sur Internet
// par la MÊME adresse IP. C'est pourquoi le blocage de la connexion porte
// d'abord sur le couple (IP, login) - un étudiant qui se trompe de mot de
// passe ne bloque pas ses voisins - et pourquoi les seuils sont réglables
// dans appsettings.json (section "Protection").
//
// Service singleton, appelé par des requêtes parallèles : toutes les
// opérations sur les compteurs se font sous un verrou (lock), qui garantit
// qu'un seul thread à la fois lit ET modifie un compteur.
// =========================================================================

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

namespace RdvMedecins.Auth;

public class LimiteurTentativesService(IOptions<ProtectionOptions> options)
{
    private class Compteur
    {
        public int Nombre; // tentatives comptées dans la fenêtre en cours
        public DateTime Debut; // début de la fenêtre de comptage
        public DateTime BloqueJusqua; // DateTime.MinValue si pas bloqué
    }

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

    private readonly Dictionary<string, Compteur> compteurs = [];
    private readonly Lock verrou = new(); // le type Lock de .NET 9+, fait pour "lock"

    private readonly int maxEchecsLogin = options.Value.LoginMaxEchecs;
    private readonly TimeSpan dureeBlocageLogin = TimeSpan.FromMinutes(options.Value.LoginBlocageMinutes);
    private readonly int maxInscriptions = options.Value.InscriptionMaxParHeure;

    // ---------------------------------------------------------------- connexion

    // nombre de minutes de blocage restantes (0 = la tentative est autorisée)
    public int MinutesBlocageLogin(string ip, string login)
    {
        lock (verrou)
        {
            return Math.Max(MinutesRestantes(CleLogin(ip, login)), MinutesRestantes(CleIp(ip)));
        }
    }

    public void EchecLogin(string ip, string login)
    {
        lock (verrou)
        {
            Compter(CleLogin(ip, login), maxEchecsLogin, dureeBlocageLogin, dureeBlocageLogin);
            Compter(CleIp(ip), maxEchecsLogin * 5, dureeBlocageLogin, dureeBlocageLogin);
        }
    }

    // une connexion réussie remet à zéro le compteur de CE login
    public void ReussiteLogin(string ip, string login)
    {
        lock (verrou)
        {
            compteurs.Remove(CleLogin(ip, login));
        }
    }

    // ---------------------------------------------------------------- inscription

    public int MinutesBlocageInscription(string ip)
    {
        lock (verrou)
        {
            return MinutesRestantes($"inscription|{ip}");
        }
    }

    // fenêtre d'une heure ; une fois le quota atteint, blocage jusqu'à la fin de l'heure
    public void InscriptionReussie(string ip)
    {
        lock (verrou)
        {
            Compter($"inscription|{ip}", maxInscriptions, UneHeure, UneHeure);
        }
    }

    // ---------------------------------------------------------------- mécanique commune
    // (les méthodes privées sont toujours appelées sous le verrou)

    private int MinutesRestantes(string cle)
    {
        if (!compteurs.TryGetValue(cle, out var c))
        {
            return 0;
        }
        var reste = c.BloqueJusqua - DateTime.UtcNow;
        return reste > TimeSpan.Zero ? (int)Math.Ceiling(reste.TotalMinutes) : 0;
    }

    // compte une tentative ; si le seuil est atteint dans la fenêtre, bloque
    private void Compter(string cle, int max, TimeSpan fenetre, TimeSpan blocage)
    {
        var maintenant = DateTime.UtcNow;
        Purger(maintenant);
        if (!compteurs.TryGetValue(cle, out var c) || maintenant - c.Debut > fenetre)
        {
            c = new Compteur { Nombre = 0, Debut = maintenant, BloqueJusqua = DateTime.MinValue };
            compteurs[cle] = c;
        }
        c.Nombre++;
        if (c.Nombre >= max)
        {
            c.BloqueJusqua = maintenant + blocage;
            c.Nombre = 0; // après le blocage, on repart de zéro
            c.Debut = c.BloqueJusqua;
        }
    }

    // on oublie les compteurs qui ne servent plus (ni bloqués, ni récents)
    private void Purger(DateTime maintenant)
    {
        if (compteurs.Count < 1000)
        {
            return;
        }
        foreach (var (cle, c) in compteurs.ToList())
        {
            if (c.BloqueJusqua < maintenant && maintenant - c.Debut > UneHeure)
            {
                compteurs.Remove(cle);
            }
        }
    }

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

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

Par rapport à l'exemple 27, le limiteur protège deux opérations (la connexion et la création de compte), le blocage de la connexion porte sur le couple (adresse IP, login), et les seuils sont réglables dans [appsettings.json]. Commentons ce code :

  • ligne 31 : [IOptions<ProtectionOptions>] donne la section [Protection] de [appsettings.json] (dossier [Config]) : le nombre d'échecs de connexion tolérés (5 par défaut), la durée du blocage (15 minutes) et le nombre de comptes créés par heure (10) ;
  • lignes 33-37 : un compteur : le nombre de tentatives, le début de la fenêtre de comptage, la fin du blocage ([DateTime.MinValue], la plus petite date possible, s'il n'y a pas de blocage). C'est une classe imbriquée, privée : elle n'existe que pour ce service. C'est une classe, et non un [record] ni une structure : un compteur lu dans le dictionnaire est modifié sur place ;
  • lignes 42-43 : un [Dictionary] ordinaire, protégé par un verrou (cf. exemple 27). Un [ConcurrentDictionary] ne suffirait pas : compter une tentative, c'est lire un compteur, le modifier, peut-être le remplacer, et toute cette suite d'opérations doit s'exécuter sans qu'une autre requête s'intercale. Chaque méthode publique fait donc son travail à l'intérieur d'un bloc [lock (verrou)] ;
  • lignes 45-47 : les seuils, lus une fois pour toutes. Le paramètre du constructeur principal est utilisable dans l'initialisation des champs ;
  • lignes 52-56 : le temps de blocage restant pour une tentative de connexion : le plus long des deux blocages possibles, celui du couple (adresse IP, login) et celui de l'adresse IP seule ;
  • lignes 60-65 : un échec est compté deux fois. Pour le couple (adresse IP, login) : cinq échecs sur le même login bloquent ce login, depuis cette adresse seulement. Pour l'adresse IP seule, avec un seuil cinq fois plus haut : un robot qui essaie un grand nombre de logins différents est bloqué, lui aussi. En salle de cours, tous les postes sortent souvent sur Internet par la même adresse IP : un étudiant qui se trompe de mot de passe ne doit pas bloquer ses voisins, d'où le blocage par couple ;
  • lignes 70-74 : une connexion réussie remet à zéro le compteur de ce login (pas celui de l'adresse IP) ;
  • lignes 80-84 et lignes 89-93 : pour la création de compte, ce sont les réussites que l'on compte : au plus dix comptes par heure depuis une même adresse IP. Le quota atteint, l'adresse est bloquée une heure ;
  • lignes 100-107 : les minutes de blocage restantes, arrondies au nombre entier supérieur ([Math.Ceiling]) : il reste « 1 minute », et non « 0 minute », quand il reste 20 secondes. Ces méthodes privées ne prennent pas le verrou : elles sont toujours appelées par une méthode publique qui le détient déjà ;
  • lignes 111-125 : le comptage. Une clé inconnue, ou dont la fenêtre de comptage est dépassée, repart d'un compteur neuf. Quand le seuil est atteint, la clé est bloquée, et le comptage reprendra de zéro à la fin du blocage ;
  • lignes 130-140 : le ménage des compteurs devenus inutiles (ni bloqués, ni récents), fait seulement quand le dictionnaire dépasse mille entrées. On parcourt une copie des entrées ([ToList()]) : un [Dictionary] ne peut pas être modifié pendant qu'on le parcourt ;
  • lignes 145-147 : la fabrication des clés du dictionnaire. Le login est débarrassé de ses espaces et mis en minuscules : [Admin] et [admin] partagent le même compteur, un robot ne peut pas contourner le blocage en changeant la casse.

11.13.7. Auth/PasseInscriptionService.cs

// =========================================================================
// Auth/PasseInscriptionService.cs
// -------------------------------------------------------------------------
// Le "laissez-passer" de la création de compte.
//
// Pour ouvrir la page "Créer un compte client", il faut d'abord avoir
// recopié le captcha de la page d'authentification (cf.
// InscriptionController, POST /inscription/acces). Le serveur délivre alors
// un laissez-passer :
//   - un identifiant aléatoire, rangé dans un cookie HttpOnly ;
//   - valable 15 minutes (le temps de remplir le formulaire, même en s'y
//     reprenant à plusieurs fois après des erreurs de saisie) ;
//   - consommé dès qu'un compte est créé : UN captcha résolu = UN compte au
//     plus. Un robot ne peut pas créer des comptes en série.
//
// Sans laissez-passer valide, GET et POST /inscription renvoient vers la
// page d'authentification : taper directement l'URL /inscription ne permet
// pas de contourner le captcha.
//
// Comme le captcha, les laissez-passer vivent dans la mémoire du processus.
// =========================================================================

using System.Collections.Concurrent;

namespace RdvMedecins.Auth;

public class PasseInscriptionService
{
    public const string Cookie = "rdvmedecins_inscription";

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

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

    // délivre un nouveau laissez-passer ; renvoie son identifiant
    public string Emettre()
    {
        Purger();
        var id = Guid.NewGuid().ToString();
        passes[id] = DateTime.UtcNow + DureeVie;
        return id;
    }

    // vrai si [id] est un laissez-passer existant et non expiré
    public bool EstValide(string? id) =>
        id is not null && passes.TryGetValue(id, out var expire) && expire > DateTime.UtcNow;

    // le laissez-passer a servi : on l'oublie
    public void Consommer(string? id)
    {
        if (id is not null)
        {
            passes.TryRemove(id, out _);
        }
    }

    // le cookie n'est envoyé par le navigateur qu'aux adresses /inscription...
    public static CookieOptions OptionsCookie() => new()
    {
        HttpOnly = true,
        SameSite = SameSiteMode.Lax,
        MaxAge = DureeVie,
        Path = "/inscription",
    };

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

La création d'un compte client est ouverte depuis la page de connexion, à condition d'avoir résolu son captcha. Le formulaire d'inscription est affiché et envoyé par des requêtes ultérieures : il faut qu'elles sachent qu'une requête précédente a résolu le captcha. C'est le rôle du laissez-passer, délivré et vérifié par [InscriptionController] (commenté avec les contrôleurs) :

  • ligne 29 : le nom du cookie qui transporte le laissez-passer ;
  • lignes 31-35 : un laissez-passer est valable quinze minutes ; les laissez-passer en attente sont gardés en mémoire, comme les captchas, avec le même plafond ;
  • lignes 38-43 : un laissez-passer est un identifiant aléatoire, mémorisé avec sa date d'expiration ; le contrôleur le dépose dans un cookie ;
  • lignes 47-48 : le formulaire d'inscription n'est affiché, et accepté, qu'avec un laissez-passer valide. La vérification ne le consomme pas ([TryGetValue], et non [TryRemove]) : l'utilisateur peut renvoyer le formulaire plusieurs fois s'il a fait des erreurs de saisie ;
  • lignes 51-55 : un compte créé consomme le laissez-passer : un captcha résolu donne droit à un compte, pas plus ;
  • lignes 60-65 : les options du cookie. [new()] crée un objet du type annoncé par la méthode ([CookieOptions]). L'option [Path = "/inscription"] limite l'envoi du cookie aux URL qui commencent par [/inscription] : les autres requêtes ne le transportent pas inutilement ;
  • lignes 68-75 : le ménage, identique à celui du captcha.

11.13.8. Controllers/AuthController.cs

// =========================================================================
// Controllers/AuthController.cs
// -------------------------------------------------------------------------
// Les routes NON protégées de la connexion :
//   GET  /        redirige vers la page d'accueil du rôle (ou vers /login)
//   GET  /login   affiche le formulaire de connexion (avec son captcha)
//   POST /login   vérifie limite de tentatives, captcha, login / mot de passe
//   POST /logout  efface le cookie du jeton
//
// Un contrôleur MVC hérite de Controller et renvoie un IActionResult :
// View(...) pour afficher une page, Redirect(...) pour rediriger...
// [HttpGet("login")] / [HttpPost("login")] : le "routage par attributs",
// l'équivalent de @Get('login') / @Post('login').
//
// Protection contre les robots, dans cet ordre :
//   1. trop d'échecs récents pour ce login depuis cette adresse IP ?
//      -> refus immédiat, sans même regarder le mot de passe ;
//   2. mauvaise réponse au captcha ? -> refus (compté comme un échec) ;
//   3. seulement alors, vérification du mot de passe.
// On ne vérifie JAMAIS le mot de passe d'une tentative refusée aux étapes
// 1 ou 2 : un robot ne peut donc rien apprendre en insistant.
// =========================================================================

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

namespace RdvMedecins.Controllers;

public class AuthController(
    AuthService authService,
    CaptchaService captchaService,
    LimiteurTentativesService limiteur) : Controller
{
    // la page d'accueil de chaque rôle, juste après la connexion
    public static string PageAccueil(Role role) => role == Role.USER ? "/mon-espace" : "/agenda";

    [HttpGet("/")]
    public IActionResult Accueil()
    {
        // User : l'utilisateur de la requête, reconstitué à partir du jeton par
        // le middleware d'authentification (cf. Auth/UtilisateurConnecte.cs)
        var utilisateur = User.Utilisateur();
        return Redirect(utilisateur is not null ? PageAccueil(utilisateur.Role) : "/login");
    }

    // GET /login[?login=xxx] : le paramètre facultatif pré-remplit le champ
    // login (utilisé juste après la création d'un compte, cf. InscriptionController)
    [HttpGet("login")]
    public IActionResult AfficherLogin(string? login)
    {
        // déjà connecté (jeton encore valide) : directement à la page d'accueil
        var utilisateur = User.Utilisateur();
        if (utilisateur is not null)
        {
            return Redirect(PageAccueil(utilisateur.Role));
        }
        return Formulaire(login is null ? "" : login[..Math.Min(login.Length, 30)]);
    }

    // les paramètres de l'action sont remplis avec les champs du formulaire de
    // même nom (liaison de modèle) ; [FromForm] précise qu'on les cherche dans
    // le corps de la requête POST
    [HttpPost("login")]
    public async Task<IActionResult> SeConnecter(
        [FromForm] string? login,
        [FromForm] string? password,
        [FromForm] string? captchaId,
        [FromForm] string? captcha)
    {
        login = (login ?? "").Trim();
        password ??= "";
        var ip = AdresseIp(HttpContext);

        // 1. trop d'échecs récents ?
        var minutes = limiteur.MinutesBlocageLogin(ip, login);
        if (minutes > 0)
        {
            return Formulaire(login, "ERRORS.TOO_MANY_ATTEMPTS", new Dictionary<string, string> { ["minutes"] = minutes.ToString() });
        }

        // 2. le captcha
        if (!captchaService.Verifier(captchaId, captcha))
        {
            limiteur.EchecLogin(ip, login);
            return Formulaire(login, "ERRORS.WRONG_CAPTCHA");
        }

        // 3. le mot de passe
        var user = login != "" && password != "" ? await authService.ValiderUtilisateurAsync(login, password) : null;
        if (user is null)
        {
            limiteur.EchecLogin(ip, login);
            return Formulaire(login, "LOGIN.ERROR");
        }

        limiteur.ReussiteLogin(ip, login);
        authService.DeposerJeton(Response, user);
        return Redirect(PageAccueil(user.Role));
    }

    // déconnexion en POST (et non par un simple lien GET) : un site tiers ne
    // peut pas déconnecter l'utilisateur à son insu (cf. SameSite, AuthService)
    [HttpPost("logout")]
    public IActionResult SeDeconnecter()
    {
        AuthService.EffacerJeton(Response);
        return Redirect("/login");
    }

    // l'adresse IP du visiteur. Derrière un proxy inverse (nginx...), il
    // faudrait activer le middleware UseForwardedHeaders pour obtenir celle du
    // visiteur et non celle du proxy.
    public static string AdresseIp(HttpContext http) => http.Connection.RemoteIpAddress?.ToString() ?? "inconnue";

    // le formulaire, toujours avec un NOUVEAU captcha (chaque image ne sert qu'une fois)
    private ViewResult Formulaire(string login, string? erreur = null, IReadOnlyDictionary<string, string>? erreurParams = null) =>
        View("Login", new LoginModele
        {
            TitrePage = "LOGIN.TITLE",
            Login = login, // on réaffiche le login saisi, jamais le mot de passe
            Captcha = captchaService.Generer(),
            Erreur = erreur,
            ErreurParams = erreurParams,
        });
}

Commentons ce code :

  • lignes 31-34 : le contrôleur reçoit les trois services dont il a besoin. Aucun attribut [[Authorize]] : ses pages sont accessibles sans être connecté ;
  • ligne 37 : la page d'accueil dépend du rôle : « Mon espace » pour un patient, l'agenda pour les autres ;
  • lignes 39-45 : la racine du site redirige vers la page d'accueil du rôle, ou vers la page de connexion. Le middleware d'authentification s'exécute pour toutes les requêtes, protégées ou non : si le cookie contient un jeton valide, [User] est rempli, même ici. Le projet [NestJS] devait, lui, vérifier le jeton lui-même (méthode [lireJeton]), la garde n'étant pas appliquée à cette route ;
  • lignes 50-59 : un utilisateur déjà connecté est envoyé à sa page d'accueil. Le paramètre facultatif [login], venu de l'URL (cf. exemple 07), préremplit le champ après la création d'un compte. [login[..n]] (opérateur de plage) en garde les [n] premiers caractères : on borne sa longueur à 30, car une donnée venue de la requête n'est jamais utilisée sans précaution ;
  • lignes 65-70 : les champs du formulaire de connexion (cf. exemple 11), dont l'identifiant du captcha, transmis par un champ caché, et la saisie de l'utilisateur. Tous sont déclarés [string?] : un champ absent donne [null], et c'est l'action qui décide quoi en faire ;
  • lignes 72-74 : un login absent devient une chaîne vide ; [password ??= ""] n'affecte la chaîne vide que si [password] est [null] ;
  • lignes 77-80 : 1. un couple bloqué n'est même pas écouté : ni le captcha, ni le mot de passe ne sont examinés. L'erreur est une clé de traduction, avec un paramètre : le nombre de minutes à attendre ;
  • lignes 84-87 : 2. le captcha. Un mauvais captcha compte comme un échec (cf. exemple 27) ;
  • lignes 91-95 : 3. seulement alors, le mot de passe. Un login ou un mot de passe vide n'est pas soumis au calcul [bcrypt], coûteux. Login inconnu ou mot de passe faux : le même message, volontairement vague (cf. exemple 25), et un échec de plus ;
  • lignes 98-100 : le succès : compteur remis à zéro, jeton déposé dans le cookie, redirection vers la page d'accueil du rôle ;
  • lignes 105-109 : la déconnexion, en POST (cf. exemple 25) ;
  • ligne 115 : l'adresse IP du client, fournie par la connexion réseau ([RemoteIpAddress], qui peut être [null], d'où [?.] et [??]). La méthode est publique et statique : [InscriptionController] et [EspacePatientController] s'en servent aussi pour le limiteur. Derrière un serveur intermédiaire (un proxy inverse comme [nginx]), cette adresse serait celle du proxy : le commentaire indique le middleware à activer dans ce cas ;
  • lignes 118-125 : la page de connexion, toujours avec un nouveau captcha : chaque image ne sert qu'une fois. Son modèle, [LoginModele] (dossier [Models]), contient le titre de la page (une clé de traduction), le login saisi (jamais le mot de passe), le captcha, et l'erreur éventuelle, clé de traduction et paramètres. [erreur] et [erreurParams] sont des paramètres facultatifs : ils valent [null] quand l'appel ne les fournit pas.

11.14. La couche web : les services communs

Avant les contrôleurs et les vues, voici ce qu'ils ont en commun : la traduction, la classe mère des vues, les messages flash, le filtre d'exceptions, le cache des listes de médecins et de clients, la préparation des formulaires à réafficher, et quelques fonctions utilitaires. Tous ces fichiers sont dans le dossier [Web]. On y retrouve, réunies, la plupart des techniques des exemples 16 à 24.

11.14.1. Web/Traducteur.cs

// =========================================================================
// Web/Traducteur.cs
// -------------------------------------------------------------------------
// Traduction français / anglais de l'interface.
//
// Les dictionnaires sont les MÊMES fichiers que ceux du projet NestJS
// (i18n/fr.json, i18n/en.json), lus une seule fois au démarrage. Une vue
// Razor traduit une clé avec @T("CLE") (cf. Web/PageVue.cs).
//
// ASP.NET Core propose aussi son propre système (IStringLocalizer et
// fichiers .resx) ; garder les fichiers JSON permet de partager les
// dictionnaires avec les autres versions de l'application.
//
// LA LANGUE COURANTE : c'est le middleware de localisation d'ASP.NET Core
// (UseRequestLocalization, cf. Program.cs) qui la détermine à chaque requête,
// d'après un cookie, et la range dans CultureInfo.CurrentUICulture.
// =========================================================================

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

namespace RdvMedecins.Web;

public partial class Traducteur
{
    public static readonly string[] Langues = ["fr", "en"];
    public const string LangueParDefaut = "fr";

    // langue -> (clé complète "LOGIN.TITLE" -> texte)
    private readonly Dictionary<string, Dictionary<string, string>> dictionnaires = [];

    public Traducteur(IWebHostEnvironment environnement)
    {
        foreach (var langue in Langues)
        {
            var chemin = Path.Combine(environnement.ContentRootPath, "i18n", $"{langue}.json");
            using var document = JsonDocument.Parse(File.ReadAllText(chemin));
            var dictionnaire = new Dictionary<string, string>();
            Aplatir(document.RootElement, "", dictionnaire);
            dictionnaires[langue] = dictionnaire;
        }
    }

    // la langue de la requête en cours : "fr" ou "en"
    public static string LangueCourante()
    {
        var langue = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
        return Langues.Contains(langue) ? langue : LangueParDefaut;
    }

    // -----------------------------------------------------------------------
    // Traduire("MEDECINS.ADDED", { ["nom"] = "Marie PELISSIER" })
    //   -> cherche "MEDECINS.ADDED" dans le dictionnaire de la langue courante,
    //      et remplace {nom} par sa valeur. Une clé absente de la langue
    //      demandée est cherchée en français ; absente partout, on affiche la
    //      clé elle-même (on repère ainsi immédiatement un oubli).
    // -----------------------------------------------------------------------
    public string Traduire(string? cle, IReadOnlyDictionary<string, string>? parametres = null)
    {
        if (string.IsNullOrEmpty(cle))
        {
            return "";
        }
        var texte = dictionnaires[LangueCourante()].GetValueOrDefault(cle)
                    ?? dictionnaires[LangueParDefaut].GetValueOrDefault(cle)
                    ?? cle;
        if (parametres is null || parametres.Count == 0)
        {
            return texte;
        }
        return Parametre().Replace(texte, m => parametres.TryGetValue(m.Groups[1].Value, out var valeur) ? valeur : m.Value);
    }

    // { "LOGIN": { "TITLE": "Connexion" } } -> { "LOGIN.TITLE": "Connexion" }
    private static void Aplatir(JsonElement noeud, string prefixe, Dictionary<string, string> resultat)
    {
        foreach (var propriete in noeud.EnumerateObject())
        {
            var cle = prefixe == "" ? propriete.Name : $"{prefixe}.{propriete.Name}";
            if (propriete.Value.ValueKind == JsonValueKind.Object)
            {
                Aplatir(propriete.Value, cle, resultat);
            }
            else
            {
                resultat[cle] = propriete.Value.GetString() ?? "";
            }
        }
    }

    // l'expression régulière {nom}, compilée une fois pour toutes par le
    // compilateur C# ("générateur de source" [GeneratedRegex])
    [GeneratedRegex(@"\{(\w+)\}")]
    private static partial Regex Parametre();
}

C'est le service de traduction de l'exemple 22, enregistré comme singleton dans [Program.cs] : les dictionnaires [i18n/fr.json] et [i18n/en.json] sont les mêmes fichiers que ceux de la version [NestJS], lus une seule fois au démarrage. Une seule différence :

  • lignes 65-67 : une clé absente du dictionnaire anglais est cherchée dans le dictionnaire français ([LangueParDefaut]) ; absente partout, c'est la clé elle-même qui est affichée. Les deux opérateurs [??] s'enchaînent : on prend la première valeur qui n'est pas [null].

11.14.2. Web/PageVue.cs

// =========================================================================
// Web/PageVue.cs
// -------------------------------------------------------------------------
// La classe mère de TOUTES les vues Razor de l'application (cf.
// Views/_ViewImports.cshtml : @inherits PageVue<TModel>).
//
// Une vue .cshtml est compilée en une classe C#, fille de RazorPage<TModel>.
// En intercalant notre propre classe, on donne à toutes les vues ce dont
// elles ont besoin, quelle que soit la page :
//   - @T("CLE")          : la traduction d'une clé (l'équivalent du helper
//                          Handlebars {{t 'CLE'}}) ;
//   - Langue             : "fr" ou "en" ;
//   - Utilisateur        : l'utilisateur connecté, pour la barre de navigation ;
//   - MessageFlash       : le message flash de la requête ;
//   - Chemin             : l'URL demandée (menu actif de la barre de navigation).
// Dans NestJS, ces informations passaient par res.locals.
// =========================================================================

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

namespace RdvMedecins.Web;

public abstract class PageVue<TModel> : RazorPage<TModel>
{
    // [RazorInject] : la propriété est remplie par l'injection de dépendances,
    // comme le ferait la directive @inject dans une vue
    [RazorInject]
    public Traducteur Traducteur { get; set; } = null!;

    // -----------------------------------------------------------------------
    // @T("BOOKING_DIALOG.SLOT", ("horaire", "8:00 - 8:20"))
    // Les paramètres sont des "tuples" (nom, valeur) : une syntaxe compacte
    // pour écrire des couples de valeurs.
    // Le texte renvoyé est ensuite ÉCHAPPÉ par Razor (< devient &lt;) : un nom
    // saisi par un utilisateur ne peut donc pas injecter de HTML dans la page.
    // -----------------------------------------------------------------------
    public string T(string? cle, params (string Nom, object? Valeur)[] parametres) =>
        Traducteur.Traduire(cle, parametres.ToDictionary(p => p.Nom, p => p.Valeur?.ToString() ?? ""));

    // la même, avec les paramètres d'un message flash ou d'une MetierException
    public string T(string? cle, IReadOnlyDictionary<string, string>? parametres) => Traducteur.Traduire(cle, parametres);

    public string Langue => Traducteur.LangueCourante();

    public UtilisateurVue? Utilisateur => UtilisateurVue.De(Context.User.Utilisateur());

    public MessageFlash? MessageFlash => Flash.Lire(Context);

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

    // "active" si la page courante est sous ce chemin (menu de la barre de navigation)
    public string Actif(string prefixe) => Chemin.StartsWith(prefixe, StringComparison.Ordinal) ? "active" : "";
}

// -------------------------------------------------------------------------
// l'utilisateur connecté, tel que les vues le voient
// -------------------------------------------------------------------------
public record UtilisateurVue(string Login, string Nom, string Role, bool EstAdmin, bool EstPatient)
{
    public static UtilisateurVue? De(UtilisateurConnecte? u) =>
        u is null
            ? null
            : new UtilisateurVue(
                u.Login,
                u.Nom,
                u.Role.ToString(),
                EstAdmin: u.Role == Entities.Role.ADMIN, // ADMIN : menus Médecins / Clients
                EstPatient: u.Role == Entities.Role.USER); // USER : menu Mon espace
}

Commentons ce code :

  • ligne 25 : la classe mère de toutes les vues, désignée par la directive [@inherits] de [Views/_ViewImports.cshtml], comme à l'exemple 22. Elle joue le rôle de [res.locals] dans la version [NestJS] : elle met à la disposition de chaque vue ce dont toutes les pages ont besoin ;
  • lignes 29-30 : le service de traduction, injecté dans la propriété (cf. exemple 22) ;
  • lignes 39-40 : la méthode [T] de l'exemple 22 : [@T("CLE", ("nom", valeur))]. Le texte rendu est échappé par [Razor] : un nom saisi par un utilisateur ne peut pas injecter de HTML dans la page ;
  • ligne 43 : une seconde version de [T], qui reçoit directement un dictionnaire : c'est la forme sous laquelle arrivent les paramètres d'un message flash ou d'une [MetierException]. Deux méthodes de même nom, qui diffèrent par leurs paramètres, sont des surcharges : le compilateur choisit la bonne selon les arguments ;
  • ligne 45 : la langue courante ([fr] ou [en]), pour l'attribut [lang] de la page et les boutons FR / EN ;
  • ligne 47 : l'utilisateur connecté, pour la barre de navigation, ou [null] s'il n'y en a pas. [Context.User] est l'utilisateur de la requête, reconstruit à partir du jeton JWT par le middleware d'authentification (cf. chapitre 8) ; la méthode [Utilisateur()] (fichier [Auth/UtilisateurConnecte.cs]) en extrait le compte. Dans la version [NestJS], il fallait un intercepteur pour recopier [req.user] dans [res.locals] après les gardes ; ici, la propriété est calculée au moment où la vue la lit, donc bien après l'authentification ;
  • ligne 49 : le message flash de la requête (cf. [Web/Flash.cs], plus bas) ;
  • lignes 51-54 : le chemin de l'URL demandée, et la méthode qui rend [active] si la page courante est sous un chemin donné : c'est ainsi que la barre de navigation met en valeur le menu courant, par exemple [@Actif("/mon-")] (cf. exemple 16). [StringComparison.Ordinal] compare les caractères un à un, sans tenir compte de la langue ;
  • lignes 60-70 : l'utilisateur connecté tel que les vues le voient : login, nom, rôle, et deux booléens [EstAdmin] et [EstPatient] qui évitent aux vues de comparer des rôles. La méthode statique [De] fabrique cet objet à partir du compte connecté, ou rend [null] ; l'opérateur [? :] choisit entre les deux. Les arguments [EstAdmin:] et [EstPatient:] sont nommés : on écrit le nom du paramètre devant la valeur, ce qui rend l'appel plus lisible.

11.14.3. Web/Flash.cs

// =========================================================================
// Web/Flash.cs
// -------------------------------------------------------------------------
// Les messages "flash" : un message à afficher UNE fois, sur la page
// SUIVANTE. C'est le complément naturel du schéma "Post / Redirect / Get"
// utilisé par tous les formulaires de l'application : un POST réussi ne
// fabrique pas de page, il redirige (302) vers une page GET - si
// l'utilisateur recharge ensuite cette page (F5), le navigateur ne renvoie
// pas le formulaire une seconde fois. Le message, lui, doit survivre à la
// redirection.
//
// Dans NestJS, on l'avait programmé nous-mêmes (un cookie de courte durée).
// ASP.NET Core MVC l'offre en standard : c'est TempData. Une valeur rangée
// dans TempData est conservée (par défaut dans un cookie chiffré) jusqu'à ce
// qu'une requête suivante la LISE ; elle est alors effacée.
// =========================================================================

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

namespace RdvMedecins.Web;

public enum TypeFlash
{
    Success, // classe Bootstrap alert-success : en haut de la page
    Danger, // une erreur : en bas de la page (cf. Views/Shared/_Erreurs.cshtml)
}

// cle : clé de traduction ; parametres : ses paramètres ({nom}, {login}...)
public record MessageFlash(TypeFlash Type, string Cle, Dictionary<string, string>? Parametres = null);

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

    // -----------------------------------------------------------------------
    // dépose un message flash, qui sera affiché par la PROCHAINE page.
    // On passe par ITempDataDictionaryFactory (et non par la propriété TempData
    // d'un contrôleur) pour pouvoir déposer un message depuis N'IMPORTE OÙ :
    // un contrôleur, mais aussi l'authentification JWT (session expirée, cf.
    // Auth/AuthentificationJwt.cs), qui s'exécute en dehors de MVC.
    // -----------------------------------------------------------------------
    public static void Deposer(HttpContext http, TypeFlash type, string cle, Dictionary<string, string>? parametres = null)
    {
        var tempData = TempDataDe(http);
        // TempData ne sait conserver que des valeurs simples : on range le
        // message sous forme de texte JSON
        tempData[Cle] = JsonSerializer.Serialize(new MessageFlash(type, cle, parametres));
        // Save() : écrit le cookie tout de suite (en dehors de MVC, personne
        // ne le ferait à notre place)
        tempData.Save();
    }

    // -----------------------------------------------------------------------
    // lit (et donc efface) le message flash de la requête. La page peut le
    // demander plusieurs fois (le message de succès est affiché en haut, celui
    // d'erreur en bas) : on le garde dans HttpContext.Items, un dictionnaire
    // qui vit le temps de la requête.
    // -----------------------------------------------------------------------
    public static MessageFlash? Lire(HttpContext http)
    {
        if (http.Items.TryGetValue(Cle, out var dejaLu))
        {
            return dejaLu as MessageFlash;
        }
        MessageFlash? message = null;
        if (TempDataDe(http)[Cle] is string json)
        {
            try
            {
                message = JsonSerializer.Deserialize<MessageFlash>(json);
            }
            catch (JsonException)
            {
                // message illisible : on l'ignore
            }
        }
        http.Items[Cle] = message;
        return message;
    }

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

Commentons ce code :

  • lignes 23-26 : les deux types de messages : un succès, affiché en haut de la page (vue partielle [_Flash.cshtml]), une erreur, affichée en bas (vue partielle [_Erreurs.cshtml]) ;
  • ligne 30 : un message flash est une clé de traduction et ses paramètres, et non un texte déjà traduit : il sera traduit par la page suivante, dans la langue de celle-ci ;
  • ligne 32 : une classe statique, qui ne contient que des fonctions (cf. [Web/Outils.cs]). [TempData] (cf. exemple 20) fait l'essentiel du travail : c'est lui qui conserve la valeur dans un cookie chiffré jusqu'à la requête suivante, et l'efface une fois lue. Dans la version [NestJS], ce cookie de courte durée avait été programmé à la main ;
  • lignes 43-51 : dépose un message. [TempData] ne sait conserver que des valeurs simples (textes, nombres) : le message est rangé sous forme de texte JSON ([JsonSerializer.Serialize]). La méthode reçoit le [HttpContext] et non la propriété [TempData] d'un contrôleur : elle peut ainsi être appelée hors de MVC, par exemple par l'authentification JWT, quand une session a expiré (cf. [Auth/AuthentificationJwt.cs]). Hors de MVC, personne n'enregistrerait [TempData] à la fin de la requête : [Save()] écrit le cookie tout de suite ;
  • lignes 60-79 : lit le message de la requête. Lire [TempData] le marque comme consommé ; or la page le demande deux fois (en haut pour un succès, en bas pour une erreur). Le premier appel range donc le résultat dans [HttpContext.Items], le dictionnaire de portée requête (cf. chapitre 6), où le second le retrouve (ligne 62) ;
  • lignes 71-73 : [Deserialize] reconstruit l'objet [MessageFlash] à partir du JSON. Un texte illisible ne doit pas faire échouer la page : l'exception est interceptée et le message ignoré ;
  • lignes 82-83 : le [TempData] de la requête, obtenu auprès du service [ITempDataDictionaryFactory]. [RequestServices] donne accès à l'injection de dépendances depuis une classe statique, qui ne peut pas recevoir de services par son constructeur.

11.14.4. Web/HtmlExceptionFilter.cs

// =========================================================================
// Web/HtmlExceptionFilter.cs
// -------------------------------------------------------------------------
// Comment les erreurs deviennent des pages HTML. Trois mécanismes
// d'ASP.NET Core se partagent le travail (cf. Program.cs) :
//
//   1. ce filtre d'exceptions MVC : une SessionInvalideException (compte
//      supprimé depuis la connexion) -> retour à la page de connexion ;
//   2. UseStatusCodePagesWithReExecute("/erreur/{0}") : toute réponse 400,
//      403 ou 404 SANS contenu (return BadRequest(), rôle insuffisant, page
//      inconnue...) est remplacée par la page d'erreur correspondante ;
//   3. UseExceptionHandler("/erreur/500") : toute autre exception (une vraie
//      anomalie) est inscrite dans le journal du serveur, et l'utilisateur
//      voit la page d'erreur 500 - jamais la pile d'appels.
//
// Dans NestJS, un seul filtre (HtmlExceptionFilter) faisait tout cela.
// =========================================================================

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

namespace RdvMedecins.Web;

public class HtmlExceptionFilter : IExceptionFilter
{
    public void OnException(ExceptionContext context)
    {
        if (context.Exception is SessionInvalideException)
        {
            AuthService.EffacerJeton(context.HttpContext.Response);
            Flash.Deposer(context.HttpContext, TypeFlash.Danger, "ERRORS.SESSION_EXPIRED");
            context.Result = new RedirectResult("/login");
            context.ExceptionHandled = true;
        }
        // les autres exceptions ne sont pas traitées ici : elles remontent
        // jusqu'à UseExceptionHandler (page 500)
    }
}

Commentons ce code :

  • ligne 25 : un filtre d'exceptions, comme à l'exemple 24, ajouté comme filtre global dans [Program.cs]. Dans la version [NestJS], un seul filtre transformait toutes les erreurs en pages HTML. Ici, le travail est partagé avec deux middlewares du framework, configurés dans [Program.cs] : [UseStatusCodePagesWithReExecute] remplace les réponses 400, 403 et 404 sans contenu par la page d'erreur correspondante (cf. exemple 24), et [UseExceptionHandler("/erreur/500")] intercepte toute autre exception : l'anomalie est inscrite dans le journal du serveur, avec sa pile d'appels, mais l'utilisateur ne voit que la page d'erreur 500, car ces détails aideraient un attaquant ;
  • lignes 29-34 : le filtre ne traite qu'un cas : le compte de l'utilisateur connecté a été supprimé depuis sa connexion, alors que son jeton est encore valide (cf. [Web/Outils.cs], plus bas). On efface le cookie du jeton, on dépose un message flash « session expirée », et on redirige vers la page de connexion. Les autres exceptions ne sont pas marquées comme traitées : elles poursuivent leur chemin jusqu'à [UseExceptionHandler].

11.14.5. Web/ApplicationModel.cs

// =========================================================================
// Web/ApplicationModel.cs
// -------------------------------------------------------------------------
// Le cache / façade déjà présent dans le serveur JSON (équivalent de la
// classe [ApplicationModel] du projet Spring) : les listes de médecins et
// de clients sont lues une fois au démarrage, puis servies depuis la mémoire.
//
// Ces listes peuvent CHANGER (l'ADMIN ajoute, modifie, supprime des
// médecins et des clients) : chaque écriture passe donc par cette façade,
// qui recharge la liste concernée juste après. C'est tout l'intérêt d'un
// point d'entrée UNIQUE vers la couche métier : la règle "écriture =>
// rechargement du cache" n'est écrite qu'ici.
//
// Différence importante avec NestJS : la DURÉE DE VIE des objets.
//   - Dans NestJS, tous les services étaient des singletons.
//   - Ici, le DbContext - donc les repositories et la couche métier - vit le
//     temps d'UNE requête HTTP (durée de vie "scoped", cf. Program.cs).
//   - Or le cache, lui, doit survivre d'une requête à l'autre.
// On sépare donc les deux rôles :
//   - CacheListes (singleton) : les deux listes, et rien d'autre ;
//   - ApplicationModel (scoped) : la façade, qui utilise la couche métier de
//     la requête en cours et met à jour le cache.
// =========================================================================

using RdvMedecins.Domain;
using RdvMedecins.Entities;
using RdvMedecins.Metier;

namespace RdvMedecins.Web;

// -------------------------------------------------------------------------
// Le cache lui-même, partagé par toutes les requêtes (singleton).
//
// ASP.NET Core traite les requêtes EN PARALLÈLE, sur plusieurs threads (Node,
// lui, n'en a qu'un). On ne modifie donc jamais une liste en place : on la
// REMPLACE en entier par une nouvelle. L'échange d'une référence est atomique ;
// "volatile" garantit que tous les threads voient aussitôt la nouvelle liste.
// -------------------------------------------------------------------------
public class CacheListes
{
    private volatile IReadOnlyList<Medecin> medecins = [];
    private volatile IReadOnlyList<Client> clients = [];

    public IReadOnlyList<Medecin> Medecins
    {
        get => medecins;
        set => medecins = value;
    }

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

// -------------------------------------------------------------------------
// La façade (scoped) : même contrat que la couche métier (IMetier).
// -------------------------------------------------------------------------
public class ApplicationModel(IMetier metier, CacheListes cache) : IMetier
{
    // chargement initial des deux listes, appelé une fois au démarrage
    // (cf. Program.cs). Si la base est inaccessible, l'exception arrête le
    // serveur avec un message explicite - plus clair que de démarrer "à vide".
    public async Task ChargerAsync()
    {
        await RechargerMedecinsAsync();
        await RechargerClientsAsync();
    }

    private async Task RechargerMedecinsAsync() => cache.Medecins = await metier.GetAllMedecinsAsync();

    private async Task RechargerClientsAsync() => cache.Clients = await metier.GetAllClientsAsync();

    // ------------------------------------------------ lectures depuis le cache

    public Task<List<Medecin>> GetAllMedecinsAsync() => Task.FromResult(cache.Medecins.ToList());

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

    // le médecin dont le compte utilisateur est [idUser] - sert à présélectionner,
    // pour un médecin connecté, son propre agenda
    public Medecin? GetMedecinByUserId(int idUser) => cache.Medecins.FirstOrDefault(m => m.IdUser == idUser);

    // ------------------------------------------------ simples délégations

    public Task<List<Creneau>> GetAllCreneauxAsync(int idMedecin) => metier.GetAllCreneauxAsync(idMedecin);

    public Task<List<Rv>> GetRvMedecinJourAsync(int idMedecin, DateOnly jour) => metier.GetRvMedecinJourAsync(idMedecin, jour);

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

    public Task<Medecin?> GetMedecinByIdAsync(int id) => metier.GetMedecinByIdAsync(id);

    public Task<Rv?> GetRvByIdAsync(int id) => metier.GetRvByIdAsync(id);

    public Task<Creneau?> GetCreneauByIdAsync(int id) => metier.GetCreneauByIdAsync(id);

    public Task<AgendaMedecinJour?> GetAgendaMedecinJourAsync(int idMedecin, DateOnly jour) =>
        metier.GetAgendaMedecinJourAsync(idMedecin, jour);

    // pas de cache ici : c'est la donnée la plus récente qui compte (un patient
    // qui vient de modifier ou de supprimer son compte)
    public Task<Client?> GetClientByUserIdAsync(int idUser) => metier.GetClientByUserIdAsync(idUser);

    public Task<List<Rv>> GetRvAVenirClientAsync(int idClient) => metier.GetRvAVenirClientAsync(idClient);

    public Task<Rv> AjouterRvAsync(DateOnly jour, Creneau creneau, Client client) => metier.AjouterRvAsync(jour, creneau, client);

    public Task SupprimerRvAsync(Rv rv) => metier.SupprimerRvAsync(rv);

    // ------------------------------------------------ écritures => rechargement

    public async Task<Medecin> AjouterMedecinAsync(DonneesPersonne donnees)
    {
        var medecin = await metier.AjouterMedecinAsync(donnees);
        await RechargerMedecinsAsync();
        return medecin;
    }

    public async Task<Medecin> ModifierMedecinAsync(int id, DonneesPersonne donnees)
    {
        var medecin = await metier.ModifierMedecinAsync(id, donnees);
        await RechargerMedecinsAsync();
        return medecin;
    }

    public async Task SupprimerMedecinAsync(int id)
    {
        await metier.SupprimerMedecinAsync(id);
        await RechargerMedecinsAsync();
    }

    public async Task<Client> AjouterClientAsync(DonneesPersonne donnees)
    {
        var client = await metier.AjouterClientAsync(donnees);
        await RechargerClientsAsync();
        return client;
    }

    public async Task<Client> ModifierClientAsync(int id, DonneesPersonne donnees)
    {
        var client = await metier.ModifierClientAsync(id, donnees);
        await RechargerClientsAsync();
        return client;
    }

    public async Task SupprimerClientAsync(int id)
    {
        await metier.SupprimerClientAsync(id);
        await RechargerClientsAsync();
    }
}

Les listes des médecins et des clients sont affichées sur presque toutes les pages (listes déroulantes de l'agenda, pages d'administration). Plutôt que de les relire dans la base à chaque requête, on les garde en mémoire : c'est une donnée de portée application (cf. chapitre 6). La version [NestJS] confiait ce rôle à un seul service, singleton comme tous les services [NestJS]. En [ASP.NET Core], c'est impossible : la couche métier utilise le [DbContext], qui ne vit que le temps d'une requête (service [Scoped], cf. chapitre 9), et un singleton ne peut pas recevoir un service [Scoped]. On sépare donc les deux rôles en deux classes.

Commentons ce code :

  • ligne 39 : le cache lui-même, enregistré comme singleton ([AddSingleton<CacheListes>()] dans [Program.cs]). Il ne contient que les deux listes ;
  • lignes 41-42 : [ASP.NET Core] traite les requêtes en parallèle, sur plusieurs fils d'exécution ([threads]), alors que [Node.js] n'en a qu'un. Une liste partagée ne doit donc jamais être modifiée pendant qu'une autre requête la parcourt. La règle choisie est simple : on ne modifie jamais une liste, on la remplace par une nouvelle. Le remplacement d'une référence se fait en une seule opération, et le mot-clé [volatile] garantit que tous les fils d'exécution voient aussitôt la nouvelle liste. [IReadOnlyList] ne propose que la lecture : le compilateur interdit d'ajouter ou de retirer un élément ;
  • ligne 60 : la façade, enregistrée comme service [Scoped] ([AddScoped<ApplicationModel>()]). Elle reçoit la couche métier de la requête en cours et le cache partagé. Elle implémente la même interface [IMetier] que la couche métier : pour les contrôleurs, elle est la couche métier, et c'est elle seule qu'ils reçoivent ;
  • lignes 65-68 : le chargement initial des deux listes. Il est appelé une fois, par [Program.cs], avant le démarrage du serveur. Un service [Scoped] n'existe que dans une portée : [Program.cs] en crée une pour l'occasion ([CreateScope]). Si la base est inaccessible, l'exception arrête l'application avec un message explicite, ce qui vaut mieux qu'un serveur démarré avec des listes vides ;
  • lignes 71-73 : le rechargement d'une liste : on lit la liste complète dans la base, et on remplace celle du cache ;
  • lignes 77-79 : les listes sont servies depuis la mémoire. L'interface [IMetier] annonce une méthode asynchrone : [Task.FromResult] fabrique une tâche déjà terminée, qui porte le résultat. [ToList()] rend une copie de la liste : l'appelant peut la modifier sans toucher au cache ;
  • ligne 83 : le médecin d'un compte, cherché dans le cache : un médecin connecté voit d'emblée son propre agenda ;
  • lignes 87-110 : les autres demandes sont relayées telles quelles à la couche métier ;
  • ligne 104 : le client d'un compte n'est volontairement pas cherché dans le cache : un patient qui vient de modifier ou de supprimer son compte doit être vu tel qu'il est dans la base ;
  • lignes 114-151 : toute écriture sur les médecins ou les clients est suivie du rechargement de la liste concernée. Puisque ce service est le seul point d'entrée des contrôleurs vers la couche métier, la règle « écriture = rechargement du cache » n'est écrite qu'ici, et le cache ne peut pas devenir périmé. Si la couche métier refuse l'opération ([MetierException]), l'exception interrompt la méthode avant le rechargement, devenu inutile.

11.14.6. Web/Validation.cs

// =========================================================================
// Web/Validation.cs
// -------------------------------------------------------------------------
// Ce qu'il faut pour RÉAFFICHER un formulaire refusé : les erreurs de
// saisie champ par champ, et les champs à encadrer en rouge.
// =========================================================================

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

namespace RdvMedecins.Web;

public static class Validation
{
    // -----------------------------------------------------------------------
    // ModelState (rempli par MVC lors de la liaison du formulaire) -> le
    // dictionnaire champ -> clé du message, en ne gardant que le premier
    // message de chaque champ : { "Nom": "ERRORS.LASTNAME_REQUIRED", ... }
    // -----------------------------------------------------------------------
    public static Dictionary<string, string> ErreursDe(ModelStateDictionary modelState)
    {
        var erreurs = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        foreach (var (champ, entree) in modelState)
        {
            if (entree.Errors.Count > 0)
            {
                var message = entree.Errors[0].ErrorMessage;
                erreurs[champ] = string.IsNullOrEmpty(message) ? "ERRORS.INVALID_FORM" : message;
            }
        }
        return erreurs;
    }

    // -----------------------------------------------------------------------
    // les champs du formulaire concernés par chaque erreur métier "de saisie"
    // (un doublon) : ils sont encadrés en rouge, le message étant en bas de page
    // -----------------------------------------------------------------------
    public static readonly Dictionary<string, string[]> ChampsErreursMetier = new()
    {
        ["ERRORS.LOGIN_TAKEN"] = ["login"],
        ["ERRORS.DOCTOR_ALREADY_EXISTS"] = ["prenom", "nom"],
        ["ERRORS.PATIENT_ALREADY_EXISTS"] = ["prenom", "nom"],
    };

    // -----------------------------------------------------------------------
    // prépare un modèle de formulaire pour son (ré)affichage :
    //   erreurs      : message sous chaque champ refusé par la validation
    //   erreur       : LE message d'erreur de la page, affiché en bas de page ;
    //                  par défaut "Le formulaire contient des erreurs." s'il y
    //                  a des erreurs de champ
    //   autresChamps : champs à encadrer en rouge SANS message propre
    // -----------------------------------------------------------------------
    public static T Preparer<T>(
        T modele,
        Dictionary<string, string>? erreurs = null,
        string? erreur = null,
        IEnumerable<string>? autresChamps = null,
        IReadOnlyDictionary<string, string>? erreurParams = null) where T : FormulaireVue
    {
        modele.Erreurs = erreurs ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        modele.ChampsInvalides = new HashSet<string>(modele.Erreurs.Keys.Concat(autresChamps ?? []), StringComparer.OrdinalIgnoreCase);
        modele.Erreur = erreur ?? (modele.Erreurs.Count > 0 ? "ERRORS.INVALID_FORM" : null);
        modele.ErreurParams = erreurParams;
        return modele;
    }
}

Commentons ce code :

  • lignes 20-31 : transforme [ModelState] (cf. exemples 07 et 19) en un dictionnaire simple « champ → clé du message ». Seul le premier message de chaque champ est gardé : un seul message s'affiche sous le champ. Le dictionnaire ignore les majuscules (ligne 22) : [ModelState] nomme les champs d'après les propriétés ([Nom], [Login]), tandis que les vues les demandent d'après les champs HTML ([nom], [login]) ;
  • lignes 27-28 : par précaution, une erreur sans message (une erreur produite par une exception, par exemple) reçoit le message générique [ERRORS.INVALID_FORM] ;
  • lignes 38-42 : certaines erreurs de la couche métier concernent des champs précis : un login déjà pris, un médecin ou un client homonyme. Ces champs seront encadrés en rouge, le message étant affiché en bas de la page. La syntaxe [["clé"] = valeur] remplit le dictionnaire dès sa création ;
  • lignes 53-64 : prépare un modèle de formulaire pour son affichage ou son réaffichage : les messages sous les champs ([Erreurs]), les champs à encadrer en rouge ([ChampsInvalides] : ceux qui ont un message, plus les [autresChamps], ligne 61), le message du bas de la page et ses paramètres. Faute de message explicite, des erreurs de champ donnent le message « Le formulaire contient des erreurs » (ligne 62). La méthode est générique : la contrainte [where T : FormulaireVue] (ligne 58) accepte tout modèle dérivé de [FormulaireVue] (cf. [Models/ModelesVues.cs]), et elle rend le modèle avec son type exact, prêt à être passé à [View]. Les paramètres ont des valeurs par défaut ([= null]) : l'appelant ne donne que ceux dont il a besoin.

11.14.7. Web/Outils.cs

// =========================================================================
// Web/Outils.cs
// -------------------------------------------------------------------------
// Petites fonctions utilitaires de la couche web :
//   - Libelles : "Mme Alice DURAND" ;
//   - DatesWeb : dates "yyyy-MM-dd", heures "8:05", jours "mercredi 23 septembre 2026" ;
//   - Saisie   : lecture d'un entier positif envoyé par le navigateur ;
//   - Patient  : le patient (entité Client) de l'utilisateur connecté.
//
// Des classes "static" : elles ne contiennent que des fonctions, on ne crée
// jamais d'objet de ces classes.
// =========================================================================

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

namespace RdvMedecins.Web;

public static class Libelles
{
    // "Mme Alice DURAND"
    public static string Libelle(Personne p) => $"{p.Titre} {p.Prenom} {p.Nom}";
}

public static class DatesWeb
{
    // le format des dates dans les URL et dans le champ <input type="date">
    public const string FormatIso = "yyyy-MM-dd";

    // vrai si [jour] est une date qui existe au format yyyy-MM-dd
    // ("2026-02-30" est refusé)
    public static bool EstUneDateValide(string? texte, out DateOnly jour) =>
        DateOnly.TryParseExact(texte, FormatIso, CultureInfo.InvariantCulture, DateTimeStyles.None, out jour);

    public static string Iso(DateOnly jour) => jour.ToString(FormatIso, CultureInfo.InvariantCulture);

    // "8:05" plutôt que "8:5"
    public static string FormaterHeure(int h, int m) => $"{h}:{m:00}";

    public static string Horaire(Creneau c) => $"{FormaterHeure(c.Hdebut, c.Mdebut)} - {FormaterHeure(c.Hfin, c.Mfin)}";

    // "mercredi 23 septembre 2026" / "Wednesday 23 September 2026", selon la
    // langue de la requête (CultureInfo.CurrentCulture, fixée par le
    // middleware de localisation - cf. Program.cs)
    public static string FormaterJour(DateOnly jour) =>
        jour.ToString("dddd d MMMM yyyy", CultureInfo.CurrentCulture);
}

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

public static class Patient
{
    // -----------------------------------------------------------------------
    // Le patient (entité Client) qui correspond à l'utilisateur connecté.
    //
    // Le jeton JWT ne contient que l'id du COMPTE (users.id). Pour un rôle
    // USER, on retrouve le client correspondant grâce à la colonne clients.user.
    //
    // S'il n'y en a pas, c'est que le compte a été supprimé depuis la
    // connexion alors que son jeton est encore valide : on le traite comme une
    // session expirée (cf. SessionInvalideException).
    // -----------------------------------------------------------------------
    public static async Task<Client> PatientConnecteAsync(ApplicationModel application, ClaimsPrincipal user)
    {
        var utilisateur = user.Utilisateur() ?? throw new SessionInvalideException();
        return await application.GetClientByUserIdAsync(utilisateur.UserId) ?? throw new SessionInvalideException();
    }
}

Commentons ce code :

  • ligne 24 : le libellé d'un médecin ou d'un client : « Mme Alice DURAND ». Le paramètre est de type [Personne], la classe mère des entités [Medecin] et [Client] : la même fonction sert aux deux ;
  • ligne 30 : le format des dates dans les URL et dans le champ [<input type="date">] ;
  • lignes 34-35 : vrai si le texte est une date au format [yyyy-MM-dd] qui existe : [2026-02-30] est refusé (cf. exemple 12). Le résultat est rendu dans le paramètre [out jour]. [CultureInfo.InvariantCulture] rend l'analyse indépendante de la langue de la requête ;
  • ligne 37 : l'opération inverse : une date vers le texte [yyyy-MM-dd] ;
  • lignes 40-42 : une heure au format [8:05] (le format [00] impose deux chiffres pour les minutes), et l'horaire d'un créneau : [8:00 - 8:20] ;
  • lignes 47-48 : un jour en toutes lettres, selon la culture de la requête, fixée par le middleware de localisation (cf. exemple 22) : « mercredi 23 septembre 2026 » ou « Wednesday 23 September 2026 » ;
  • lignes 54-55 : un identifiant envoyé par le navigateur (dans l'URL ou un champ de formulaire) : un entier strictement positif, ou [null]. [NumberStyles.None] n'accepte que des chiffres : ni signe, ni espaces, ni séparateur de milliers. Le contrôleur n'a plus qu'à tester [null] ;
  • lignes 70-73 : le patient (entité [Client]) de l'utilisateur connecté. Le jeton JWT ne contient que l'identifiant du compte ; on cherche le client rattaché à ce compte. S'il n'existe plus, c'est que le compte a été supprimé depuis la connexion : la [SessionInvalideException] sera traitée par le filtre [HtmlExceptionFilter] vu plus haut, qui renvoie à la page de connexion. [?? throw] lève l'exception quand la valeur de gauche est [null] (cf. chapitre 9).

11.15. Les modèles des formulaires et des vues

Deux sortes de classes transportent les données entre le navigateur, les contrôleurs et les vues : les formulaires, remplis par la liaison de modèle à partir de ce qu'envoie le navigateur, et les modèles de vue, préparés par les contrôleurs pour les vues. Les premiers correspondent aux DTO de la version [NestJS], les seconds aux objets passés à [res.render].

11.15.1. Models/Formulaires.cs

// =========================================================================
// Models/Formulaires.cs
// -------------------------------------------------------------------------
// Les champs des formulaires "personne" et leurs contraintes. Trois
// formulaires partagent les mêmes champs d'identité (titre, prénom, nom) :
//
//   ChampsPersonne                titre, prénom, nom
//    ├─ FormulairePersonne        + login / mot de passe FACULTATIFS, version
//    │                              (l'ADMIN ajoute ou modifie un médecin / client)
//    ├─ FormulaireInscription     + login / mot de passe / confirmation OBLIGATOIRES
//    │                              (un patient crée son compte)
//    └─ FormulaireMonCompte       + login, nouveau mot de passe facultatif,
//                                   mot de passe ACTUEL obligatoire, version
//                                   (un patient modifie son compte)
//
// LIAISON DE MODÈLE (model binding) : quand une action de contrôleur reçoit
// un paramètre de l'une de ces classes, ASP.NET Core MVC crée l'objet et
// remplit chaque propriété avec le champ de formulaire de même nom (sans
// tenir compte des majuscules : le champ "nom" remplit la propriété Nom).
//
// VALIDATION : juste après, MVC vérifie les attributs [Required],
// [MaxLength]... (espace de noms standard System.ComponentModel.DataAnnotations,
// l'équivalent de class-validator) et range les erreurs dans ModelState.
// Le contrôleur n'a plus qu'à tester ModelState.IsValid.
//
// Les messages d'erreur sont des CLÉS de traduction (cf. i18n/*.json).
//
// NETTOYAGE DE LA SAISIE (l'équivalent des @Transform de class-transformer) :
//   - MVC transforme déjà un champ vide en null ;
//   - les setters ci-dessous retirent les espaces superflus et mettent le
//     login en minuscules. Un mot de passe, lui, n'est pas "nettoyé" (les
//     espaces peuvent en faire partie).
// Toutes les propriétés sont "nullables" (string?) : un champ peut toujours
// manquer dans ce qu'envoie le navigateur ; c'est [Required] qui l'exige.
// =========================================================================

using System.ComponentModel.DataAnnotations;

namespace RdvMedecins.Models;

public static class ReglesSaisie
{
    public static readonly string[] Titres = ["Mr", "Mme", "Melle"];

    // login : lettres minuscules, chiffres, point, tiret, souligné - 30 caractères au plus
    public const string FormatLogin = "^[a-z0-9._-]{1,30}$";

    // longueur minimale d'un mot de passe CHOISI par un patient
    public const int MotDePasseMin = 6;

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

// -------------------------------------------------------------------------
// Un attribut de validation "maison" : la valeur doit figurer dans une liste.
// Il suffit d'hériter de ValidationAttribute et d'écrire IsValid.
// -------------------------------------------------------------------------
public class ParmiAttribute(params string[] valeurs) : ValidationAttribute
{
    // null est accepté ici : c'est le rôle de [Required] de l'exiger
    public override bool IsValid(object? value) => value is null || valeurs.Contains(value as string);
}

// ------------------------------------------------------------------ base commune
public class ChampsPersonne
{
    [Required(ErrorMessage = "ERRORS.INVALID_TITLE")]
    [Parmi("Mr", "Mme", "Melle", ErrorMessage = "ERRORS.INVALID_TITLE")]
    public string? Titre { get; set; }

    // longueurs maximales = celles des colonnes SQL (VARCHAR(20))
    private string? prenom;

    [Required(ErrorMessage = "ERRORS.FIRSTNAME_REQUIRED")]
    [MaxLength(20, ErrorMessage = "ERRORS.FIRSTNAME_TOO_LONG")]
    public string? Prenom
    {
        get => prenom;
        set => prenom = ReglesSaisie.Nettoyer(value);
    }

    private string? nom;

    [Required(ErrorMessage = "ERRORS.LASTNAME_REQUIRED")]
    [MaxLength(20, ErrorMessage = "ERRORS.LASTNAME_TOO_LONG")]
    public string? Nom
    {
        get => nom;
        set => nom = ReglesSaisie.Nettoyer(value);
    }
}

// ------------------------------------------------------------------ ADMIN
public class FormulairePersonne : ChampsPersonne
{
    private string? login;

    // facultatif (cf. DonneesPersonne, Metier/IMetier.cs)
    [RegularExpression(ReglesSaisie.FormatLogin, ErrorMessage = "ERRORS.INVALID_LOGIN")]
    public string? Login
    {
        get => login;
        set => login = ReglesSaisie.Nettoyer(value)?.ToLowerInvariant();
    }

    // facultatif ; 72 caractères au plus (au-delà, bcrypt ignore la suite)
    [MaxLength(72, ErrorMessage = "ERRORS.PASSWORD_TOO_LONG")]
    public string? Password { get; set; }

    // champ caché du formulaire de modification (verrou optimiste).
    // int? : un champ absent ou vide donne null ; un texte qui n'est pas un
    // nombre donne une erreur de liaison (cf. Program.cs, ModelBindingMessageProvider)
    public int? Version { get; set; }
}

// ------------------------------------------------------------------ inscription d'un patient
public class FormulaireInscription : ChampsPersonne
{
    private string? login;

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

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

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

// ------------------------------------------------------------------ "Gérer mon compte"
public class FormulaireMonCompte : ChampsPersonne
{
    private string? login;

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

    // facultatif : vide = mot de passe inchangé
    [MinLength(ReglesSaisie.MotDePasseMin, ErrorMessage = "ERRORS.PASSWORD_TOO_SHORT")]
    [MaxLength(72, ErrorMessage = "ERRORS.PASSWORD_TOO_LONG")]
    public string? Password { get; set; }

    public string? Confirmation { get; set; }

    // le mot de passe ACTUEL, exigé pour toute modification du compte
    [Required(ErrorMessage = "ERRORS.CURRENT_PASSWORD_REQUIRED")]
    public string? MotDePasseActuel { get; set; }

    public int? Version { get; set; }
}

Commentons ce code :

  • ligne 43 : les titres possibles ;
  • ligne 46 : le format d'un login, décrit par une expression régulière : de 1 à 30 caractères parmi les lettres minuscules, les chiffres, le point, le tiret et le souligné ;
  • ligne 49 : la longueur minimale d'un mot de passe choisi par un patient. C'est une constante ([const]) : elle peut donc servir d'argument à un attribut, comme [[MinLength(ReglesSaisie.MotDePasseMin)]] plus bas ;
  • ligne 52 : le nettoyage d'une saisie : les espaces de début et de fin sont retirés, et un champ fait seulement d'espaces devient [null], c'est-à-dire absent ;
  • lignes 59-62 : un attribut de validation écrit par nos soins : la valeur doit figurer dans une liste. Il suffit de dériver de [ValidationAttribute] et de redéfinir [IsValid]. [null] est accepté : c'est le rôle de [[Required]] de refuser un champ absent. On l'utilise sous le nom [[Parmi]] : par convention, le suffixe [Attribute] peut être omis ;
  • ligne 66 : les champs d'identité, communs aux trois formulaires. Toutes les propriétés sont de type [string?] : un champ peut toujours manquer dans ce qu'envoie le navigateur, et c'est [[Required]] qui l'exige. Les messages d'erreur sont des clés de traduction (cf. exemple 22) ;
  • lignes 68-70 : le titre, obligatoire et choisi dans la liste. Les valeurs sont répétées dans [[Parmi(...)]] : l'argument d'un attribut doit être connu à la compilation, ce qui n'est pas le cas du tableau [ReglesSaisie.Titres] (un champ [static readonly], créé à l'exécution) ;
  • lignes 73-80 et lignes 83-90 : le prénom et le nom, obligatoires, de 20 caractères au plus (la taille des colonnes SQL). Ces propriétés ont un corps : leur valeur est rangée dans un champ privé, et le [set] la nettoie au passage. La liaison de modèle affectant les propriétés avant la validation, c'est la valeur nettoyée qui est validée. C'est l'équivalent des [@Transform] de la version [NestJS] ; [ASP.NET Core MVC] transforme déjà, de lui-même, un champ vide en [null] ;
  • ligne 95 : le formulaire de l'ADMIN, pour ajouter ou modifier un médecin ou un client. La classe hérite des champs d'identité et de leurs attributs de validation ;
  • lignes 97-104 : le login, facultatif ici, mais au bon format s'il est présent (ligne 100) ; il est nettoyé et mis en minuscules. [?.] n'appelle [ToLowerInvariant] que si la valeur n'est pas [null] ;
  • lignes 108-109 : le mot de passe, facultatif lui aussi, de 72 caractères au plus : l'algorithme [bcrypt], qui le chiffre, ignore les caractères suivants. Un mot de passe n'est pas nettoyé : ses espaces en font partie ;
  • ligne 114 : la version de la fiche, dans un champ caché du formulaire de modification (verrou optimiste, cf. exemple 28). Un champ absent ou vide donne [null] ; un texte qui n'est pas un nombre produit une erreur de liaison, dont le message est configuré dans [Program.cs] (cf. exemple 19) ;
  • ligne 118 : la création d'un compte par un patient : login, mot de passe (de 6 à 72 caractères, ligne 131) et confirmation (lignes 135-136) sont obligatoires. L'égalité du mot de passe et de sa confirmation met en jeu deux champs : elle est vérifiée par le contrôleur ([Controllers/InscriptionController.cs]) ;
  • ligne 140 : la modification de son compte par un patient. Le nouveau mot de passe est facultatif (ligne 153) : vide, il reste inchangé ; sa confirmation (ligne 157) n'est donc pas exigée ici. Le mot de passe actuel, en revanche, est obligatoire pour toute modification (lignes 160-161), et la version de la fiche voyage dans un champ caché (ligne 163).

11.15.2. Models/ModelesVues.cs

// =========================================================================
// Models/ModelesVues.cs
// -------------------------------------------------------------------------
// Les "modèles de vue" (view models) : ce que chaque contrôleur transmet à
// sa vue (return View("...", modele)), dans une classe C# TYPÉE. Dans une
// vue Razor, @model AgendaModele déclare le type du modèle : le compilateur
// vérifie alors chaque @Model.Xxx de la vue - une faute de frappe est une
// erreur de compilation, et non une zone vide dans la page.
//
// Règle déjà suivie avec Handlebars : pas de logique dans les vues. Les
// contrôleurs préparent des données DÉJÀ mises en forme ("Mme Marie
// PELISSIER", "8:00 - 8:20", Selectionne = true...) et déjà filtrées selon
// le rôle de l'utilisateur ; les vues se contentent de les placer dans le HTML.
// =========================================================================

namespace RdvMedecins.Models;

// -------------------------------------------------------------------------
// ce que TOUTES les pages ont en commun, lu par le gabarit
// (Views/Shared/_Layout.cshtml)
// -------------------------------------------------------------------------
public abstract class ModeleVue
{
    // clé de traduction du titre de la page (<title>)
    public string TitrePage { get; set; } = "APP.TITLE";

    // LE message d'erreur de la page, affiché en bas de page
    // (cf. Views/Shared/_Erreurs.cshtml) ; null = pas d'erreur
    public string? Erreur { get; set; }

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

// une option d'une liste déroulante (médecin, patient)
public record OptionVue(int Id, string Libelle, bool Selectionne);

// ------------------------------------------------------------------ connexion
public class LoginModele : ModeleVue
{
    public string Login { get; set; } = "";
    public required Auth.Captcha Captcha { get; set; }
}

// ------------------------------------------------------------------ agenda
public class LigneAgendaVue
{
    public int IdCreneau { get; init; }
    public string Horaire { get; init; } = ""; // "8:00 - 8:20"
    public bool Libre { get; init; }
    public string? Patient { get; init; } // nom affiché pour un créneau occupé ; null = « Réservé »
    public bool Moi { get; init; } // le rendez-vous du patient connecté
    public bool Reservable { get; init; } // afficher le bouton "Réserver" ?
    public string UrlReserver { get; init; } = "";
    public int? IdRv { get; init; } // renseigné seulement si le bouton "Annuler" est affiché
}

public record AgendaVue(string Medecin, string Jour, List<LigneAgendaVue> Lignes);

public class ReservationVue
{
    public int IdCreneau { get; init; }
    public string Horaire { get; init; } = "";
    // ADMIN / DOCTOR : la liste des patients parmi lesquels choisir
    public List<OptionVue> Clients { get; init; } = [];
    // USER : le patient, c'est lui-même
    public string? Patient { get; init; }
}

public class AgendaModele : ModeleVue
{
    public List<OptionVue> Medecins { get; set; } = [];
    public int? IdMedecin { get; set; }
    public string Jour { get; set; } = ""; // "yyyy-MM-dd"
    public AgendaVue? Agenda { get; set; }
    public ReservationVue? Reservation { get; set; }
    public string UrlRetour { get; set; } = "/agenda"; // l'agenda affiché, sans fenêtre de réservation
    // patient qui a déjà un rendez-vous à venir : le message qui explique
    // pourquoi il n'y a pas de bouton "Réserver"
    public string? Info { get; set; }
}

// ------------------------------------------------------------------ formulaires "personne"

// les valeurs affichées dans les champs du formulaire (jamais le mot de passe)
public class ValeursPersonne
{
    public string Titre { get; set; } = "Mr";
    public string Prenom { get; set; } = "";
    public string Nom { get; set; } = "";
    public string Login { get; set; } = "";
    public int? Version { get; set; }
}

// ce qu'utilise le partiel commun _ChampsPersonne.cshtml (titre, prénom, nom)
public abstract class FormulaireVue : ModeleVue
{
    public ValeursPersonne Valeurs { get; set; } = new();

    // champ -> clé du message affiché SOUS ce champ (erreur de saisie)
    public Dictionary<string, string> Erreurs { get; set; } = new(StringComparer.OrdinalIgnoreCase);

    // champs à encadrer en rouge (classe Bootstrap is-invalid) : ceux qui ont
    // un message, plus d'éventuels autres (doublon prénom + nom, login pris...)
    public HashSet<string> ChampsInvalides { get; set; } = new(StringComparer.OrdinalIgnoreCase);

    public string? ErreurDe(string champ) => Erreurs.GetValueOrDefault(champ);

    public string ClasseInvalide(string champ) => ChampsInvalides.Contains(champ) ? "is-invalid" : "";

    public IEnumerable<(string Valeur, bool Selectionne)> Titres =>
        ReglesSaisie.Titres.Select(t => (t, t == Valeurs.Titre));
}

public class FormulairePersonneModele : FormulaireVue
{
    public bool Creation { get; set; } // true : ajout ; false : modification
    public string Action { get; set; } = ""; // URL du POST
    public string UrlListe { get; set; } = "";
}

public class InscriptionModele : FormulaireVue
{
    public int MotDePasseMin => ReglesSaisie.MotDePasseMin;
}

public class MonCompteModele : FormulaireVue
{
    public int MotDePasseMin => ReglesSaisie.MotDePasseMin;
}

// ------------------------------------------------------------------ listes médecins / clients

// les clés de traduction propres à chaque type de personne
public record ClesPersonnes(
    string Titre, // "Médecins"
    string Ajouter, // "Ajouter un médecin"
    string Modifier, // "Modifier un médecin"
    string Vide, // "Aucun médecin."
    string ConfirmerSuppression, // "Supprimer ce médecin et son compte ?"
    string Ajoute, // "Le médecin {nom} a été ajouté (login : {login})."
    string Modifie,
    string Supprime);

public record PersonneVue(string Titre, string Prenom, string Nom, string Login, string UrlModifier, string UrlSupprimer);

public class ListePersonnesModele : ModeleVue
{
    public required ClesPersonnes Cles { get; set; }
    public string UrlAjouter { get; set; } = "";
    public List<PersonneVue> Personnes { get; set; } = [];
}

// ------------------------------------------------------------------ espace du patient
public record RendezVousVue(int Id, string Jour, string Horaire, string Medecin, string UrlAgenda);

public class EspaceModele : ModeleVue
{
    public string Patient { get; set; } = "";
    public List<RendezVousVue> RendezVous { get; set; } = [];
}

// ------------------------------------------------------------------ page d'erreur
public class ErreurModele : ModeleVue
{
    public int Code { get; set; }
    public string Message { get; set; } = "ERRORS.SERVER_ERROR";
}

Les modèles de vue suivent la règle déjà appliquée avec [Handlebars] : pas de logique dans les vues. Les contrôleurs préparent des données déjà mises en forme (« Mme Marie PELISSIER », « 8:00 - 8:20 ») et déjà filtrées selon le rôle de l'utilisateur ; les vues se contentent de les placer dans le HTML. Chaque vue déclare le type de son modèle ([@model AgendaModele], cf. exemple 13) : le compilateur vérifie chaque [@Model.Xxx], et une faute de frappe devient une erreur de compilation.

Commentons ce code :

  • lignes 22-31 : ce que toutes les pages ont en commun, lu par le gabarit [_Layout.cshtml] : la clé de traduction du titre de la page (ligne 25), et le message d'erreur de la page, affiché en bas, avec ses paramètres. La classe est abstraite : chaque page a son propre modèle, qui en dérive ;
  • ligne 35 : une option d'une liste déroulante (un médecin, un patient), déjà marquée comme choisie ou non (cf. exemple 18) ;
  • lignes 38-41 : la page de connexion : le login déjà saisi et le captcha à afficher. Le mot-clé [required] oblige celui qui crée l'objet à donner une valeur à la propriété : l'oubli est une erreur de compilation ;
  • lignes 45-54 : une ligne de l'agenda, c'est-à-dire un créneau. Le contrôleur a déjà tout décidé : le nom du patient affiché ou non (un patient ne voit pas le nom des autres), le bouton Réserver affiché ou non, le bouton Annuler affiché ou non ([IdRv] renseigné). Les propriétés [init] ne peuvent recevoir une valeur qu'à la création de l'objet ;
  • ligne 57 : l'agenda d'un médecin pour un jour : le médecin, le jour en toutes lettres, et les lignes ;
  • lignes 59-66 : la fenêtre de réservation : la liste des patients pour l'ADMIN et les médecins, ou le seul nom du patient connecté pour un patient ;
  • lignes 69-79 : la page de l'agenda : la liste des médecins, le médecin et le jour choisis, l'agenda ([null] tant qu'aucun médecin n'est choisi), l'éventuelle fenêtre de réservation, l'adresse de l'agenda sans cette fenêtre (ligne 76), et le message qui explique à un patient pourquoi il ne peut pas réserver ;
  • lignes 85-91 : les valeurs affichées dans les champs d'un formulaire « personne », jamais le mot de passe : un mot de passe n'est pas renvoyé au navigateur ;
  • ligne 95 : la classe mère des modèles des trois formulaires « personne », celle qu'exige [Validation.Preparer] : les valeurs des champs, les messages sous les champs (ligne 100), et les champs à encadrer en rouge (ligne 104). Un [HashSet] est un ensemble : chaque élément n'y figure qu'une fois, et le test d'appartenance est rapide ;
  • lignes 106-108 : deux méthodes pour les vues : le message d'un champ (ou [null]), et la classe [Bootstrap] [is-invalid] qui encadre un champ en rouge. Les vues écrivent par exemple [@Model.ClasseInvalide("login")] ;
  • lignes 110-111 : les options de la liste des titres, celle qui est choisie étant marquée (cf. exemple 18). Chaque option est un tuple [(Valeur, Selectionne)] ;
  • lignes 114-118 : le formulaire de l'ADMIN, qui sert à l'ajout et à la modification (cf. exemple 28) : le mode, l'adresse d'envoi du formulaire, et celle de la liste pour le bouton de retour ;
  • lignes 121-128 : les formulaires d'inscription et de gestion du compte ; ils transmettent à la vue la longueur minimale d'un mot de passe, qui sert de paramètre [{min}] au message d'erreur du champ et, pour l'inscription, au texte d'aide ;
  • lignes 134-142 : les pages de gestion des médecins et des clients sont les mêmes vues ; seuls leurs textes diffèrent. Ce [record] porte les clés de traduction propres à chaque type de personne ;
  • lignes 144-150 : une ligne de la liste des médecins ou des clients, avec les adresses de ses boutons Modifier et Supprimer, et le modèle de la page de la liste ;
  • lignes 154-159 : l'espace du patient : son nom et ses rendez-vous à venir, chacun avec un lien vers l'agenda du jour ;
  • lignes 163-166 : la page d'erreur : le code HTTP et la clé du message, par défaut celle d'une erreur du serveur.

11.16. La couche web : les contrôleurs

Les contrôleurs sont la partie visible de la couche web : chacun reçoit les requêtes d'un groupe de pages, lit leurs paramètres, interroge la couche métier (toujours à travers le service [ApplicationModel]), puis choisit une vue et lui passe un modèle typé, ou bien redirige. Tous les formulaires POST suivent le schéma Post / Redirect / Get (cf. exemple 20) ; tous les messages sont des clés de traduction, traduites par la vue. Le contrôleur de la connexion, [AuthController], a été commenté avec l'authentification.

11.16.1. Controllers/LangueController.cs

// =========================================================================
// Controllers/LangueController.cs
// -------------------------------------------------------------------------
// GET /langue/fr, GET /langue/en : les deux boutons FR / EN de la barre de
// navigation. Route non protégée : on doit pouvoir changer de langue avant
// de se connecter.
//
// Le choix est mémorisé dans LE cookie du middleware de localisation
// d'ASP.NET Core (CookieRequestCultureProvider, cf. Program.cs) : à chaque
// requête suivante, ce middleware le relit et fixe la langue de la requête
// (CultureInfo.CurrentCulture / CurrentUICulture). Puis on revient sur la
// page d'où l'on vient.
// =========================================================================

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

namespace RdvMedecins.Controllers;

public class LangueController : Controller
{
    [HttpGet("langue/{langue}")]
    public IActionResult Changer(string langue)
    {
        if (!Traducteur.Langues.Contains(langue))
        {
            return NotFound(); // -> page d'erreur 404 (cf. Program.cs)
        }
        Response.Cookies.Append(
            CookieRequestCultureProvider.DefaultCookieName,
            // valeur du cookie : "c=en|uic=en" (langue des formats, langue des textes)
            CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(langue)),
            new CookieOptions { MaxAge = TimeSpan.FromDays(365), SameSite = SameSiteMode.Lax, HttpOnly = true, Path = "/" });
        return Redirect(PageDOrigine());
    }

    // la page précédente, d'après l'entête HTTP "Referer" envoyé par le
    // navigateur. On n'en garde que le chemin (/agenda?...) : rediriger vers
    // une adresse complète fournie par la requête permettrait d'envoyer
    // l'utilisateur sur un autre site (faille dite "open redirect").
    private string PageDOrigine()
    {
        var referer = Request.Headers.Referer.ToString();
        if (!Uri.TryCreate(referer, UriKind.Absolute, out var url))
        {
            return "/";
        }
        return url.AbsolutePath.StartsWith("/langue/", StringComparison.Ordinal) ? "/" : url.PathAndQuery;
    }
}

C'est, à une ligne près, le contrôleur de l'exemple 22 : même route [/langue/{langue}], même cookie du middleware de localisation, même retour à la page d'origine par l'entête [Referer]. Deux précisions :

  • ligne 34 : l'option [Path = "/"] rend le cookie valable pour toutes les adresses du site, quelle que soit la page d'où l'on a changé de langue ;
  • ligne 49 : si la page d'origine est elle-même une page [/langue/...], on revient à la racine du site plutôt que d'y retourner, ce qui ferait tourner le navigateur en rond.

11.16.2. Controllers/AgendaController.cs

La page principale de l'application, et le contrôleur le plus riche. Commentons-le par parties.

11.16.2.1. Les URL de la page

// =========================================================================
// Controllers/AgendaController.cs
// -------------------------------------------------------------------------
// La page de l'agenda : choix d'un médecin et d'un jour, agenda de ce
// médecin ce jour-là, réservation et annulation de rendez-vous.
//
//   GET  /agenda?idMedecin=1&jour=2026-09-23              la page
//   GET  /agenda?idMedecin=1&jour=2026-09-23&reserver=7    la même page, avec
//                                                          la fenêtre de réservation
//                                                          du créneau 7 ouverte
//   POST /agenda/reserver                                  crée un rendez-vous
//   POST /agenda/annuler                                   annule un rendez-vous
//
// Toute la page est décrite par son URL : on peut la recharger, la mettre
// en favori, utiliser le bouton "Précédent" du navigateur - sans une ligne
// de JavaScript.
//
// Ce que chacun voit et peut faire :
//
//   | rôle          | créneau occupé affiché            | Réserver           | Annuler |
//   |---------------|-----------------------------------|--------------------|---------|
//   | ADMIN, DOCTOR | nom du patient                    | pour un patient    | oui     |
//   |               |                                   | choisi dans liste  |         |
//   | USER (patient)| « Réservé », ou SON nom si c'est  | pour lui-même, s'il| non (*) |
//   |               | son propre rendez-vous            | n'a pas déjà un rdv|         |
//   |               |                                   | à venir            |         |
//
//   (*) le patient annule son rendez-vous depuis sa page "Mon espace".
//
// CONFIDENTIALITÉ : pour un patient, le nom des AUTRES patients n'est pas
// simplement masqué par la vue - il n'est jamais placé dans le modèle
// transmis à la vue. Le HTML envoyé au navigateur ne le contient donc pas,
// même caché (un simple "Afficher le code source" le révélerait sinon).
//
// Pas de réservation dans le passé : le bouton "Réserver" n'apparaît que
// pour aujourd'hui et les jours suivants (et la couche métier le refuserait
// de toute façon).
//
// [Authorize] sur la classe : toutes les actions exigent un utilisateur
// connecté (l'équivalent de @UseGuards(JwtAuthGuard)). [Authorize(Roles =
// "...")] sur une action exige en plus l'un des rôles cités (RolesGuard +
// @Roles). Sans jeton valide : retour à /login ; rôle insuffisant : page 403.
// =========================================================================

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

namespace RdvMedecins.Controllers;

[Authorize]
[Route("agenda")]
public class AgendaController(ApplicationModel application) : Controller
{
    // ce que le contrôleur sait de l'utilisateur, pour décider de l'affichage
    private record Droits(
        bool Gestionnaire, // ADMIN ou DOCTOR
        Client? Patient, // le client connecté (rôle USER), sinon null
        bool PatientPeutReserver); // USER sans rendez-vous à venir
  • lignes 7-12 : les routes de la page. On remarque que la fenêtre de réservation n'est pas ouverte par du [JavaScript] : c'est un paramètre de l'URL ([reserver=7]) qui demande au serveur de fabriquer la page avec la fenêtre ouverte. Le bouton Réserver est donc un simple lien. Toute la page est décrite par son URL : on peut la recharger, la mettre en favori, utiliser le bouton « Précédent » du navigateur ;
  • lignes 18-28 : ce que chaque rôle voit et peut faire. Ce tableau est la « spécification » de la page : tout le reste du contrôleur le met en œuvre ;
  • lignes 30-33 : la règle de confidentialité, sur laquelle nous reviendrons : le nom des autres patients n'est pas caché par la vue, il n'est jamais envoyé au navigateur ;
  • lignes 55-57 : [[Authorize]] sur la classe : toutes les actions exigent un utilisateur connecté, quel que soit son rôle (cf. exemple 25). [[Route("agenda")]] est le préfixe de toutes les routes du contrôleur (cf. exemple 02). Le contrôleur reçoit par injection (cf. exemple 06) le service [ApplicationModel], par son constructeur principal : les paramètres écrits entre parenthèses après le nom de la classe sont accessibles dans toutes ses méthodes ;
  • lignes 60-63 : ce que le contrôleur sait de l'utilisateur connecté, calculé une fois par requête (cf. [DroitsAsync], plus bas). Un [record] privé, déclaré à l'intérieur de la classe : il ne sert qu'à elle. Un gestionnaire est un ADMIN ou un DOCTOR ; [Patient] n'est renseigné que pour un client (rôle USER).

11.16.2.2. GET /agenda

    // -----------------------------------------------------------------------
    // GET /agenda
    // Les paramètres de l'URL sont lus bruts (Request.Query), pour distinguer
    // un paramètre ABSENT d'un paramètre VIDE (?idMedecin=).
    // -----------------------------------------------------------------------
    [HttpGet("")]
    public async Task<IActionResult> Afficher()
    {
        string? erreur = null;
        var utilisateur = User.Utilisateur()!;
        var droits = await DroitsAsync(utilisateur);

        // le jour : celui de l'URL s'il est valide, sinon aujourd'hui
        var jour = Dates.Aujourdhui();
        if (Request.Query.TryGetValue("jour", out var jourBrut))
        {
            if (DatesWeb.EstUneDateValide(jourBrut, out var jourDemande))
            {
                jour = jourDemande;
            }
            else
            {
                erreur = "ERRORS.INVALID_DATE";
            }
        }
        var jourReservable = jour >= Dates.Aujourdhui();

        // le médecin : celui de l'URL ; à défaut, pour un médecin connecté, lui-même
        var idMedecin = Saisie.EntierPositif(Request.Query["idMedecin"]);
        if (!Request.Query.ContainsKey("idMedecin") && utilisateur.Role == Role.DOCTOR)
        {
            idMedecin = application.GetMedecinByUserId(utilisateur.UserId)?.Id;
        }

        var medecins = (await application.GetAllMedecinsAsync())
            .Select(m => Option(m, m.Id == idMedecin))
            .ToList();

        // l'agenda, si un médecin est choisi (et la date valide)
        AgendaVue? agenda = null;
        if (idMedecin is int id && erreur is null)
        {
            var agendaMedecin = await application.GetAgendaMedecinJourAsync(id, jour);
            if (agendaMedecin is null)
            {
                erreur = "ERRORS.DOCTOR_NOT_FOUND";
            }
            else
            {
                var lignes = agendaMedecin.CreneauxMedecinJour.Select(cmj =>
                {
                    var (creneau, rv) = (cmj.Creneau, cmj.Rv);
                    var libre = rv is null;
                    // le rendez-vous du patient connecté ?
                    var moi = rv is not null && droits.Patient is not null && rv.IdClient == droits.Patient.Id;
                    return new LigneAgendaVue
                    {
                        IdCreneau = creneau.Id,
                        Horaire = DatesWeb.Horaire(creneau),
                        Libre = libre,
                        // LE point de confidentialité : un patient ne reçoit le nom que
                        // de SON rendez-vous ; pour les autres, null (la vue affiche « Réservé »)
                        Patient = rv is not null && (droits.Gestionnaire || moi) ? Libelles.Libelle(rv.Client) : null,
                        Moi = moi,
                        Reservable = libre && jourReservable && (droits.Gestionnaire || droits.PatientPeutReserver),
                        UrlReserver = UrlAgenda(id, jour, creneau.Id),
                        IdRv = rv is not null && droits.Gestionnaire ? rv.Id : null,
                    };
                }).ToList();
                agenda = new AgendaVue(Libelles.Libelle(agendaMedecin.Medecin), DatesWeb.Iso(jour), lignes);
            }
        }
  • lignes 6-7 : la route [GET /agenda] (préfixe du contrôleur, suivi d'une chaîne vide). L'action n'a aucun paramètre : elle lit elle-même l'URL par [Request.Query] (cf. exemple 05). La liaison de modèle convertirait un paramètre [int? idMedecin] en [null] aussi bien quand il est absent que quand il est vide ; or ces deux cas doivent être distingués (voir plus bas) ;
  • lignes 10-11 : l'utilisateur connecté et ses droits. [Utilisateur()] est une méthode d'extension (cf. exemple 06) définie dans [Auth/UtilisateurConnecte.cs] : elle lit les claims du jeton et en fait un objet typé. Le [!] affirme au compilateur que le résultat n'est pas [null] : c'est certain, puisque [[Authorize]] a écarté les requêtes non authentifiées ;
  • lignes 14-26 : le jour demandé, s'il est valide ; aujourd'hui sinon. [TryGetValue] rend [false] si le paramètre [jour] est absent : c'est alors aujourd'hui, sans message. Présent mais invalide ([jour=2026-02-30]), il produit un message d'erreur. On ne peut réserver qu'à partir d'aujourd'hui ([jourReservable]) ;
  • lignes 29-32 : le médecin demandé. [Saisie.EntierPositif] (dossier [Web]) rend un entier strictement positif, ou [null] pour une valeur absente, vide ou invalide. Si le paramètre [idMedecin] est absent de l'URL, un médecin connecté voit son propre agenda : c'est sa page d'accueil. S'il est vide ([idMedecin=], l'option « choisir un médecin » de la liste), aucun agenda n'est affiché ;
  • lignes 35-37 : les options de la liste déroulante des médecins, lue dans le cache d'[ApplicationModel] ; celle du médecin demandé est sélectionnée (cf. la fonction [Option], en fin de fichier, et l'exemple 18) ;
  • lignes 40-46 : l'agenda n'est calculé que si un médecin est choisi et si la date est valide. [idMedecin is int id] teste que la valeur (de type [int?]) n'est pas [null], et range sa valeur dans une nouvelle variable [id], de type [int]. Un identifiant de médecin inconnu donne un message, pas une page d'erreur ;
  • lignes 50-56 : l'agenda calculé par la couche métier (un objet [AgendaMedecinJour] du dossier [Domain]) est transformé en lignes de tableau, une par créneau. [var (creneau, rv) = ...] déconstruit un couple de valeurs en deux variables. [moi] dit si le rendez-vous est celui du patient connecté ;
  • lignes 58-59 : la vue ne reçoit que des données déjà mises en forme ([8:00 - 8:20]) : elle n'a plus rien à calculer ;
  • ligne 63 : le point de confidentialité de l'application. Le nom du patient n'est placé dans le modèle que pour un gestionnaire, ou pour le patient dont c'est le rendez-vous. Pour les autres, le modèle contient [null], et la vue affiche « Réservé ». Le nom n'est pas simplement caché par la vue : il n'est jamais envoyé au navigateur, et « Afficher le code source de la page » ne le révélerait pas ;
  • lignes 65-67 : ce que la vue doit afficher, décidé par le contrôleur : le bouton Réserver (créneau libre, jour non passé, et utilisateur autorisé à réserver), son lien (l'URL de la page, avec [reserver]), et le bouton Annuler ([IdRv] n'est renseigné que pour un gestionnaire). La vue ne décide de rien : elle suit ces indications ;
  • ligne 70 : l'agenda complet : le médecin, le jour, les lignes.

11.16.2.3. La fenêtre de réservation et le modèle de la vue

        // la fenêtre de réservation : seulement si le créneau demandé est
        // réservable par cet utilisateur
        ReservationVue? reservation = null;
        var idCreneau = Saisie.EntierPositif(Request.Query["reserver"]);
        var ligne = agenda?.Lignes.FirstOrDefault(l => l.IdCreneau == idCreneau && l.Reservable);
        if (ligne is not null)
        {
            reservation = new ReservationVue
            {
                IdCreneau = ligne.IdCreneau,
                Horaire = ligne.Horaire,
                Clients = droits.Gestionnaire
                    ? (await application.GetAllClientsAsync()).Select(c => Option(c, false)).ToList()
                    : [],
                Patient = droits.Patient is not null ? Libelles.Libelle(droits.Patient) : null,
            };
        }

        return View("Agenda", new AgendaModele
        {
            TitrePage = "AGENDA.PAGE_TITLE",
            Medecins = medecins,
            IdMedecin = idMedecin,
            Jour = DatesWeb.Iso(jour),
            Agenda = agenda,
            Reservation = reservation,
            UrlRetour = idMedecin is int idm ? UrlAgenda(idm, jour) : "/agenda",
            Info = droits.Patient is not null && !droits.PatientPeutReserver ? "AGENDA.ALREADY_BOOKED_INFO" : null,
            Erreur = erreur,
        });
    }
  • lignes 3-5 : le créneau dont l'URL demande la réservation ([reserver=7]). Il doit figurer dans l'agenda affiché et être réservable par cet utilisateur : une URL fabriquée à la main (un créneau passé, occupé, ou d'un autre médecin) n'ouvre pas la fenêtre. [agenda?.Lignes] vaut [null] si [agenda] est [null] (opérateur [?.]) ;
  • lignes 6-15 : le modèle de la fenêtre. Un gestionnaire choisit le patient dans une liste (tous les clients, lus dans le cache) ; un patient réserve pour lui-même : la fenêtre affiche simplement son nom. [[]] est une liste vide ;
  • lignes 19-29 : le modèle de la page. Ses classes ([AgendaModele], [AgendaVue], [LigneAgendaVue], [ReservationVue], [OptionVue]) sont définies dans [Models/ModelesVues.cs] ; [TitrePage] et [Erreur] viennent de leur classe mère commune [ModeleVue]. [TitrePage] est, comme tous les textes, une clé de traduction ;
  • ligne 27 : l'adresse de l'agenda affiché, sans la fenêtre de réservation : c'est celle du bouton qui ferme la fenêtre ;
  • ligne 28 : un message d'information pour le patient qui a déjà un rendez-vous à venir : il lui explique pourquoi il ne voit aucun bouton Réserver.

11.16.2.4. POST /agenda/reserver

    // -----------------------------------------------------------------------
    // POST /agenda/reserver   champs : idMedecin, jour, idCreneau, idClient
    //
    // Pour un patient (USER), le champ idClient est IGNORÉ : il réserve
    // toujours pour lui-même. Faire confiance à un champ envoyé par le
    // navigateur permettrait à un patient malin de réserver au nom d'un autre.
    //
    // Schéma "Post / Redirect / Get" : quel que soit le résultat, on ne
    // fabrique pas de page ici - on dépose un message flash et on redirige
    // vers l'agenda (cf. Web/Flash.cs).
    // -----------------------------------------------------------------------
    [HttpPost("reserver")]
    [Authorize(Roles = "ADMIN,DOCTOR,USER")]
    public async Task<IActionResult> Reserver(
        [FromForm] string? idMedecin,
        [FromForm] string? jour,
        [FromForm] string? idCreneau,
        [FromForm] string? idClient)
    {
        if (!LireAgendaDemande(idMedecin, jour, out var idMed, out var jourDemande)
            || Saisie.EntierPositif(idCreneau) is not int idCre)
        {
            return BadRequest(); // -> page "requête invalide" (cf. Program.cs)
        }

        Client? client;
        if (User.IsInRole(nameof(Role.USER)))
        {
            client = await Patient.PatientConnecteAsync(application, User);
        }
        else
        {
            if (Saisie.EntierPositif(idClient) is not int idCli)
            {
                // aucun patient choisi : on rouvre la fenêtre de réservation
                Flash.Deposer(HttpContext, TypeFlash.Danger, "ERRORS.CHOOSE_PATIENT");
                return Redirect(UrlAgenda(idMed, jourDemande, idCre));
            }
            client = await application.GetClientByIdAsync(idCli);
        }

        var creneau = await application.GetCreneauByIdAsync(idCre);
        if (client is null)
        {
            Flash.Deposer(HttpContext, TypeFlash.Danger, "ERRORS.PATIENT_NOT_FOUND");
        }
        else if (creneau is null || creneau.IdMedecin != idMed)
        {
            Flash.Deposer(HttpContext, TypeFlash.Danger, "ERRORS.SLOT_NOT_FOUND");
        }
        else
        {
            try
            {
                // les règles (pas dans le passé, un seul rendez-vous à venir par
                // patient, créneau libre) sont vérifiées par la couche métier
                await application.AjouterRvAsync(jourDemande, creneau, client);
                Flash.Deposer(HttpContext, TypeFlash.Success, "AGENDA.BOOKED");
            }
            // "when" : un filtre d'exception. Même règle, mais un message à la 2e
            // personne quand c'est le patient lui-même qui réserve (« Vous avez
            // déjà... » plutôt que « Ce patient... »)
            catch (MetierException e) when (e.Cle == "ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT" && User.IsInRole(nameof(Role.USER)))
            {
                Flash.Deposer(HttpContext, TypeFlash.Danger, "AGENDA.ALREADY_BOOKED_INFO");
            }
            // toute autre MetierException devient un message flash ; les autres
            // exceptions (vraies anomalies) ne sont pas attrapées : page 500
            catch (MetierException e)
            {
                Flash.Deposer(HttpContext, TypeFlash.Danger, e.Cle, new Dictionary<string, string>(e.Parametres));
            }
        }
        return Redirect(UrlAgenda(idMed, jourDemande));
    }
  • lignes 12-13 : [[Authorize(Roles = ...)]] sur l'action s'ajoute au [[Authorize]] de la classe : les trois rôles peuvent réserver (cf. exemple 26). Ici, cet attribut ne restreint rien de plus ; il documente la règle, et la protégerait si un quatrième rôle apparaissait ;
  • lignes 15-18 : les champs du formulaire de la fenêtre de réservation (cf. exemple 11). Ils sont reçus sous forme de chaînes, et convertis ensuite avec prudence ;
  • lignes 20-23 : le médecin et le jour sont des champs cachés du formulaire : ils permettent de revenir à l'agenda affiché. S'ils sont invalides, la requête ne vient pas de notre page : elle a été fabriquée à la main. [BadRequest()] rend une réponse 400 sans contenu, que le middleware [UseStatusCodePagesWithReExecute] transforme en page d'erreur (cf. exemple 24). [is not int idCre] est le contraire de [is int] : vrai si la valeur est [null] ; sinon, [idCre] reçoit la valeur ;
  • lignes 27-29 : pour un patient, le champ [idClient] est ignoré : il réserve toujours pour lui-même. Se fier à ce champ, envoyé par le navigateur, permettrait à un patient malin de réserver au nom d'un autre. [Patient.PatientConnecteAsync] (dossier [Web]) retrouve le client à partir du jeton ; [nameof(Role.USER)] est la chaîne ["USER"], vérifiée par le compilateur ;
  • lignes 33-39 : un gestionnaire qui n'a pas choisi de patient retrouve la fenêtre de réservation (l'URL avec [reserver]), avec un message flash d'erreur. [Flash.Deposer] (dossier [Web]) range le message dans [TempData] (cf. exemple 20) ;
  • lignes 42-49 : le patient doit exister, et le créneau doit exister et appartenir au médecin de l'agenda : on ne peut pas réserver chez un médecin un créneau d'un autre ;
  • lignes 57-58 : les règles de gestion (pas dans le passé, créneau libre, un seul rendez-vous à venir par patient) sont vérifiées par la couche métier, qui lève une [MetierException] en cas de refus ;
  • lignes 63-65 : [when] ajoute une condition au [catch] : c'est un filtre d'exception [C#]. Ce bloc n'attrape la [MetierException] que si la condition est vraie : pour un patient, le message « ce patient a déjà un rendez-vous » est remplacé par un message à la deuxième personne. Sinon, l'exception passe au [catch] suivant ;
  • lignes 69-71 : toute autre [MetierException] devient un message flash, avec sa clé et ses paramètres ([e.Parametres] est un dictionnaire en lecture seule : on en fait une copie, du type attendu par [Flash.Deposer]). Les autres exceptions ne sont pas attrapées : ce sont de vraies anomalies, qui aboutissent à la page d'erreur 500 ;
  • ligne 74 : le schéma PRG : quel que soit le résultat, message flash et redirection vers l'agenda, qui affichera le message. Dans la version [NestJS], une fonction utilitaire regroupait cette conversion des erreurs métier ; ici, les deux blocs [catch] suffisent.

11.16.2.5. POST /agenda/annuler

    // -----------------------------------------------------------------------
    // POST /agenda/annuler   champs : idRv, idMedecin, jour
    // (un patient annule SON rendez-vous depuis "Mon espace", pas d'ici)
    // -----------------------------------------------------------------------
    [HttpPost("annuler")]
    [Authorize(Roles = "ADMIN,DOCTOR")]
    public async Task<IActionResult> Annuler([FromForm] string? idRv, [FromForm] string? idMedecin, [FromForm] string? jour)
    {
        if (!LireAgendaDemande(idMedecin, jour, out var idMed, out var jourDemande)
            || Saisie.EntierPositif(idRv) is not int id)
        {
            return BadRequest();
        }
        var rv = await application.GetRvByIdAsync(id);
        if (rv is null)
        {
            // déjà annulé (par quelqu'un d'autre, ou double clic)
            Flash.Deposer(HttpContext, TypeFlash.Danger, "ERRORS.APPOINTMENT_NOT_FOUND");
        }
        else
        {
            await application.SupprimerRvAsync(rv);
            Flash.Deposer(HttpContext, TypeFlash.Success, "AGENDA.CANCELLED");
        }
        return Redirect(UrlAgenda(idMed, jourDemande));
    }
  • lignes 5-7 : l'annulation est réservée aux gestionnaires ; un patient annule son rendez-vous depuis « Mon espace » (cf. [EspacePatientController]). Un patient qui enverrait cette requête recevrait la page 403 ;
  • lignes 9-12 : les mêmes contrôles que pour la réservation ;
  • lignes 14-18 : un rendez-vous déjà annulé (double clic, ou annulation par un autre gestionnaire) donne un message d'erreur, pas une page d'erreur ;
  • lignes 22-25 : la suppression, puis le schéma PRG.

11.16.2.6. Les droits et les fonctions utilitaires

    // -----------------------------------------------------------------------
    // qui est connecté, et qu'a-t-il le droit de faire sur cette page ?
    // -----------------------------------------------------------------------
    private async Task<Droits> DroitsAsync(UtilisateurConnecte utilisateur)
    {
        if (utilisateur.Role != Role.USER)
        {
            return new Droits(Gestionnaire: true, Patient: null, PatientPeutReserver: false);
        }
        var patient = await Patient.PatientConnecteAsync(application, User);
        var rvAVenir = await application.GetRvAVenirClientAsync(patient.Id);
        return new Droits(Gestionnaire: false, patient, PatientPeutReserver: rvAVenir.Count == 0);
    }

    // ----------------------------------------------------------------- fonctions utilitaires

    // les champs cachés idMedecin / jour, communs aux deux formulaires POST
    private static bool LireAgendaDemande(string? idMedecinBrut, string? jourBrut, out int idMedecin, out DateOnly jour)
    {
        idMedecin = Saisie.EntierPositif(idMedecinBrut) ?? 0;
        return DatesWeb.EstUneDateValide(jourBrut, out jour) && idMedecin > 0;
    }

    private static string UrlAgenda(int idMedecin, DateOnly jour, int? reserver = null)
    {
        var url = $"/agenda?idMedecin={idMedecin}&jour={DatesWeb.Iso(jour)}";
        return reserver is null ? url : $"{url}&reserver={reserver}";
    }

    private static OptionVue Option(Personne p, bool selectionne) => new(p.Id, Libelles.Libelle(p), selectionne);
}
  • lignes 4-12 : un gestionnaire a tous les droits ; un patient peut réserver s'il n'a pas de rendez-vous à venir. Les arguments nommés ([Gestionnaire: true]) rendent l'appel du constructeur du [record] lisible ;
  • lignes 18-21 : la lecture des deux champs cachés communs aux formulaires POST. Les paramètres [out] sont des résultats supplémentaires de la méthode : elle rend un booléen (les champs sont-ils valides ?) et, par ses paramètres [out], les valeurs converties. C'est le procédé de [int.TryParse] ;
  • lignes 24-27 : l'URL de l'agenda d'un médecin un jour donné, éventuellement avec la fenêtre de réservation ouverte. Toutes les redirections du contrôleur la fabriquent par cette fonction : les URL de la page sont écrites à un seul endroit ;
  • ligne 30 : une option de liste déroulante (identifiant, libellé, sélection), pour un médecin comme pour un client : le paramètre est de type [Personne], la classe mère des deux entités.

11.16.3. Controllers/PersonnesControllerBase.cs

// =========================================================================
// Controllers/PersonnesControllerBase.cs
// -------------------------------------------------------------------------
// La gestion des médecins et celle des clients (réservées à l'ADMIN) sont
// IDENTIQUES : même liste, même formulaire, mêmes règles. Plutôt que
// d'écrire deux fois les mêmes actions, on les écrit une fois dans cette
// classe ABSTRAITE et GÉNÉRIQUE, dont héritent MedecinsController
// ([Route("medecins")]) et ClientsController ([Route("clients")]). Chaque
// sous-classe n'a qu'à fournir sa "configuration" : quelles méthodes
// appeler, quels libellés afficher.
//
// Les routes posées ICI ([HttpGet("ajouter")]...) sont héritées, et
// complètent la route de la sous-classe. On obtient donc, pour chacune :
//
//   GET  /medecins                   liste
//   GET  /medecins/ajouter           formulaire vide
//   POST /medecins/ajouter           création
//   GET  /medecins/{id}/modifier     formulaire pré-rempli
//   POST /medecins/{id}/modifier     modification
//   POST /medecins/{id}/supprimer    suppression
//
// (et les mêmes URL sous /clients). Les vues sont elles aussi communes :
// Views/Personnes/Liste.cshtml et Views/Personnes/Formulaire.cshtml.
//
// Une classe abstraite n'est jamais prise pour un contrôleur par MVC : seules
// ses sous-classes concrètes le sont.
// =========================================================================

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

namespace RdvMedecins.Controllers;

// ce que chaque sous-classe doit fournir
public record ConfigPersonnes<T>(
    string Url, // "/medecins" ou "/clients"
    ClesPersonnes Cles,
    Func<Task<List<T>>> Lister,
    Func<int, Task<T?>> Trouver,
    Func<DonneesPersonne, Task<T>> Ajouter,
    Func<int, DonneesPersonne, Task<T>> Modifier,
    Func<int, Task> Supprimer) where T : Personne;

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

    protected abstract ConfigPersonnes<T> Config { get; }

    // -----------------------------------------------------------------------
    // GET /medecins (ou /clients)
    // -----------------------------------------------------------------------
    [HttpGet("")]
    public async Task<IActionResult> Liste()
    {
        var personnes = await Config.Lister();
        return View("~/Views/Personnes/Liste.cshtml", new ListePersonnesModele
        {
            TitrePage = Config.Cles.Titre,
            Cles = Config.Cles,
            UrlAjouter = $"{Config.Url}/ajouter",
            Personnes = personnes
                .Select(p => new PersonneVue(p.Titre, p.Prenom, p.Nom, p.User?.Login ?? "",
                    $"{Config.Url}/{p.Id}/modifier", $"{Config.Url}/{p.Id}/supprimer"))
                .ToList(),
        });
    }

    // -----------------------------------------------------------------------
    // GET /medecins/ajouter
    // -----------------------------------------------------------------------
    [HttpGet("ajouter")]
    public IActionResult FormulaireAjout() => AfficherFormulaire(null, new ValeursPersonne());

    // -----------------------------------------------------------------------
    // POST /medecins/ajouter
    // [formulaire] a été rempli ET validé par MVC : ModelState dit si la
    // saisie est correcte.
    // -----------------------------------------------------------------------
    [HttpPost("ajouter")]
    public async Task<IActionResult> Ajouter(FormulairePersonne formulaire)
    {
        if (!ModelState.IsValid)
        {
            return AfficherFormulaire(null, ValeursSaisies(formulaire), Validation.ErreursDe(ModelState));
        }
        try
        {
            var personne = await Config.Ajouter(Donnees(formulaire));
            Flash.Deposer(HttpContext, TypeFlash.Success, Config.Cles.Ajoute, Parametres(personne));
            return Redirect(Config.Url);
        }
        catch (MetierException e)
        {
            return AfficherErreurMetier(null, ValeursSaisies(formulaire), e);
        }
    }

    // -----------------------------------------------------------------------
    // GET /medecins/{id}/modifier
    // {id:int} : une "contrainte de route" - /medecins/abc/modifier ne
    // correspond à aucune route (page 404)
    // -----------------------------------------------------------------------
    [HttpGet("{id:int}/modifier")]
    public async Task<IActionResult> FormulaireModification(int id)
    {
        var personne = await Config.Trouver(id);
        if (personne is null)
        {
            return NotFound();
        }
        return AfficherFormulaire(id, new ValeursPersonne
        {
            Titre = personne.Titre,
            Prenom = personne.Prenom,
            Nom = personne.Nom,
            Login = personne.User?.Login ?? "",
            Version = personne.Version,
        });
    }

    // -----------------------------------------------------------------------
    // POST /medecins/{id}/modifier
    // -----------------------------------------------------------------------
    [HttpPost("{id:int}/modifier")]
    public async Task<IActionResult> Modifier(int id, FormulairePersonne formulaire)
    {
        if (!ModelState.IsValid)
        {
            return AfficherFormulaire(id, ValeursSaisies(formulaire), Validation.ErreursDe(ModelState));
        }
        try
        {
            var personne = await Config.Modifier(id, Donnees(formulaire));
            Flash.Deposer(HttpContext, TypeFlash.Success, Config.Cles.Modifie, Parametres(personne));
            return Redirect(Config.Url);
        }
        catch (MetierException e)
        {
            return AfficherErreurMetier(id, ValeursSaisies(formulaire), e);
        }
    }

    // -----------------------------------------------------------------------
    // POST /medecins/{id}/supprimer
    // (la confirmation "Êtes-vous sûr ?" est demandée par le navigateur avant
    // l'envoi du formulaire, cf. wwwroot/js/app.js)
    // -----------------------------------------------------------------------
    [HttpPost("{id:int}/supprimer")]
    public async Task<IActionResult> Supprimer(int id)
    {
        var personne = await Config.Trouver(id);
        try
        {
            await Config.Supprimer(id);
            Flash.Deposer(HttpContext, TypeFlash.Success, Config.Cles.Supprime, Parametres(personne));
        }
        catch (MetierException e)
        {
            Flash.Deposer(HttpContext, TypeFlash.Danger, e.Cle, new Dictionary<string, string>(e.Parametres));
        }
        return Redirect(Config.Url);
    }

    // ----------------------------------------------------------------- méthodes privées

    // id null : formulaire de création ; sinon : formulaire de modification
    private ViewResult AfficherFormulaire(
        int? id,
        ValeursPersonne valeurs,
        Dictionary<string, string>? erreurs = null,
        string? erreur = null,
        IEnumerable<string>? autresChamps = null)
    {
        var creation = id is null;
        return View("~/Views/Personnes/Formulaire.cshtml", Validation.Preparer(
            new FormulairePersonneModele
            {
                TitrePage = creation ? Config.Cles.Ajouter : Config.Cles.Modifier,
                Creation = creation,
                Action = creation ? $"{Config.Url}/ajouter" : $"{Config.Url}/{id}/modifier",
                UrlListe = Config.Url,
                Valeurs = valeurs,
            },
            erreurs, erreur, autresChamps));
    }

    // une règle de gestion a refusé l'écriture : on réaffiche le formulaire,
    // avec la saisie de l'utilisateur et le message de la couche métier
    private IActionResult AfficherErreurMetier(int? id, ValeursPersonne valeurs, MetierException e)
    {
        if (e.Cle is "ERRORS.DOCTOR_NOT_FOUND" or "ERRORS.PATIENT_NOT_FOUND")
        {
            // supprimé entre-temps par un autre ADMIN : retour à la liste
            Flash.Deposer(HttpContext, TypeFlash.Danger, e.Cle);
            return Redirect(Config.Url);
        }
        // le message va en bas de page ; les champs concernés, s'il y en a, sont
        // encadrés en rouge pour que l'utilisateur voie tout de suite quoi corriger
        return AfficherFormulaire(id, valeurs, null, e.Cle, Validation.ChampsErreursMetier.GetValueOrDefault(e.Cle));
    }

    // ce que l'utilisateur a saisi (mot de passe exclu : on ne le renvoie jamais au navigateur)
    private static ValeursPersonne ValeursSaisies(FormulairePersonne f) => new()
    {
        Titre = f.Titre ?? "",
        Prenom = f.Prenom ?? "",
        Nom = f.Nom ?? "",
        Login = f.Login ?? "",
        Version = f.Version,
    };

    // le formulaire validé -> les données attendues par la couche métier
    private static DonneesPersonne Donnees(FormulairePersonne f) => new()
    {
        Titre = f.Titre!,
        Nom = f.Nom!,
        Prenom = f.Prenom!,
        Login = f.Login,
        Password = f.Password,
        Version = f.Version,
    };

    // {nom} et {login} des messages "ajouté / modifié / supprimé"
    private static Dictionary<string, string> Parametres(Personne? personne) => personne is null
        ? new() { ["nom"] = "", ["login"] = "" }
        : new() { ["nom"] = $"{personne.Prenom} {personne.Nom}", ["login"] = personne.User?.Login ?? "" };
}

La gestion des médecins et celle des clients sont identiques. Plutôt que d'écrire deux contrôleurs semblables, on écrit une seule fois leurs actions, dans une classe mère abstraite et générique. Commentons ce code :

  • lignes 15-20 : les routes obtenues pour les médecins ; les mêmes existent sous [/clients] ;
  • lignes 22-23 : les vues sont communes elles aussi (dossier [Views/Personnes]) ;
  • lignes 25-26 : MVC ne prend pour contrôleurs que les classes concrètes : seules [MedecinsController] et [ClientsController] reçoivent des requêtes ;
  • lignes 38-45 : la configuration que chaque sous-classe fournit : l'URL de base, les textes (des clés de traduction, regroupées dans le [record] [ClesPersonnes] de [Models/ModelesVues.cs]), et les fonctions à appeler pour lister, trouver, ajouter, modifier, supprimer. [Func<int, Task<T?>>] est le type d'une fonction qui reçoit un [int] et rend une [Task<T?>] : on range ainsi des méthodes dans des propriétés, pour les appeler plus tard. [where T : Personne] est une contrainte du type générique : [T] sera [Medecin] ou [Client], deux classes dérivées de [Personne] ; le code peut donc utiliser [p.Prenom] ou [p.User], communs à toutes les personnes ;
  • ligne 47 : la classe mère, générique (cf. la classe [PageVue<TModel>] de l'exemple 22) et abstraite. Elle reçoit [ApplicationModel] par son constructeur principal, et le transmet à ses sous-classes par la propriété protégée [Application] ([protected] : visible des seules sous-classes) ;
  • ligne 51 : une propriété abstraite : chaque sous-classe doit la définir ;
  • lignes 56-69 : la liste. Les attributs de route posés sur les méthodes d'une classe mère sont hérités : combinés au [[Route("medecins")]] de la sous-classe, ils donnent [GET /medecins]. Le modèle contient, pour chaque personne, les URL de ses boutons Modifier et Supprimer. [p.User?.Login ?? ""] : le login du compte associé, ou une chaîne vide si la personne n'a pas de compte ;
  • ligne 60 : la vue est désignée par son chemin complet. Avec son seul nom, MVC la chercherait dans le dossier du contrôleur ([Views/Medecins] ou [Views/Clients], cf. exemple 13), alors qu'elle est commune aux deux ;
  • lignes 75-76 : le formulaire vide ;
  • lignes 83-98 : l'ajout, en trois temps, comme à l'exemple 28. Le paramètre [formulaire] a été rempli et validé par MVC avant l'appel (cf. exemple 19) : une saisie invalide réaffiche le formulaire, avec les valeurs saisies et un message sous chaque champ erroné ; une saisie valide est confiée à la couche métier, puis le navigateur est redirigé vers la liste, avec un message flash (PRG) ; un refus de la couche métier (un homonyme, un login déjà pris) réaffiche le formulaire ;
  • lignes 107-121 : le formulaire de modification, prérempli. La contrainte de route [:int] (cf. exemple 02) n'accepte qu'un identifiant entier : [/medecins/abc/modifier] ne correspond à aucune route, d'où une page 404 (le [ParseIntPipe] de [NestJS] donnait une erreur 400). Une personne inexistante donne également une 404. Le numéro de version, champ caché du formulaire, servira au verrou optimiste (cf. exemple 28) ;
  • lignes 128-143 : la modification, sur le même modèle que l'ajout ;
  • lignes 152-165 : la suppression. La personne est lue avant, pour afficher son nom dans le message de confirmation. Un refus de la couche métier (un médecin qui a des rendez-vous) devient un message flash d'erreur sur la page de la liste ;
  • lignes 171-188 : le formulaire, pour la création ([id] vaut [null]) comme pour la modification : seuls le titre et l'adresse d'envoi changent. [Validation.Preparer] (dossier [Web]) complète le modèle avec les erreurs par champ, le message du bas de page et les champs à encadrer en rouge ;
  • lignes 193-203 : une personne supprimée entre-temps (par un autre administrateur) renvoie à la liste ; les autres erreurs métier réaffichent le formulaire, avec le message en bas de page et les champs concernés encadrés en rouge ([Validation.ChampsErreursMetier] associe à chaque clé d'erreur ses champs : [prenom] et [nom] pour un homonyme). [is "A" or "B"] compare une valeur à plusieurs constantes ;
  • lignes 207-213 : les valeurs à réafficher, jamais le mot de passe ;
  • lignes 217-224 : les données transmises à la couche métier. Les [!] sont sans risque : la validation a vérifié la présence de ces champs ;
  • lignes 228-230 : les paramètres [{nom}] et [{login}] des messages de succès : le prénom et le nom de la personne, et son login.

11.16.4. Controllers/MedecinsController.cs

// =========================================================================
// Controllers/MedecinsController.cs
// -------------------------------------------------------------------------
// Gestion des médecins (rôle ADMIN uniquement). Toutes les actions sont
// héritées de PersonnesControllerBase : il ne reste ici qu'à dire QUOI
// appeler et QUELS libellés afficher.
//
// [Authorize(Roles = "ADMIN")] posé sur la CLASSE s'applique à toutes ses
// actions, y compris celles héritées.
// =========================================================================

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

namespace RdvMedecins.Controllers;

[Authorize(Roles = "ADMIN")]
[Route("medecins")]
public class MedecinsController(ApplicationModel application) : PersonnesControllerBase<Medecin>(application)
{
    protected override ConfigPersonnes<Medecin> Config => new(
        Url: "/medecins",
        Cles: new ClesPersonnes(
            Titre: "MEDECINS.TITLE",
            Ajouter: "MEDECINS.ADD",
            Modifier: "MEDECINS.EDIT",
            Vide: "MEDECINS.EMPTY",
            ConfirmerSuppression: "MEDECINS.DELETE_CONFIRM",
            Ajoute: "MEDECINS.ADDED",
            Modifie: "MEDECINS.UPDATED",
            Supprime: "MEDECINS.DELETED"),
        Lister: Application.GetAllMedecinsAsync,
        Trouver: Application.GetMedecinByIdAsync,
        Ajouter: Application.AjouterMedecinAsync,
        Modifier: Application.ModifierMedecinAsync,
        Supprimer: Application.SupprimerMedecinAsync);
}
  • lignes 20-22 : réservé à l'ADMIN. L'attribut posé sur la classe s'applique à toutes ses actions, héritées comprises. Le contrôleur hérite de [PersonnesControllerBase<Medecin>] : le type [T] de la classe mère devient [Medecin]. Son constructeur principal transmet [application] à celui de la classe mère. (En [TypeScript], il fallait écrire un constructeur explicite pour que [NestJS] sache quoi injecter : rien de tel ici) ;
  • lignes 24-34 : la configuration. [override] redéfinit la propriété abstraite de la classe mère ; [=>] en fait une propriété calculée, qui fabrique l'objet à chaque lecture. Les arguments nommés ([Url:], [Cles:]...) rendent la liste lisible ;
  • lignes 35-39 : les fonctions à appeler, qui sont des méthodes d'[ApplicationModel]. [Application.GetAllMedecinsAsync], écrit sans parenthèses, n'appelle pas la méthode : il la désigne, et le compilateur la convertit en une valeur du type [Func] attendu.

11.16.5. Controllers/ClientsController.cs

// =========================================================================
// Controllers/ClientsController.cs
// -------------------------------------------------------------------------
// Gestion des clients (les PATIENTS du cabinet), rôle ADMIN uniquement.
// Même principe que MedecinsController (voir ses commentaires).
// =========================================================================

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

namespace RdvMedecins.Controllers;

[Authorize(Roles = "ADMIN")]
[Route("clients")]
public class ClientsController(ApplicationModel application) : PersonnesControllerBase<Client>(application)
{
    protected override ConfigPersonnes<Client> Config => new(
        Url: "/clients",
        Cles: new ClesPersonnes(
            Titre: "CLIENTS.TITLE",
            Ajouter: "CLIENTS.ADD",
            Modifier: "CLIENTS.EDIT",
            Vide: "CLIENTS.EMPTY",
            ConfirmerSuppression: "CLIENTS.DELETE_CONFIRM",
            Ajoute: "CLIENTS.ADDED",
            Modifie: "CLIENTS.UPDATED",
            Supprime: "CLIENTS.DELETED"),
        Lister: Application.GetAllClientsAsync,
        Trouver: Application.GetClientByIdAsync,
        Ajouter: Application.AjouterClientAsync,
        Modifier: Application.ModifierClientAsync,
        Supprimer: Application.SupprimerClientAsync);
}

Le même contrôleur, pour les clients : autre route, autres clés de traduction, autres méthodes d'[ApplicationModel].

11.16.6. Controllers/InscriptionController.cs

// =========================================================================
// Controllers/InscriptionController.cs
// -------------------------------------------------------------------------
// "Créer un compte client" : un patient qui n'a pas encore de compte le
// crée lui-même. Routes NON protégées (on n'a pas encore de compte, par
// définition) :
//
//   POST /inscription/acces   bouton "Créer un compte client" de la page
//                             d'authentification : vérifie le captcha de
//                             CETTE page, puis ouvre le formulaire
//   GET  /inscription         le formulaire
//   POST /inscription         la création, puis retour à la page de connexion
//
// Le parcours est protégé contre les robots par UN captcha, celui de la page
// d'authentification :
//   1. le bouton "Créer un compte client" envoie le formulaire de connexion
//      vers /inscription/acces ; si le captcha est bien recopié, le serveur
//      délivre un laissez-passer (cf. Auth/PasseInscriptionService.cs) ;
//   2. sans laissez-passer valide, /inscription renvoie à la page
//      d'authentification - taper l'URL directement ne sert à rien ;
//   3. le laissez-passer est consommé à la création : un captcha résolu
//      permet de créer UN compte ;
//   4. en plus, le nombre de comptes créés par heure et par adresse IP est
//      limité (cf. Auth/LimiteurTentativesService.cs).
//
// Le compte créé est exactement celui que créerait l'ADMIN depuis la page
// "Clients" (même méthode de la couche métier : AjouterClientAsync), si ce
// n'est que le patient CHOISIT son login et son mot de passe (obligatoires,
// avec confirmation).
// =========================================================================

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

namespace RdvMedecins.Controllers;

[Route("inscription")]
public class InscriptionController(
    ApplicationModel application,
    CaptchaService captchaService,
    LimiteurTentativesService limiteur,
    PasseInscriptionService passes) : Controller
{
    // -----------------------------------------------------------------------
    // POST /inscription/acces   champs : captchaId, captcha (ceux de la page
    // d'authentification ; login et mot de passe, envoyés aussi, sont ignorés)
    // -----------------------------------------------------------------------
    [HttpPost("acces")]
    public IActionResult Acces([FromForm] string? captchaId, [FromForm] string? captcha)
    {
        var ip = AuthController.AdresseIp(HttpContext);

        // trop de comptes créés récemment depuis cette adresse : inutile d'aller plus loin
        var minutes = limiteur.MinutesBlocageInscription(ip);
        if (minutes > 0)
        {
            Flash.Deposer(HttpContext, TypeFlash.Danger, "ERRORS.TOO_MANY_ACCOUNTS", new() { ["minutes"] = minutes.ToString() });
            return Redirect("/login");
        }

        // captcha absent ou faux : retour à la page d'authentification, avec
        // une nouvelle image (un échec compte comme un échec de connexion)
        if (!captchaService.Verifier(captchaId, captcha))
        {
            limiteur.EchecLogin(ip, "");
            Flash.Deposer(HttpContext, TypeFlash.Danger, "ERRORS.CAPTCHA_BEFORE_ACCOUNT");
            return Redirect("/login");
        }

        // captcha correct : on délivre le laissez-passer et on ouvre le formulaire
        Response.Cookies.Append(PasseInscriptionService.Cookie, passes.Emettre(), PasseInscriptionService.OptionsCookie());
        return Redirect("/inscription");
    }

    // -----------------------------------------------------------------------
    // GET /inscription
    // -----------------------------------------------------------------------
    [HttpGet("")]
    public IActionResult Formulaire()
    {
        if (!ALaissezPasser())
        {
            return RetourLogin();
        }
        return Afficher(new ValeursPersonne());
    }

    // -----------------------------------------------------------------------
    // POST /inscription
    // Le paramètre [formulaire] est rempli et VALIDÉ par MVC avant l'appel :
    // le résultat de la validation est dans ModelState.
    // -----------------------------------------------------------------------
    [HttpPost("")]
    public async Task<IActionResult> Creer(FormulaireInscription formulaire)
    {
        if (!ALaissezPasser())
        {
            return RetourLogin();
        }
        var ip = AuthController.AdresseIp(HttpContext);
        var valeurs = new ValeursPersonne
        {
            Titre = formulaire.Titre ?? "Mr",
            Prenom = formulaire.Prenom ?? "",
            Nom = formulaire.Nom ?? "",
            Login = formulaire.Login ?? "",
        };

        // 1. trop de comptes créés récemment depuis cette adresse ?
        var minutes = limiteur.MinutesBlocageInscription(ip);
        if (minutes > 0)
        {
            return Afficher(valeurs, null, "ERRORS.TOO_MANY_ACCOUNTS", null, new Dictionary<string, string> { ["minutes"] = minutes.ToString() });
        }

        // 2. toutes les erreurs de saisie d'un coup (champs + confirmation) :
        //    l'utilisateur les corrige toutes en une fois
        var erreurs = Validation.ErreursDe(ModelState);
        if (!erreurs.ContainsKey("password") && !erreurs.ContainsKey("confirmation") && formulaire.Password != formulaire.Confirmation)
        {
            erreurs["confirmation"] = "ERRORS.PASSWORDS_DIFFER";
        }
        if (erreurs.Count > 0)
        {
            return Afficher(valeurs, erreurs);
        }

        // 3. la création (client + compte USER, dans une transaction)
        try
        {
            // "!" : on affirme au compilateur que ces valeurs ne sont pas null -
            // c'est vrai, puisque la validation ([Required]) a réussi
            await application.AjouterClientAsync(new DonneesPersonne
            {
                Titre = formulaire.Titre!,
                Nom = formulaire.Nom!,
                Prenom = formulaire.Prenom!,
                Login = formulaire.Login,
                Password = formulaire.Password,
            });
        }
        catch (MetierException e)
        {
            // login ou prénom + nom déjà pris : le laissez-passer reste valable
            return Afficher(valeurs, null, e.Cle, Validation.ChampsErreursMetier.GetValueOrDefault(e.Cle));
        }

        // 4. le laissez-passer a servi : il ne permettra pas de créer un 2e compte
        passes.Consommer(Request.Cookies[PasseInscriptionService.Cookie]);
        Response.Cookies.Delete(PasseInscriptionService.Cookie, new CookieOptions { Path = "/inscription" });
        limiteur.InscriptionReussie(ip);

        // 5. retour à la page de connexion, login pré-rempli
        Flash.Deposer(HttpContext, TypeFlash.Success, "INSCRIPTION.DONE", new() { ["login"] = formulaire.Login! });
        return Redirect($"/login?login={Uri.EscapeDataString(formulaire.Login!)}");
    }

    // ----------------------------------------------------------------- méthodes privées

    private bool ALaissezPasser() => passes.EstValide(Request.Cookies[PasseInscriptionService.Cookie]);

    // pas de laissez-passer valide : retour à la page d'authentification
    private RedirectResult RetourLogin()
    {
        Flash.Deposer(HttpContext, TypeFlash.Danger, "ERRORS.CAPTCHA_BEFORE_ACCOUNT");
        return Redirect("/login");
    }

    // le formulaire ; jamais de mot de passe renvoyé au navigateur
    private ViewResult Afficher(
        ValeursPersonne valeurs,
        Dictionary<string, string>? erreurs = null,
        string? erreur = null,
        IEnumerable<string>? autresChamps = null,
        IReadOnlyDictionary<string, string>? erreurParams = null) =>
        View("Inscription", Validation.Preparer(
            new InscriptionModele { TitrePage = "INSCRIPTION.TITLE", Valeurs = valeurs },
            erreurs, erreur, autresChamps, erreurParams));
}

Commentons ce code :

  • lignes 14-24 : la protection contre les robots, en quatre points. Le captcha et le limiteur de tentatives ont été présentés à l'exemple 27 ;
  • lignes 40-45 : le contrôleur n'a pas d'attribut [[Authorize]] : ses pages sont ouvertes à tous. Il reçoit quatre services par injection ;
  • lignes 51-52 : le bouton Créer un compte client de la page de connexion envoie le formulaire de connexion à cette adresse (attribut [formaction], cf. exemple 20). Seuls les champs du captcha sont lus ;
  • ligne 54 : l'adresse IP du navigateur, par une méthode statique d'[AuthController] ;
  • lignes 57-61 : trop de comptes créés depuis cette adresse dans l'heure : retour à la page de connexion, avec un message flash qui donne le nombre de minutes d'attente. [new() { ["minutes"] = ... }] crée le dictionnaire des paramètres du message : son type est déduit du paramètre de [Flash.Deposer] ;
  • lignes 66-70 : un captcha absent ou faux compte comme un échec de connexion (avec un login vide) : un robot ne peut pas essayer indéfiniment. Le retour à la page de connexion affiche une nouvelle image ;
  • lignes 74-75 : le captcha est correct : un laissez-passer (cf. [Auth/PasseInscriptionService.cs]) est déposé dans un cookie, et le navigateur est redirigé vers le formulaire ;
  • lignes 81-88 : le formulaire, seulement avec un laissez-passer valide. Taper directement l'adresse [/inscription] ne sert à rien ;
  • lignes 96-97 : la création. Le formulaire [FormulaireInscription] (dossier [Models]) a été lié et validé par MVC ;
  • lignes 99-101 : le laissez-passer est vérifié de nouveau : la requête POST pourrait avoir été envoyée sans passer par le formulaire ;
  • lignes 104-109 : les valeurs à réafficher en cas d'erreur, sans le mot de passe. Le titre par défaut est [Mr] ;
  • lignes 113-116 : la limite horaire, vérifiée de nouveau au moment de la création ;
  • lignes 121-128 : les erreurs de saisie, champ par champ (cf. exemple 19). L'égalité du mot de passe et de sa confirmation, qui porte sur deux champs, est vérifiée ici, à la main ; elle n'est testée que si ces deux champs sont par ailleurs corrects. Toutes les erreurs sont signalées ensemble : l'utilisateur les corrige en une fois ;
  • lignes 136-148 : la création du client et de son compte USER, par la méthode de la couche métier qu'utilise l'ADMIN. Un login ou un couple prénom-nom déjà pris réaffiche le formulaire, les champs concernés encadrés en rouge ; le laissez-passer reste valable ;
  • lignes 152-154 : le laissez-passer a servi : il est consommé côté serveur et son cookie est effacé. Pour effacer un cookie, il faut donner le même chemin ([Path]) que lors de sa création : le cookie du laissez-passer n'est envoyé qu'aux adresses [/inscription...]. La création est enfin comptée pour la limite horaire ;
  • lignes 157-158 : retour à la page de connexion, login prérempli, avec un message de succès. [Uri.EscapeDataString] encode le login pour qu'il puisse figurer dans une URL ;
  • ligne 163 : le laissez-passer est lu dans le cookie de la requête ;
  • lignes 166-169 : sans laissez-passer, retour à la page de connexion avec un message ;
  • lignes 173-181 : le formulaire, préparé par [Validation.Preparer] comme celui des médecins et des clients. [erreurParams] sert aux paramètres du message du bas de page (le nombre de minutes).

11.16.7. Controllers/EspacePatientController.cs

// =========================================================================
// Controllers/EspacePatientController.cs
// -------------------------------------------------------------------------
// Les pages PERSONNELLES du patient (rôle USER), celle qu'il voit juste
// après s'être connecté :
//
//   GET  /mon-espace               page d'accueil : [Gérer mon compte],
//                                  [Voir un agenda], et son rendez-vous à venir
//   POST /mon-espace/annuler       annule SON rendez-vous
//   GET  /mon-compte               formulaire de modification de son compte
//   POST /mon-compte               modification
//   POST /mon-compte/supprimer     suppression du compte (et de ses rendez-vous)
//
// Règle d'or de ce contrôleur : le patient concerné est TOUJOURS celui du
// jeton (Patient.PatientConnecteAsync), jamais un identifiant envoyé par le
// navigateur. Un patient ne peut donc ni voir, ni modifier, ni annuler ce qui
// appartient à un autre patient, même en fabriquant lui-même ses requêtes.
//
// Toute modification ou suppression du compte exige le mot de passe
// ACTUEL : quelqu'un qui trouverait une session restée ouverte ne pourrait
// ni changer le mot de passe, ni supprimer le compte. Les erreurs de mot de
// passe actuel sont comptées comme des échecs de connexion (même limite).
// =========================================================================

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

namespace RdvMedecins.Controllers;

[Authorize(Roles = "USER")]
public class EspacePatientController(
    ApplicationModel application,
    AuthService authService,
    LimiteurTentativesService limiteur) : Controller
{
    // -----------------------------------------------------------------------
    // GET /mon-espace
    // -----------------------------------------------------------------------
    [HttpGet("mon-espace")]
    public async Task<IActionResult> Espace()
    {
        var patient = await Patient.PatientConnecteAsync(application, User);
        var rendezVous = await application.GetRvAVenirClientAsync(patient.Id);
        return View("Espace", new EspaceModele
        {
            TitrePage = "ESPACE.TITLE",
            Patient = Libelles.Libelle(patient),
            RendezVous = rendezVous.Select(rv => new RendezVousVue(
                rv.Id,
                DatesWeb.FormaterJour(rv.Jour),
                DatesWeb.Horaire(rv.Creneau),
                Libelles.Libelle(rv.Creneau.Medecin),
                // lien vers l'agenda du médecin ce jour-là : le patient y voit son nom
                $"/agenda?idMedecin={rv.Creneau.Medecin.Id}&jour={DatesWeb.Iso(rv.Jour)}")).ToList(),
        });
    }

    // -----------------------------------------------------------------------
    // POST /mon-espace/annuler   champ : idRv
    // -----------------------------------------------------------------------
    [HttpPost("mon-espace/annuler")]
    public async Task<IActionResult> AnnulerRv([FromForm] string? idRv)
    {
        var patient = await Patient.PatientConnecteAsync(application, User);
        var rv = Saisie.EntierPositif(idRv) is int id ? await application.GetRvByIdAsync(id) : null;
        // un rendez-vous qui n'existe pas, et celui d'un AUTRE patient, reçoivent
        // la même réponse : on ne révèle rien sur les rendez-vous des autres
        if (rv is null || rv.IdClient != patient.Id)
        {
            Flash.Deposer(HttpContext, TypeFlash.Danger, "ERRORS.APPOINTMENT_NOT_FOUND");
        }
        else
        {
            await application.SupprimerRvAsync(rv);
            Flash.Deposer(HttpContext, TypeFlash.Success, "AGENDA.CANCELLED");
        }
        return Redirect("/mon-espace");
    }

    // -----------------------------------------------------------------------
    // GET /mon-compte
    // -----------------------------------------------------------------------
    [HttpGet("mon-compte")]
    public async Task<IActionResult> FormulaireCompte()
    {
        var patient = await Patient.PatientConnecteAsync(application, User);
        return Afficher(new ValeursPersonne
        {
            Titre = patient.Titre,
            Prenom = patient.Prenom,
            Nom = patient.Nom,
            Login = patient.User.Login,
            Version = patient.Version,
        });
    }

    // -----------------------------------------------------------------------
    // POST /mon-compte
    // -----------------------------------------------------------------------
    [HttpPost("mon-compte")]
    public async Task<IActionResult> ModifierCompte(FormulaireMonCompte formulaire)
    {
        var patient = await Patient.PatientConnecteAsync(application, User);
        var valeurs = new ValeursPersonne
        {
            Titre = formulaire.Titre ?? patient.Titre,
            Prenom = formulaire.Prenom ?? "",
            Nom = formulaire.Nom ?? "",
            Login = formulaire.Login ?? "",
            Version = formulaire.Version,
        };

        var erreurs = Validation.ErreursDe(ModelState);
        if (!erreurs.ContainsKey("password") && formulaire.Password is not null && formulaire.Password != formulaire.Confirmation)
        {
            erreurs["confirmation"] = "ERRORS.PASSWORDS_DIFFER";
        }
        if (erreurs.Count > 0)
        {
            return Afficher(valeurs, erreurs);
        }

        // le mot de passe actuel
        var erreurMotDePasse = await VerifierMotDePasseActuelAsync(patient, formulaire.MotDePasseActuel);
        if (erreurMotDePasse is not null)
        {
            var (cle, parametres) = erreurMotDePasse.Value;
            return Afficher(
                valeurs,
                cle == "ERRORS.WRONG_CURRENT_PASSWORD" ? new(StringComparer.OrdinalIgnoreCase) { ["motDePasseActuel"] = cle } : null,
                cle,
                null,
                parametres);
        }

        try
        {
            var client = await application.ModifierClientAsync(patient.Id, new DonneesPersonne
            {
                Titre = formulaire.Titre!,
                Nom = formulaire.Nom!,
                Prenom = formulaire.Prenom!,
                Login = formulaire.Login,
                Password = formulaire.Password, // null = inchangé
                Version = formulaire.Version,
            });
            // le login et le nom affiché sont DANS le jeton : on en délivre un
            // nouveau, sinon la barre de navigation garderait l'ancien nom
            authService.DeposerJeton(Response, client.User);
            Flash.Deposer(HttpContext, TypeFlash.Success, "MON_COMPTE.UPDATED");
            return Redirect("/mon-espace");
        }
        catch (MetierException e)
        {
            return Afficher(valeurs, null, e.Cle, Validation.ChampsErreursMetier.GetValueOrDefault(e.Cle));
        }
    }

    // -----------------------------------------------------------------------
    // POST /mon-compte/supprimer   champ : motDePasseSuppression
    // Le compte, le client et TOUS ses rendez-vous disparaissent ensemble,
    // puis le patient est déconnecté.
    // -----------------------------------------------------------------------
    [HttpPost("mon-compte/supprimer")]
    public async Task<IActionResult> SupprimerCompte([FromForm] string? motDePasseSuppression)
    {
        var patient = await Patient.PatientConnecteAsync(application, User);
        var erreurMotDePasse = await VerifierMotDePasseActuelAsync(patient, motDePasseSuppression);
        if (erreurMotDePasse is not null)
        {
            var (cle, parametres) = erreurMotDePasse.Value;
            Flash.Deposer(HttpContext, TypeFlash.Danger, cle, parametres);
            return Redirect("/mon-compte");
        }
        await application.SupprimerClientAsync(patient.Id);
        AuthService.EffacerJeton(Response);
        Flash.Deposer(HttpContext, TypeFlash.Success, "MON_COMPTE.DELETED");
        return Redirect("/login");
    }

    // ----------------------------------------------------------------- méthodes privées

    // null si le mot de passe actuel est correct ; sinon la clé du message
    // (et ses paramètres). Le type de retour est un "tuple" nullable.
    private async Task<(string Cle, Dictionary<string, string>? Parametres)?> VerifierMotDePasseActuelAsync(
        Client patient, string? motDePasse)
    {
        var ip = AuthController.AdresseIp(HttpContext);
        var login = patient.User.Login;
        var minutes = limiteur.MinutesBlocageLogin(ip, login);
        if (minutes > 0)
        {
            return ("ERRORS.TOO_MANY_ATTEMPTS", new Dictionary<string, string> { ["minutes"] = minutes.ToString() });
        }
        var ok = !string.IsNullOrEmpty(motDePasse) && await authService.ValiderUtilisateurAsync(login, motDePasse) is not null;
        if (!ok)
        {
            limiteur.EchecLogin(ip, login);
            return ("ERRORS.WRONG_CURRENT_PASSWORD", null);
        }
        return null;
    }

    private ViewResult Afficher(
        ValeursPersonne valeurs,
        Dictionary<string, string>? erreurs = null,
        string? erreur = null,
        IEnumerable<string>? autresChamps = null,
        IReadOnlyDictionary<string, string>? erreurParams = null) =>
        View("Compte", Validation.Preparer(
            new MonCompteModele { TitrePage = "MON_COMPTE.TITLE", Valeurs = valeurs },
            erreurs, erreur, autresChamps, erreurParams));
}

Commentons ce code :

  • lignes 14-17 : la règle d'or de ce contrôleur : le patient concerné est toujours celui du jeton, jamais un identifiant envoyé par le navigateur ;
  • lignes 35-39 : réservé aux patients. Le contrôleur n'a pas d'attribut [[Route]] : chaque action donne son adresse complète, car elles ne partagent pas de préfixe ([/mon-espace], [/mon-compte]) ;
  • lignes 44-59 : la page « Mon espace ». [Patient.PatientConnecteAsync] retrouve le client à partir du jeton ; si son compte a été supprimé depuis la connexion, elle lève une [SessionInvalideException], que le filtre [Web/HtmlExceptionFilter.cs] transforme en retour à la page de connexion. La date est formatée par [DatesWeb.FormaterJour] selon la langue de la requête, fixée par le middleware de localisation (cf. exemple 22) ; chaque rendez-vous porte le lien vers l'agenda du médecin ce jour-là ;
  • lignes 66-82 : l'annulation de son rendez-vous. [condition ? valeur1 : valeur2] donne ici le rendez-vous, ou [null] si l'identifiant est invalide. Un rendez-vous inexistant et celui d'un autre patient (ligne 73) reçoivent la même réponse : on ne révèle rien sur les rendez-vous des autres ;
  • lignes 88-98 : le formulaire du compte, prérempli, avec le numéro de version (verrou optimiste) ;
  • lignes 105-106 : la modification, en trois temps : validation, vérification du mot de passe actuel, couche métier ;
  • lignes 109-115 : les valeurs à réafficher en cas d'erreur, jamais les mots de passe ;
  • lignes 118-125 : les erreurs de saisie. Le nouveau mot de passe est facultatif (vide : inchangé) : sa confirmation n'est comparée que s'il a été saisi ;
  • lignes 129-138 : le mot de passe actuel. [var (cle, parametres) = ...] déconstruit le couple rendu par la vérification. Un mauvais mot de passe est signalé sous son champ et en bas de page ; un blocage (trop de tentatives) seulement en bas de page, avec le nombre de minutes ;
  • lignes 143-150 : la modification par la couche métier, qui applique les mêmes règles que pour l'ADMIN (unicité, verrou optimiste) ;
  • ligne 154 : le jeton contient le login et le nom affiché : après une modification, on délivre un nouveau jeton, sans quoi la barre de navigation garderait l'ancien nom ;
  • lignes 158-160 : un refus de la couche métier réaffiche le formulaire, les champs concernés encadrés en rouge ;
  • lignes 169-183 : la suppression du compte, exigeant elle aussi le mot de passe actuel. Une erreur est signalée par un message flash sur la page du compte (PRG) ;
  • lignes 180-181 : la suppression de la fiche, du compte et de tous les rendez-vous (la méthode de la couche métier qu'utilise l'ADMIN), puis la déconnexion : le cookie du jeton est effacé ;
  • lignes 190-191 : la vérification du mot de passe actuel. Elle rend [null] si le mot de passe est correct, sinon un tuple : un couple de valeurs nommées (la clé du message et ses paramètres). Le [?] final rend ce tuple « nullable » ;
  • lignes 195-198 : le mot de passe actuel est soumis au même limiteur que la connexion (cf. exemple 27). Quelqu'un qui trouverait une session restée ouverte ne pourrait ni changer le mot de passe, ni supprimer le compte, ni essayer indéfiniment des mots de passe ;
  • lignes 200-204 : la vérification elle-même, confiée à [AuthService] ; un échec est compté comme un échec de connexion ;
  • lignes 209-217 : le formulaire du compte.

11.16.8. Controllers/ErreurController.cs

// =========================================================================
// Controllers/ErreurController.cs
// -------------------------------------------------------------------------
// La page d'erreur (400, 403, 404, 500).
//
// On n'y arrive jamais par un lien : ce sont les middlewares
// UseStatusCodePagesWithReExecute("/erreur/{0}") et
// UseExceptionHandler("/erreur/500") (cf. Program.cs) qui RÉ-EXÉCUTENT la
// requête en échec avec cette adresse, pour fabriquer la page à renvoyer à la
// place d'une réponse vide ou d'une exception.
//
// [Route] sans [HttpGet] : l'action accepte toutes les méthodes HTTP - la
// requête ré-exécutée garde sa méthode d'origine (un POST refusé reste un POST).
// =========================================================================

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

namespace RdvMedecins.Controllers;

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

    [Route("erreur/{code:int}")]
    public IActionResult Afficher(int code)
    {
        // la page garde le code d'erreur d'origine (404 reste 404...) ; un code
        // inattendu devient une erreur 500
        if (code < 400 || code > 599)
        {
            code = StatusCodes.Status500InternalServerError;
        }
        Response.StatusCode = code;
        return View("Erreur", new ErreurModele
        {
            TitrePage = "ERROR_PAGE.TITLE",
            Code = code,
            Message = Messages.GetValueOrDefault(code, "ERRORS.SERVER_ERROR"),
        });
    }
}

C'est le contrôleur de l'exemple 24, avec des messages traduits :

  • lignes 23-28 : la clé du message de chaque code d'erreur. [static readonly] : un seul dictionnaire, créé au chargement de la classe et jamais remplacé ;
  • lignes 30-31 : [[Route]], sans méthode HTTP, accepte toutes les méthodes : une requête POST ré-exécutée (un patient qui tente une annulation réservée aux gestionnaires, par exemple) reste une requête POST ;
  • lignes 35-37 : un code hors des erreurs HTTP (une adresse [/erreur/200] tapée à la main) devient une erreur 500 ;
  • ligne 39 : la page garde le code d'erreur d'origine ;
  • lignes 40-44 : le modèle de la page. [GetValueOrDefault] rend la clé associée au code, ou [ERRORS.SERVER_ERROR] pour tout autre code (500 en particulier).

11.17. Les vues

Les vues n'ont presque rien à décider : leur modèle a été préparé par les contrôleurs, dans les classes du fichier [Models/ModelesVues.cs] (quels boutons afficher, quelles URL, quelles clés de traduction). Chaque vue déclare le type de son modèle ([@model], cf. exemple 13) : une faute de frappe dans [@Model.Xxx] est une erreur de compilation. Chaque vue commence aussi par un commentaire [Razor] [@* ... *@] qui décrit son rôle ; ces commentaires n'apparaissent pas dans les pages envoyées au navigateur.

Une différence avec les exemples du chapitre 4 : l'étude de cas n'utilise pas les Tag Helpers. Les vues partielles sont insérées par [@await Html.PartialAsync("_Nom")], l'équivalent de la balise [<partial name="_Nom">] de l'exemple 15. Un second argument, facultatif, fournit le modèle de la vue partielle ; sans lui, elle reçoit le modèle de la page. La méthode est asynchrone (la vue partielle peut avoir à être compilée ou lue sur le disque) : [await] attend son résultat.

11.17.1. Le gabarit et les vues partielles

11.17.1.1. Views/_ViewImports.cshtml

@* =========================================================================
   Views/_ViewImports.cshtml
   -------------------------------------------------------------------------
   Lu avant CHAQUE vue de ce dossier et de ses sous-dossiers : les "using"
   communs, et la classe mère de toutes les vues (Web/PageVue.cs), qui leur
   apporte @T("CLE"), Utilisateur, MessageFlash...
   TModel désigne le type du modèle de chaque vue (@model ...).
   ========================================================================= *@
@using RdvMedecins
@using RdvMedecins.Models
@using RdvMedecins.Web
@inherits RdvMedecins.Web.PageVue<TModel>
  • lignes 9-11 : les espaces de noms dont les vues utilisent les types : les modèles des vues ([RdvMedecins.Models]), les messages flash ([RdvMedecins.Web]) ;
  • ligne 12 : la classe mère de toutes les vues, [Web/PageVue.cs], comme dans l'exemple 22. Elle leur apporte la traduction [@T("CLE")], la langue [Langue], l'utilisateur connecté [Utilisateur], le message flash [MessageFlash], le chemin demandé [Chemin] et la méthode [Actif] (menu actif de la barre de navigation). Dans la version [NestJS], ces informations passaient par [res.locals]. Il n'y a pas de directive [@addTagHelper] : l'application n'utilise pas de Tag Helpers.

11.17.1.2. Views/_ViewStart.cshtml

@* =========================================================================
   Views/_ViewStart.cshtml
   -------------------------------------------------------------------------
   Exécuté avant chaque vue : toutes les pages utilisent le gabarit commun
   Views/Shared/_Layout.cshtml (hbs le faisait automatiquement pour
   views/layout.hbs ; ici, on le dit explicitement).
   ========================================================================= *@
@{
    Layout = "_Layout";
}
  • ligne 9 : toutes les pages utilisent le gabarit [Views/Shared/_Layout.cshtml] (cf. exemple 15). [Handlebars] utilisait automatiquement [views/layout.hbs] ; [Razor] demande qu'on le dise.

11.17.1.3. Views/Shared/_Layout.cshtml

@* =========================================================================
   Views/Shared/_Layout.cshtml
   -------------------------------------------------------------------------
   Le gabarit COMMUN à toutes les pages. La page demandée par le contrôleur
   (Agenda.cshtml, Login.cshtml...) est insérée à l'emplacement
   @RenderBody().

   Razor ÉCHAPPE tout ce qu'il affiche avec @ (les caractères < > & " '
   deviennent &lt; &gt;...) : protection contre l'injection de HTML via un
   nom saisi, par exemple. Pour insérer du HTML tel quel, il faut le demander
   explicitement (@Html.Raw, cf. _Captcha.cshtml).

   Messages : un succès s'affiche en HAUT de la page (_Flash), une erreur en
   BAS, sur fond rose léger (_Erreurs).

   Le gabarit partage le modèle de la page : s'il s'agit d'un ModeleVue (cf.
   Models/ModelesVues.cs), on y lit le titre et l'erreur de la page.
   ========================================================================= *@
@{
    var page = ViewData.Model as ModeleVue;
}
<!doctype html>
<html lang="@Langue">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>@T(page?.TitrePage ?? "APP.TITLE") - @T("APP.TITLE")</title>
  <link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css">
  <link rel="stylesheet" href="/css/styles.css">
</head>
<body>
  <div class="container py-4">
    @await Html.PartialAsync("_Navbar")
    @await Html.PartialAsync("_Flash")
    @RenderBody()
    @await Html.PartialAsync("_Erreurs")
  </div>
  @* la fenêtre "Êtes-vous sûr ?", cachée tant qu'on n'en a pas besoin *@
  @await Html.PartialAsync("_Confirmation")
  <script src="/js/app.js"></script>
</body>
</html>
  • ligne 20 : le gabarit partage le modèle de la page, accessible par [ViewData.Model] (de type [object]). Tous les modèles de pages dérivent de la classe abstraite [ModeleVue], qui porte la clé du titre ([TitrePage]) et l'erreur de la page ([Erreur]). [as ModeleVue] rend [null] si le modèle n'est pas un [ModeleVue] : le gabarit fonctionne avec n'importe quelle page ;
  • ligne 23 : la langue de la page (cf. exemple 22) ;
  • ligne 27 : le titre, traduit, par exemple « Agenda - RdvMedecins ». Contrairement à l'exemple 22, la clé n'est pas déposée par la vue dans [ViewData] : elle est fournie par le contrôleur, dans le modèle typé. [page?.TitrePage] vaut [null] si [page] est [null], et l'opérateur [??] remplace alors la clé par [APP.TITLE] ;
  • lignes 28-29 : les feuilles de style, servies par [UseStaticFiles] depuis le dossier [wwwroot] : [Bootstrap] ([wwwroot/lib/bootstrap]) et la feuille propre à l'application ;
  • lignes 33-36 : la barre de navigation, le message de succès (en haut), la page demandée ([@RenderBody()]), la zone d'erreur (en bas) ;
  • lignes 39-40 : la fenêtre de confirmation, cachée, et son script (cf. exemple 21), chargé en fin de page.

11.17.1.4. Views/Shared/_Navbar.cshtml

@* =========================================================================
   Views/Shared/_Navbar.cshtml - la barre de navigation.

   Titre, FR/EN, rôle, nom, déconnexion - et un menu dont les entrées
   dépendent du rôle : "Médecins" et "Clients" n'apparaissent que pour
   l'ADMIN. Ce n'est qu'un confort d'affichage : la vraie protection, ce sont
   les [Authorize(Roles = ...)] des contrôleurs (un USER qui tape /medecins à
   la main obtient une page 403). Un patient (USER) a en plus son menu
   "Mon espace".
   ========================================================================= *@
@{
    var utilisateur = Utilisateur;
}
<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 (utilisateur is not null)
  {
    <ul class="navbar-nav me-auto">
      @if (utilisateur.EstPatient)
      {
        <li class="nav-item"><a class="nav-link @Actif("/mon-")" href="/mon-espace">@T("APP.MENU_MY_SPACE")</a></li>
      }
      <li class="nav-item"><a class="nav-link @Actif("/agenda")" href="/agenda">@T("APP.MENU_AGENDA")</a></li>
      @if (utilisateur.EstAdmin)
      {
        <li class="nav-item"><a class="nav-link @Actif("/medecins")" href="/medecins">@T("APP.MENU_DOCTORS")</a></li>
        <li class="nav-item"><a class="nav-link @Actif("/clients")" href="/clients">@T("APP.MENU_PATIENTS")</a></li>
      }
    </ul>
  }
  else
  {
    <div class="me-auto"></div>
  }

  <div class="d-flex align-items-center gap-2">
    @* FR / EN : de simples liens (GET /langue/fr), la langue active en surbrillance *@
    <div class="btn-group btn-group-sm" role="group" aria-label="FR / EN">
      <a class="btn @(Langue == "fr" ? "btn-light" : "btn-outline-light")" href="/langue/fr">FR</a>
      <a class="btn @(Langue == "en" ? "btn-light" : "btn-outline-light")" href="/langue/en">EN</a>
    </div>

    @if (utilisateur is not null)
    {
      <span class="badge text-bg-light text-primary">@utilisateur.Role</span>
      <span class="text-white">@utilisateur.Nom</span>
      @* la déconnexion est un POST : un formulaire réduit à son bouton *@
      <form method="post" action="/logout" class="m-0">
        <button type="submit" class="btn btn-sm btn-outline-light">@T("APP.LOGOUT")</button>
      </form>
    }
  </div>
</nav>
  • ligne 12 : l'utilisateur connecté, tel que le décrit la classe [UtilisateurVue] de [Web/PageVue.cs] (login, nom, rôle, [EstAdmin], [EstPatient]), tiré du jeton JWT. Il vaut [null] sur les pages publiques (connexion, création de compte) ;
  • lignes 17-31 : les menus dépendent du rôle : « Mon espace » pour un patient, « Médecins » et « Clients » pour l'ADMIN. Ce n'est qu'un confort : la protection est assurée par les attributs [[Authorize(Roles = ...)]] des contrôleurs (un patient qui tape [/medecins] à la main obtient une page 403). [Actif("/medecins")] rend [active] si le chemin demandé commence par [/medecins] : [Bootstrap] met alors ce menu en évidence. Le préfixe [/mon-] couvre les deux pages du patient, [/mon-espace] et [/mon-compte] ;
  • ligne 34 : sans menus, un bloc vide occupe la place ([me-auto] : marge droite automatique), pour que la suite reste à droite de la barre ;
  • lignes 40-41 : les liens FR / EN de l'exemple 22. L'expression [@( ... )] est entre parenthèses parce qu'elle contient des espaces (cf. exemple 14) ;
  • lignes 44-52 : le rôle, le nom, et le bouton de déconnexion, dans un formulaire POST (cf. exemple 21).

11.17.1.5. Views/Shared/_Flash.cshtml

@* =========================================================================
   Views/Shared/_Flash.cshtml - le message de SUCCÈS déposé par l'action
   précédente (rendez-vous enregistré, médecin ajouté...), affiché une seule
   fois, en haut de la page (cf. Web/Flash.cs).
   Un message flash d'ERREUR, lui, est affiché en bas de la page, par
   _Erreurs.cshtml.
   ========================================================================= *@
@{
    var flash = MessageFlash;
}
@if (flash is { Type: TypeFlash.Success })
{
  <div class="alert alert-success" role="status">
    @T(flash.Cle, flash.Parametres)
  </div>
}
  • ligne 9 : le message flash déposé par l'action précédente (cf. exemple 20 et [Web/Flash.cs]). Lire un message flash l'efface ; comme la page le lit deux fois (ici et dans [_Erreurs.cshtml]), la classe [Flash] le garde, une fois lu, dans [HttpContext.Items] (cf. chapitre 6) ;
  • ligne 11 : [flash is { Type: TypeFlash.Success }] est un motif de propriétés : la condition est vraie si [flash] n'est pas [null] et si sa propriété [Type] vaut [TypeFlash.Success]. Seul un message de succès s'affiche ici ;
  • ligne 14 : le message est une clé ; ses paramètres éventuels ([nom], [login], [minutes]) sont un dictionnaire, passé à la seconde forme de la méthode [T]. [role="status"] : un lecteur d'écran annonce le message sans interrompre l'utilisateur.

11.17.1.6. Views/Shared/_Erreurs.cshtml

@* =========================================================================
   Views/Shared/_Erreurs.cshtml - la zone d'erreur, en BAS de chaque page,
   sur fond rose léger (classe .zone-erreur, cf. wwwroot/css/styles.css).

   Elle regroupe les deux sources d'erreur possibles :
     - Erreur : l'erreur de la page affichée (formulaire refusé, login ou
       prénom + nom déjà utilisés, date invalide, mot de passe incorrect...) ;
     - un message flash d'erreur, déposé par l'action précédente avant une
       redirection (créneau déjà réservé, suppression refusée...).

   role="alert" : un lecteur d'écran annonce le message dès l'affichage de
   la page, même s'il se trouve en bas.
   ========================================================================= *@
@{
    var page = ViewData.Model as ModeleVue;
    var erreur = page?.Erreur;
    var flash = MessageFlash;
    var flashErreur = flash is { Type: TypeFlash.Danger } ? flash : null;
}
@if (erreur is not null || flashErreur is not null)
{
  <div class="zone-erreur" role="alert">
    @if (erreur is not null)
    {
      <div>@T(erreur, page!.ErreurParams)</div>
    }
    @if (flashErreur is not null)
    {
      <div>@T(flashErreur.Cle, flashErreur.Parametres)</div>
    }
  </div>
}
  • lignes 15-18 : les deux sources d'erreur : l'erreur de la page affichée (propriété [Erreur] du modèle : formulaire refusé, mot de passe incorrect...), et un message flash d'erreur, déposé avant une redirection (créneau déjà réservé, suppression refusée...) ;
  • lignes 20-22 : la zone s'affiche s'il y a l'une ou l'autre. [role="alert"] : un lecteur d'écran annonce le message dès l'affichage de la page, bien qu'il soit en bas. La classe [zone-erreur] est définie dans [wwwroot/css/styles.css] ;
  • ligne 25 : l'opérateur [!] affirme au compilateur que [page] n'est pas [null] : il ne peut pas le déduire seul, alors que c'est certain puisque [erreur], lue dans [page], ne l'est pas.

11.17.1.7. Views/Shared/_Confirmation.cshtml

@* =========================================================================
   Views/Shared/_Confirmation.cshtml - la fenêtre modale "Êtes-vous sûr ?".

   Elle remplace la boîte window.confirm() du navigateur, qu'on ne peut ni
   mettre en forme, ni traduire (ses boutons restent dans la langue du
   navigateur, pas dans celle de la page).

   Une SEULE fenêtre pour toute la page, cachée (d-none) au départ. Ses
   libellés sont traduits ici, par le serveur, comme le reste de la page ;
   le petit script wwwroot/js/app.js se contente de l'afficher, d'y placer
   le message propre au formulaire (attribut data-confirmation), et d'envoyer
   le formulaire si l'utilisateur confirme.

   Mêmes classes Bootstrap que la fenêtre de réservation (Agenda/_Reservation.cshtml).
   ========================================================================= *@
<div id="modale-confirmation" 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="modale-confirmation-titre" aria-describedby="modale-confirmation-message">
    <div class="modal-dialog modal-dialog-centered">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title" id="modale-confirmation-titre">@T("CONFIRMATION.TITLE")</h5>
          <button type="button" class="btn-close" data-action="non" aria-label="@T("BOOKING_DIALOG.CLOSE_ARIA")"></button>
        </div>
        <div class="modal-body">
          <p class="mb-0" id="modale-confirmation-message"></p>
        </div>
        <div class="modal-footer">
          @* "Non, revenir" plutôt qu'"Annuler" : la question est parfois
             « Annuler ce rendez-vous ? », et un bouton "Annuler" y serait ambigu *@
          <button type="button" class="btn btn-secondary" data-action="non">@T("CONFIRMATION.NO")</button>
          <button type="button" class="btn btn-danger" data-action="oui">@T("CONFIRMATION.YES")</button>
        </div>
      </div>
    </div>
  </div>
</div>

La fenêtre de l'exemple 21, avec des libellés traduits par le serveur, comme le reste de la page, et des attributs d'accessibilité :

  • ligne 16 : une seule fenêtre pour toute la page, cachée au départ ([d-none]) ;
  • lignes 18-19 : [role="alertdialog"] annonce une fenêtre qui attend une réponse ; [aria-modal="true"] indique que le reste de la page est inactif ; [aria-labelledby] et [aria-describedby] désignent le titre et le message que lit un lecteur d'écran ;
  • ligne 24 : la croix de fermeture, qui vaut « Non » ([data-action="non"]). Elle n'a pas de texte : [aria-label] lui donne un nom pour les lecteurs d'écran ;
  • lignes 32-33 : « Non, revenir » plutôt qu'« Annuler » : la question est parfois « Annuler ce rendez-vous ? », et un bouton « Annuler » y serait ambigu.

11.17.1.8. Views/Auth/_Captcha.cshtml

@* =========================================================================
   Views/Auth/_Captcha.cshtml - le captcha de la page d'authentification
   (cf. Auth/CaptchaService.cs). Il protège à la fois la connexion et
   l'accès à la création de compte (bouton "Créer un compte client").

   @Html.Raw(...) : l'image SVG est insérée TELLE QUELLE dans la page, sans
   échappement. C'est sans danger ici parce que ce SVG est fabriqué par le
   serveur lui-même et ne contient rien qui vienne de l'utilisateur - on ne
   ferait JAMAIS cela avec une saisie.

   Le champ caché captchaId dit au serveur QUEL captcha vérifier ; la bonne
   réponse, elle, n'est jamais envoyée au navigateur.
   "Autre image" recharge simplement la page (chemin = l'URL courante).
   ========================================================================= *@
@model RdvMedecins.Auth.Captcha
<div class="mb-3">
  <label class="form-label" for="captcha">@T("CAPTCHA.LABEL")</label>
  <div class="d-flex align-items-center gap-3 mb-2">
    <div class="captcha-image" role="img" aria-label="@T("CAPTCHA.IMAGE_ALT")">@Html.Raw(Model.Svg)</div>
    <a class="small" href="@Chemin">@T("CAPTCHA.NEW_IMAGE")</a>
  </div>
  <input type="hidden" name="captchaId" value="@Model.Id">
  <input id="captcha" name="captcha" type="text" maxlength="10" autocomplete="off" autocapitalize="off" spellcheck="false" required
         class="form-control">
  <div class="form-text">@T("CAPTCHA.HELP")</div>
</div>
  • ligne 15 : la vue partielle a son propre modèle, le captcha fabriqué par [Auth/CaptchaService.cs] (cf. exemple 27) : son identifiant et son image ;
  • ligne 19 : l'image SVG est insérée telle quelle (cf. exemple 14) : elle est fabriquée par le serveur, sans rien qui vienne de l'utilisateur. [role="img"] et [aria-label] en font une image pour les lecteurs d'écran ;
  • ligne 20 : « Autre image » recharge simplement la page courante : un nouveau captcha est tiré ;
  • ligne 22 : l'identifiant du captcha, dans un champ caché : il dit au serveur quel captcha vérifier. La bonne réponse, elle, ne quitte jamais le serveur ;
  • lignes 23-24 : la saisie. [autocomplete="off"], [autocapitalize="off"] et [spellcheck="false"] empêchent le navigateur de proposer, de mettre en majuscule ou de corriger les caractères recopiés.

11.17.1.9. Views/Shared/_ChampsPersonne.cshtml

@* =========================================================================
   Views/Shared/_ChampsPersonne.cshtml - les champs d'identité (titre,
   prénom, nom), communs à trois formulaires : médecin/client de l'ADMIN,
   "Créer un compte client" et "Gérer mon compte".
   Le partiel reçoit le modèle de la page qui l'insère (un FormulaireVue) :
   valeurs, message sous chaque champ, champs encadrés en rouge.

   selected="@selectionne" : un attribut booléen. Razor écrit selected="selected"
   si la valeur est true, et OMET l'attribut si elle est false.
   ========================================================================= *@
@model FormulaireVue
<div class="mb-3">
  <label class="form-label" for="titre">@T("PERSONNES.TITLE_LABEL")</label>
  <select id="titre" name="titre" class="form-select @Model.ClasseInvalide("titre")">
    @foreach (var (valeur, selectionne) in Model.Titres)
    {
      <option value="@valeur" selected="@selectionne">@valeur</option>
    }
  </select>
  @if (Model.ErreurDe("titre") is { } erreurTitre)
  {
    <div class="invalid-feedback">@T(erreurTitre)</div>
  }
</div>

<div class="row g-3 mb-3">
  <div class="col-sm-6">
    <label class="form-label" for="prenom">@T("PERSONNES.FIRSTNAME_LABEL")</label>
    <input id="prenom" name="prenom" type="text" maxlength="20" required
           class="form-control @Model.ClasseInvalide("prenom")" value="@Model.Valeurs.Prenom">
    @if (Model.ErreurDe("prenom") is { } erreurPrenom)
    {
      <div class="invalid-feedback">@T(erreurPrenom)</div>
    }
  </div>
  <div class="col-sm-6">
    <label class="form-label" for="nom">@T("PERSONNES.LASTNAME_LABEL")</label>
    <input id="nom" name="nom" type="text" maxlength="20" required
           class="form-control @Model.ClasseInvalide("nom")" value="@Model.Valeurs.Nom">
    @if (Model.ErreurDe("nom") is { } erreurNom)
    {
      <div class="invalid-feedback">@T(erreurNom)</div>
    }
  </div>
</div>

Les champs d'identité (titre, prénom, nom), communs à trois formulaires : médecin ou client de l'ADMIN, création d'un compte, gestion de son compte par le patient.

  • ligne 11 : [FormulaireVue] est la classe mère (abstraite) des modèles de ces trois formulaires. Elle porte les valeurs saisies ([Valeurs]), le message de chaque champ refusé ([Erreurs]) et les champs à encadrer en rouge ([ChampsInvalides]). La vue partielle accepte donc le modèle de chacune des trois pages ;
  • lignes 14-19 : la liste des titres. [Model.Titres] donne des tuples (valeur, sélectionnée ?) ; [var (valeur, selectionne)] déconstruit chaque tuple en deux variables. [selected="@selectionne"] est un attribut booléen, écrit seulement si la valeur est vraie (cf. exemple 17). [ClasseInvalide("titre")] rend [is-invalid] si le champ est à encadrer en rouge (cf. exemple 19) ;
  • lignes 20-23 : le message d'erreur du champ, s'il en a un. [ErreurDe] rend le message ou [null] ; le motif [is { } erreurTitre] est vrai si la valeur n'est pas [null], et la range alors dans la nouvelle variable [erreurTitre] ;
  • lignes 26-45 : le prénom et le nom, sur le même schéma, côte à côte sur un écran assez large (grille [Bootstrap] : [col-sm-6]). Les attributs [maxlength] et [required] ne sont qu'un confort : les formulaires qui insèrent cette vue partielle ont l'attribut [novalidate], et c'est le serveur qui valide.

11.17.1.10. Views/Agenda/_Reservation.cshtml

@* =========================================================================
   Views/Agenda/_Reservation.cshtml - la fenêtre de réservation.

   Même technique que le client Angular (booking-dialog) : les VRAIES classes
   Bootstrap d'une fenêtre modale (.modal, .modal-backdrop...), rendues
   visibles "en dur" (show d-block) plutôt que par le JavaScript de
   Bootstrap. La différence : c'est le SERVEUR qui décide de l'afficher
   (paramètre &reserver=... de l'URL), et la fermer revient à suivre un lien
   vers la même page sans ce paramètre.
   ========================================================================= *@
@model AgendaModele
@{
    var reservation = Model.Reservation!;
}
<div class="modal-backdrop fade show"></div>
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" aria-labelledby="titre-reservation">
  <div class="modal-dialog modal-dialog-centered">
    <form method="post" action="/agenda/reserver" class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title" id="titre-reservation">@T("BOOKING_DIALOG.TITLE")</h5>
        <a class="btn-close" href="@Model.UrlRetour" aria-label="@T("BOOKING_DIALOG.CLOSE_ARIA")"></a>
      </div>

      <div class="modal-body">
        <p>@T("BOOKING_DIALOG.SLOT", ("horaire", reservation.Horaire))</p>

        @* ce que le serveur doit savoir en plus du patient choisi *@
        <input type="hidden" name="idMedecin" value="@Model.IdMedecin">
        <input type="hidden" name="jour" value="@Model.Jour">
        <input type="hidden" name="idCreneau" value="@reservation.IdCreneau">

        @if (reservation.Patient is not null)
        {
          @* un patient réserve pour lui-même : pas de liste à choisir
             (et le serveur ignore de toute façon un éventuel idClient) *@
          <p class="mb-0">@T("BOOKING_DIALOG.PATIENT_LABEL") : <strong>@reservation.Patient</strong></p>
        }
        else
        {
          <label class="form-label" for="idClient">@T("BOOKING_DIALOG.PATIENT_LABEL")</label>
          <select id="idClient" name="idClient" class="form-select" required autofocus>
            <option value="">@T("BOOKING_DIALOG.CHOOSE_PATIENT")</option>
            @foreach (var client in reservation.Clients)
            {
              <option value="@client.Id">@client.Libelle</option>
            }
          </select>
        }
      </div>

      <div class="modal-footer">
        <a class="btn btn-secondary" href="@Model.UrlRetour">@T("BOOKING_DIALOG.CANCEL")</a>
        <button type="submit" class="btn btn-primary">@T("BOOKING_DIALOG.CONFIRM")</button>
      </div>
    </form>
  </div>
</div>
  • ligne 11 : la vue partielle reçoit le modèle de la page de l'agenda. [Model.Reservation!] : la page ne l'insère que si [Reservation] n'est pas [null], ce que l'opérateur [!] affirme au compilateur ;
  • lignes 15-16 : les classes [Bootstrap] d'une fenêtre modale et de son fond grisé, rendus visibles « en dur » ([show d-block]) : c'est le serveur qui décide d'afficher la fenêtre, quand l'URL contient le paramètre [reserver]. Le [JavaScript] de [Bootstrap] n'est pas utilisé (il n'est même pas chargé) ;
  • ligne 18 : le contenu de la fenêtre est lui-même le formulaire de réservation ;
  • ligne 21 : fermer la fenêtre, c'est suivre un lien vers le même agenda, sans le paramètre [reserver] ([UrlRetour], préparée par le contrôleur) ;
  • ligne 25 : « Créneau de 8:00 - 8:20 » : un paramètre de traduction ;
  • lignes 28-30 : les champs cachés : ce que le serveur doit savoir en plus du patient choisi ;
  • lignes 32-48 : un patient voit son nom ; un gestionnaire (ADMIN, DOCTOR) choisit le patient dans une liste. La première option a une valeur vide : avec [required], le navigateur exige qu'on choisisse un vrai patient. [autofocus] place le curseur dans la liste ;
  • lignes 52-53 : « Annuler » est un lien, comme la croix ; « Confirmer » envoie le formulaire.

11.17.2. Les pages

11.17.2.1. Views/Auth/Login.cshtml

@* =========================================================================
   Views/Auth/Login.cshtml - le formulaire de connexion.
   Un <form> HTML ordinaire : le navigateur envoie lui-même login, mot de
   passe et réponse au captcha en POST /login, sans JavaScript. En cas
   d'échec, le serveur renvoie CETTE page, avec le login déjà saisi, un
   NOUVEAU captcha et l'erreur, affichée en bas de page par le gabarit.

   @model : le type du modèle de la vue. Le compilateur vérifie chaque
   @Model.Xxx (une faute de frappe est une erreur de compilation).
   ========================================================================= *@
@model LoginModele
@{
    var loginSaisi = Model.Login != "";
}
<div class="row justify-content-center">
  <div class="col-sm-8 col-md-6 col-lg-5">
    <div class="card p-4">
      <h5 class="card-title mb-3">@T("LOGIN.TITLE")</h5>

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

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

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

        @* pas encore de compte : un patient peut en créer un lui-même.
           Ce n'est PAS un simple lien : c'est un second bouton d'envoi de
           CE formulaire, qui part vers une autre adresse (formaction). Le
           serveur y vérifie le captcha ci-dessus avant d'ouvrir la page de
           création de compte (cf. InscriptionController).
           formnovalidate : le navigateur n'exige pas login et mot de passe
           (inutiles pour créer un compte) ; c'est le serveur qui vérifie
           le captcha. Entrée dans un champ déclenche toujours le PREMIER
           bouton du formulaire, "Se connecter". *@
        <button type="submit" class="btn btn-outline-primary w-100 mt-3"
                formaction="/inscription/acces" 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>
  • ligne 13 : après un échec, le contrôleur renvoie cette page avec le login déjà saisi ;
  • ligne 20 : un formulaire HTML ordinaire : le navigateur envoie lui-même le login, le mot de passe et la réponse au captcha ;
  • lignes 23-29 : [autofocus] est un attribut booléen : un seul des deux champs le reçoit. Le curseur est placé dans le champ login, ou dans le champ mot de passe si le login est déjà connu. Les valeurs de [autocomplete] ([username], [current-password]) aident les gestionnaires de mots de passe des navigateurs ;
  • ligne 32 : le captcha ; le second argument fournit le modèle de la vue partielle ;
  • lignes 45-46 : le second bouton envoie le même formulaire à [/inscription/acces] (attribut [formaction], cf. exemple 20) : le serveur y vérifie le captcha avant d'ouvrir la page de création de compte. [formnovalidate] : le navigateur n'exige pas le login et le mot de passe, inutiles pour créer un compte. La touche Entrée, dans un champ, déclenche toujours le premier bouton du formulaire : « Se connecter » ;
  • lignes 51-56 : les comptes de démonstration (mot de passe = login).

11.17.2.2. Views/Agenda/Agenda.cshtml

@* =========================================================================
   Views/Agenda/Agenda.cshtml - la page principale (cf. AgendaController).
   Trois blocs, comme les trois composants du client Angular :
     1. le choix du médecin et du jour   (<app-doctor-day-picker>)
     2. l'agenda                         (<app-agenda>)
     3. la fenêtre de réservation        (<app-booking-dialog>)
   Une éventuelle erreur (date invalide, médecin inconnu) est affichée en
   bas de la page par le gabarit (cf. Views/Shared/_Erreurs.cshtml).
   ========================================================================= *@
@model AgendaModele

@* 1. choix du médecin et du jour : un formulaire GET, qui recharge
   simplement cette page avec ?idMedecin=...&jour=... dans l'URL *@
<form method="get" action="/agenda" class="card p-3 mb-3">
  <div class="row g-2 align-items-end">
    <div class="col-sm-5">
      <label class="form-label" for="idMedecin">@T("DOCTOR_DAY_PICKER.DOCTOR_LABEL")</label>
      <select id="idMedecin" name="idMedecin" class="form-select" required>
        <option value="">@T("DOCTOR_DAY_PICKER.CHOOSE_DOCTOR")</option>
        @foreach (var medecin in Model.Medecins)
        {
          <option value="@medecin.Id" selected="@medecin.Selectionne">@medecin.Libelle</option>
        }
      </select>
    </div>
    <div class="col-sm-4">
      <label class="form-label" for="jour">@T("DOCTOR_DAY_PICKER.DAY_LABEL")</label>
      <input id="jour" name="jour" type="date" class="form-control" value="@Model.Jour" required>
    </div>
    <div class="col-sm-3">
      <button type="submit" class="btn btn-primary w-100">@T("DOCTOR_DAY_PICKER.VIEW_AGENDA")</button>
    </div>
  </div>
</form>

@* patient qui a déjà un rendez-vous à venir : pourquoi il n'y a pas de bouton "Réserver" *@
@if (Model.Info is not null)
{
  <div class="alert alert-info">
    @T(Model.Info) <a href="/mon-espace" class="alert-link">@T("APP.MENU_MY_SPACE")</a>
  </div>
}

@* 2. l'agenda *@
@if (Model.Agenda is { } agenda)
{
  <div class="card p-3">
    <h5>@T("AGENDA.TITLE_PREFIX") @agenda.Jour - @agenda.Medecin</h5>

    @if (agenda.Lignes.Count > 0)
    {
      <table class="table table-hover align-middle">
        <thead>
          <tr>
            <th>@T("AGENDA.COLUMN_SLOT")</th>
            <th>@T("AGENDA.COLUMN_STATUS")</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          @foreach (var ligne in agenda.Lignes)
          {
            <tr class="@(ligne.Reservable ? "creneau-libre" : null)">
              <td>@ligne.Horaire</td>
              <td>
                @* [Patient] n'est renseigné que si l'utilisateur a le droit de
                   voir ce nom (ADMIN, DOCTOR, ou le patient pour SON
                   rendez-vous) - sinon, simplement « Réservé » *@
                @if (ligne.Libre)
                {
                  <span class="badge text-bg-success">@T("AGENDA.FREE")</span>
                }
                else if (ligne.Patient is not null)
                {
                  <span class="badge @(ligne.Moi ? "text-bg-primary" : "text-bg-secondary")">@ligne.Patient</span>
                }
                else
                {
                  <span class="badge text-bg-secondary">@T("AGENDA.BOOKED_BY_OTHER")</span>
                }
              </td>
              <td>
                @* les boutons affichés ont été décidés par le contrôleur
                   (Reservable, IdRv) selon le rôle de l'utilisateur *@
                @if (ligne.Reservable)
                {
                  @* "Réserver" est un simple lien : il rouvre cette page
                     avec la fenêtre de réservation de ce créneau *@
                  <a class="btn btn-sm btn-outline-success" href="@ligne.UrlReserver">@T("AGENDA.BOOK")</a>
                }
                @if (ligne.IdRv is not null)
                {
                  @* "Annuler" MODIFIE des données : c'est donc un POST *@
                  <form method="post" action="/agenda/annuler" class="m-0"
                        data-confirmation="@T("AGENDA.CANCEL_CONFIRM")">
                    <input type="hidden" name="idRv" value="@ligne.IdRv">
                    <input type="hidden" name="idMedecin" value="@Model.IdMedecin">
                    <input type="hidden" name="jour" value="@Model.Jour">
                    <button type="submit" class="btn btn-sm btn-outline-danger">@T("AGENDA.CANCEL_APPOINTMENT")</button>
                  </form>
                }
              </td>
            </tr>
          }
        </tbody>
      </table>
    }
    else
    {
      <p class="text-muted mb-0">@T("AGENDA.NO_SLOTS")</p>
    }
  </div>
}
else
{
  <p class="text-muted">@T("AGENDA.EMPTY_STATE")</p>
}

@* 3. la fenêtre de réservation, seulement si l'URL contient &reserver=... *@
@if (Model.Reservation is not null)
{
  @await Html.PartialAsync("_Reservation", Model)
}

Commentons ce code :

  • lignes 14-34 : le choix du médecin et du jour : un formulaire GET (cf. exemple 17), qui recharge la page avec [?idMedecin=...&jour=...] dans l'URL. Chaque médecin est un [OptionVue] qui dit s'il est sélectionné. Un champ [type="date"] envoie la date au format [aaaa-mm-jj] (cf. exemple 18) ;
  • lignes 37-42 : le message d'un patient qui a déjà un rendez-vous à venir : il explique l'absence de boutons « Réserver » ;
  • ligne 45 : l'agenda, s'il a été demandé. Le motif [is { } agenda] teste que la propriété n'est pas [null] et range sa valeur dans la variable [agenda] ;
  • ligne 48 : le titre : « Agenda du 2026-09-25 - Mme Marie PELISSIER » ;
  • ligne 63 : un créneau réservable change de couleur au survol (classe [creneau-libre], cf. [styles.css]). Quand la valeur d'un attribut est [null], [Razor] n'écrit pas l'attribut du tout : les autres lignes n'ont pas d'attribut [class] ;
  • lignes 69-80 : trois cas : libre ; occupé, avec le nom du patient (en bleu si c'est le rendez-vous du patient connecté, [Moi]) ; occupé sans nom visible, « Réservé ». Le contrôleur ne renseigne [Patient] que si l'utilisateur a le droit de voir ce nom ;
  • lignes 85-90 : « Réserver » est un lien : il rouvre cette page, avec la fenêtre de réservation de ce créneau ([UrlReserver], préparée par le contrôleur) ;
  • lignes 91-101 : « Annuler » modifie des données : c'est un formulaire POST, avec confirmation ([data-confirmation], cf. exemple 21). Les champs cachés [idMedecin] et [jour] permettent au contrôleur de rediriger vers le même agenda. Dans la boucle, [Model] désigne toujours le modèle de la page : pas besoin de l'équivalent du [@root] de [Handlebars] ;
  • ligne 110 et ligne 116 : un médecin sans créneaux, ou aucun agenda encore demandé ;
  • lignes 120-123 : la fenêtre de réservation, si le contrôleur l'a préparée.

11.17.2.3. Views/Personnes/Liste.cshtml

@* =========================================================================
   Views/Personnes/Liste.cshtml - la liste des médecins OU des clients (ADMIN).
   Une seule vue pour les deux : les libellés propres à chacun arrivent dans
   Model.Cles (cf. MedecinsController / ClientsController).
   ========================================================================= *@
@model ListePersonnesModele
<div class="d-flex justify-content-between align-items-center mb-3">
  <h4 class="mb-0">@T(Model.Cles.Titre)</h4>
  <a class="btn btn-primary" href="@Model.UrlAjouter">@T(Model.Cles.Ajouter)</a>
</div>

<div class="card p-3">
  @if (Model.Personnes.Count > 0)
  {
    <table class="table table-hover align-middle mb-0">
      <thead>
        <tr>
          <th>@T("PERSONNES.COLUMN_TITLE")</th>
          <th>@T("PERSONNES.COLUMN_FIRSTNAME")</th>
          <th>@T("PERSONNES.COLUMN_LASTNAME")</th>
          <th>@T("PERSONNES.COLUMN_LOGIN")</th>
          <th></th>
        </tr>
      </thead>
      <tbody>
        @foreach (var personne in Model.Personnes)
        {
          <tr>
            <td>@personne.Titre</td>
            <td>@personne.Prenom</td>
            <td>@personne.Nom</td>
            <td><code>@personne.Login</code></td>
            <td class="text-end">
              <div class="d-inline-flex gap-2">
                <a class="btn btn-sm btn-outline-primary" href="@personne.UrlModifier">@T("PERSONNES.EDIT")</a>
                @* data-confirmation : une fenêtre de confirmation s'ouvrira
                   avant l'envoi du formulaire (cf. wwwroot/js/app.js) *@
                <form method="post" action="@personne.UrlSupprimer" class="m-0"
                      data-confirmation="@T(Model.Cles.ConfirmerSuppression)">
                  <button type="submit" class="btn btn-sm btn-outline-danger">@T("PERSONNES.DELETE")</button>
                </form>
              </div>
            </td>
          </tr>
        }
      </tbody>
    </table>
  }
  else
  {
    <p class="text-muted mb-0">@T(Model.Cles.Vide)</p>
  }
</div>

Une seule vue pour les médecins et les clients :

  • lignes 8-9 : les textes propres à chacun arrivent sous forme de clés ([Model.Cles], de type [ClesPersonnes]), fournies par [MedecinsController] ou [ClientsController] : [MEDECINS.TITLE] ou [CLIENTS.TITLE], etc. ;
  • lignes 26-45 : une ligne par personne, avec ses deux boutons ;
  • ligne 35 : « Modifier » est un lien vers le formulaire ;
  • lignes 38-41 : « Supprimer » est un formulaire POST, avec confirmation ;
  • ligne 51 : « Aucun médecin. » ou « Aucun client. ».

11.17.2.4. Views/Personnes/Formulaire.cshtml

@* =========================================================================
   Views/Personnes/Formulaire.cshtml - création OU modification d'un médecin
   ou d'un client (ADMIN). Model.Creation distingue les deux cas.

   Quand la saisie est refusée (champ vide, login déjà pris...), le
   contrôleur renvoie CETTE vue avec :
     - les valeurs déjà saisies (Model.Valeurs) ;
     - les champs fautifs encadrés en rouge (classe Bootstrap "is-invalid") ;
     - sous un champ refusé par la validation, son message ;
     - le message d'erreur de la page, affiché EN BAS de la page par le
       gabarit (cf. Views/Shared/_Erreurs.cshtml).
   ========================================================================= *@
@model FormulairePersonneModele
<div class="row justify-content-center">
  <div class="col-md-8 col-lg-6">
    <div class="card p-4">
      <h5 class="card-title mb-3">@T(Model.TitrePage)</h5>

      @* novalidate : on laisse le SERVEUR valider (c'est lui qui fait foi) ;
         les contrôles du navigateur (required...) ne sont qu'un confort,
         qu'un utilisateur peut toujours contourner *@
      <form method="post" action="@Model.Action" novalidate>
        @if (!Model.Creation)
        {
          <input type="hidden" name="version" value="@Model.Valeurs.Version">
        }

        @* titre, prénom, nom : partiel commun à trois formulaires *@
        @await Html.PartialAsync("_ChampsPersonne", Model)

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

        <div class="mb-3">
          <label class="form-label" for="login">@T("PERSONNES.LOGIN_LABEL")</label>
          <input id="login" name="login" type="text" maxlength="30" autocomplete="off"
                 class="form-control @Model.ClasseInvalide("login")" value="@Model.Valeurs.Login">
          @if (Model.ErreurDe("login") is { } erreurLogin)
          {
            <div class="invalid-feedback">@T(erreurLogin)</div>
          }
          else
          {
            <div class="form-text">@T(Model.Creation ? "PERSONNES.LOGIN_HELP_CREATE" : "PERSONNES.LOGIN_HELP_EDIT")</div>
          }
        </div>

        <div class="mb-4">
          <label class="form-label" for="password">@T("PERSONNES.PASSWORD_LABEL")</label>
          @* jamais pré-rempli : le mot de passe ne repart jamais vers le navigateur *@
          <input id="password" name="password" type="password" maxlength="72" autocomplete="new-password"
                 class="form-control @Model.ClasseInvalide("password")">
          @if (Model.ErreurDe("password") is { } erreurPassword)
          {
            <div class="invalid-feedback">@T(erreurPassword)</div>
          }
          else
          {
            <div class="form-text">@T(Model.Creation ? "PERSONNES.PASSWORD_HELP_CREATE" : "PERSONNES.PASSWORD_HELP_EDIT")</div>
          }
        </div>

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

Commentons ce code :

  • ligne 17 : le titre (« Ajouter un médecin », « Modifier un client »...) est la clé du titre de la page, fournie par le contrôleur ;
  • ligne 22 : l'adresse d'envoi est fournie par le contrôleur : la même vue sert à l'ajout et à la modification, des médecins comme des clients. [novalidate] : c'est le serveur qui valide (cf. exemple 19) ;
  • lignes 23-25 : pour une modification, la version de la fiche, dans un champ caché : c'est le verrou optimiste de l'exemple 28 ;
  • ligne 29 : les champs d'identité ;
  • lignes 34-44 : le login, avec un message d'erreur ou, à défaut, un texte d'aide. L'aide diffère selon qu'il s'agit d'un ajout (« Facultatif : par défaut, le nom en minuscules. ») ou d'une modification ;
  • lignes 48-59 : le mot de passe, jamais prérempli : il ne repart jamais vers le navigateur. [autocomplete="new-password"] : le navigateur ne propose pas le mot de passe enregistré de l'administrateur. La longueur est limitée à 72 caractères, parce que l'algorithme [bcrypt] ne tient pas compte des caractères suivants ;
  • ligne 64 : le retour à la liste est un lien.

11.17.2.5. Views/Inscription/Inscription.cshtml

@* =========================================================================
   Views/Inscription/Inscription.cshtml - "Créer un compte client" (page
   publique, cf. InscriptionController). Mêmes champs d'identité que le
   formulaire de l'ADMIN (partiel _ChampsPersonne), mais le login et le mot
   de passe sont obligatoires, avec confirmation.
   Pas de captcha ICI : on n'arrive sur cette page qu'après avoir recopié
   celui de la page d'authentification (bouton "Créer un compte client").
   ========================================================================= *@
@model InscriptionModele
<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("INSCRIPTION.TITLE")</h5>
      <p class="text-muted small mb-3">@T("INSCRIPTION.INTRO")</p>

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

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

        <div class="mb-3">
          <label class="form-label" for="login">@T("PERSONNES.LOGIN_LABEL")</label>
          <input id="login" name="login" type="text" maxlength="30" autocomplete="username" required
                 class="form-control @Model.ClasseInvalide("login")" value="@Model.Valeurs.Login">
          @if (Model.ErreurDe("login") is { } erreurLogin)
          {
            <div class="invalid-feedback">@T(erreurLogin)</div>
          }
          else
          {
            <div class="form-text">@T("INSCRIPTION.LOGIN_HELP")</div>
          }
        </div>

        <div class="row g-3 mb-3">
          <div class="col-sm-6">
            <label class="form-label" for="password">@T("PERSONNES.PASSWORD_LABEL")</label>
            <input id="password" name="password" type="password" maxlength="72" autocomplete="new-password" required
                   class="form-control @Model.ClasseInvalide("password")">
            @if (Model.ErreurDe("password") is { } erreurPassword)
            {
              <div class="invalid-feedback">@T(erreurPassword, ("min", Model.MotDePasseMin))</div>
            }
            else
            {
              <div class="form-text">@T("INSCRIPTION.PASSWORD_HELP", ("min", Model.MotDePasseMin))</div>
            }
          </div>
          <div class="col-sm-6">
            <label class="form-label" for="confirmation">@T("COMPTE.CONFIRMATION_LABEL")</label>
            <input id="confirmation" name="confirmation" type="password" maxlength="72" autocomplete="new-password" required
                   class="form-control @Model.ClasseInvalide("confirmation")">
            @if (Model.ErreurDe("confirmation") is { } erreurConfirmation)
            {
              <div class="invalid-feedback">@T(erreurConfirmation)</div>
            }
          </div>
        </div>

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

La création d'un compte client, page publique. Pas de captcha ici : on n'arrive sur cette page qu'après avoir recopié celui de la page de connexion.

  • ligne 17 : les mêmes champs d'identité que le formulaire de l'ADMIN ;
  • lignes 22-32 : le login, cette fois obligatoire ;
  • lignes 37-47 : le texte d'aide et le message « trop court » ont un paramètre, [{min}], la longueur minimale du mot de passe, fournie par le modèle ([MotDePasseMin]). Un message sans paramètre [{min}] ignore simplement la valeur fournie ;
  • lignes 50-56 : la confirmation du mot de passe ;
  • ligne 62 : le retour à la page de connexion.

11.17.2.6. Views/EspacePatient/Espace.cshtml

@* =========================================================================
   Views/EspacePatient/Espace.cshtml - "Mon espace" : la page d'accueil du
   patient (rôle USER), affichée juste après sa connexion.
     - deux options : [Gérer mon compte] et [Voir un agenda] ;
     - son rendez-vous à venir, s'il en a un, avec le moyen de l'annuler.
   ========================================================================= *@
@model EspaceModele
<h4 class="mb-3">@T("ESPACE.HELLO", ("nom", Model.Patient))</h4>

<div class="row g-3 mb-4">
  <div class="col-sm-6">
    <a class="card p-4 h-100 text-decoration-none carte-option" href="/mon-compte">
      <h5 class="mb-1">@T("ESPACE.MANAGE_ACCOUNT")</h5>
      <span class="text-muted">@T("ESPACE.MANAGE_ACCOUNT_HELP")</span>
    </a>
  </div>
  <div class="col-sm-6">
    <a class="card p-4 h-100 text-decoration-none carte-option" href="/agenda">
      <h5 class="mb-1">@T("ESPACE.VIEW_AGENDA")</h5>
      <span class="text-muted">@T("ESPACE.VIEW_AGENDA_HELP")</span>
    </a>
  </div>
</div>

<div class="card p-3">
  <h5>@T("ESPACE.MY_APPOINTMENT")</h5>
  @if (Model.RendezVous.Count > 0)
  {
    @* normalement un seul (règle "un rendez-vous à venir à la fois"),
       mais la page sait en afficher plusieurs *@
    for (var i = 0; i < Model.RendezVous.Count; i++)
    {
      var rv = Model.RendezVous[i];
      var dernier = i == Model.RendezVous.Count - 1;
      <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 py-2 @(dernier ? "" : "border-bottom")">
        <div>
          <div class="fw-semibold">@rv.Jour, @rv.Horaire</div>
          <div class="text-muted">@rv.Medecin - <a href="@rv.UrlAgenda">@T("ESPACE.SEE_IN_AGENDA")</a></div>
        </div>
        <form method="post" action="/mon-espace/annuler" class="m-0"
              data-confirmation="@T("AGENDA.CANCEL_CONFIRM")">
          <input type="hidden" name="idRv" value="@rv.Id">
          <button type="submit" class="btn btn-outline-danger">@T("ESPACE.CANCEL_APPOINTMENT")</button>
        </form>
      </div>
    }
  }
  else
  {
    <p class="text-muted mb-0">@T("ESPACE.NO_APPOINTMENT")</p>
  }
</div>
  • ligne 8 : « Bonjour Mr Jean MARTIN » ;
  • lignes 12-21 : les deux options sont des liens, présentés comme des cartes (classe [carte-option], cf. [styles.css]) ;
  • lignes 31-46 : les rendez-vous à venir (normalement un seul). À l'intérieur d'un bloc [@if], on est en [C#] : la boucle [for] s'écrit sans [@]. Une bordure ([border-bottom]) sépare les rendez-vous, sauf après le dernier ;
  • lignes 40-44 : l'annulation, un formulaire POST avec confirmation.

11.17.2.7. Views/EspacePatient/Compte.cshtml

@* =========================================================================
   Views/EspacePatient/Compte.cshtml - "Gérer mon compte" (rôle USER).
   Deux formulaires indépendants sur la même page :
     1. la modification (identité, login, nouveau mot de passe facultatif) ;
     2. la suppression du compte - qui annule aussi ses rendez-vous.
   L'un comme l'autre exigent le mot de passe ACTUEL.
   ========================================================================= *@
@model MonCompteModele
<div class="row justify-content-center">
  <div class="col-md-8 col-lg-6">

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

      <form method="post" action="/mon-compte" novalidate>
        <input type="hidden" name="version" value="@Model.Valeurs.Version">

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

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

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

        <div class="row g-3 mb-3">
          <div class="col-sm-6">
            <label class="form-label" for="password">@T("MON_COMPTE.NEW_PASSWORD_LABEL")</label>
            <input id="password" name="password" type="password" maxlength="72" autocomplete="new-password"
                   class="form-control @Model.ClasseInvalide("password")">
            @if (Model.ErreurDe("password") is { } erreurPassword)
            {
              <div class="invalid-feedback">@T(erreurPassword, ("min", Model.MotDePasseMin))</div>
            }
            else
            {
              <div class="form-text">@T("PERSONNES.PASSWORD_HELP_EDIT")</div>
            }
          </div>
          <div class="col-sm-6">
            <label class="form-label" for="confirmation">@T("COMPTE.CONFIRMATION_LABEL")</label>
            <input id="confirmation" name="confirmation" type="password" maxlength="72" autocomplete="new-password"
                   class="form-control @Model.ClasseInvalide("confirmation")">
            @if (Model.ErreurDe("confirmation") is { } erreurConfirmation)
            {
              <div class="invalid-feedback">@T(erreurConfirmation)</div>
            }
          </div>
        </div>

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

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

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

  </div>
</div>
  • lignes 16-76 : la modification du compte : identité, login, nouveau mot de passe (facultatif) et sa confirmation ;
  • ligne 17 : la version de la fiche du client (verrou optimiste) ;
  • lignes 59-69 : le mot de passe actuel, exigé pour toute modification ;
  • lignes 83-91 : la suppression, avec confirmation. Deux formulaires indépendants sur une même page : chacun n'envoie que ses propres champs. Le champ du mot de passe a donc ici un autre nom ([motDePasseSuppression]) et un autre identifiant. Ce formulaire n'a pas l'attribut [novalidate] : le navigateur exige le mot de passe ([required]) avant même de déclencher l'événement [submit], donc avant de poser la question de confirmation.

11.17.2.8. Views/Shared/Erreur.cshtml

@* =========================================================================
   Views/Shared/Erreur.cshtml - la page d'erreur (400, 403, 404, 500),
   fabriquée par ErreurController.
   ========================================================================= *@
@model ErreurModele
<div class="row justify-content-center">
  <div class="col-md-8 col-lg-6">
    <div class="card p-4 text-center">
      <h1 class="display-4 text-danger">@Model.Code</h1>
      <p class="lead">@T(Model.Message)</p>
      <div><a class="btn btn-primary" href="/">@T("ERROR_PAGE.BACK")</a></div>
    </div>
  </div>
</div>
  • lignes 9-10 : la page d'erreur fabriquée par [ErreurController] (400, 403, 404, 500) : le code HTTP, et le message traduit. Rangée dans [Views/Shared], elle est trouvée quel que soit le contrôleur qui la demande.

11.18. Les dictionnaires

Les dictionnaires sont ceux de la version [NestJS], sans changement : ils sont lus par le service [Traducteur] (cf. exemple 22).

11.18.1. i18n/fr.json

{
  "APP": {
    "TITLE": "RdvMedecins",
    "LOGOUT": "Se déconnecter",
    "MENU_AGENDA": "Agenda",
    "MENU_DOCTORS": "Médecins",
    "MENU_PATIENTS": "Clients",
    "MENU_MY_SPACE": "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_AGENDA": "Voir l'agenda"
  },
  "AGENDA": {
    "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 {horaire}",
    "PATIENT_LABEL": "Patient",
    "CHOOSE_PATIENT": "-- choisir un patient --",
    "CANCEL": "Annuler",
    "CONFIRM": "Confirmer"
  },
  "PERSONNES": {
    "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"
  },
  "MEDECINS": {
    "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 {nom} a été ajouté (login : {login}).",
    "UPDATED": "Le médecin {nom} a été modifié.",
    "DELETED": "Le médecin {nom} 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 {nom} a été ajouté (login : {login}).",
    "UPDATED": "Le client {nom} a été modifié.",
    "DELETED": "Le client {nom} 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."
  },
  "INSCRIPTION": {
    "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}."
  },
  "COMPTE": {
    "CONFIRMATION_LABEL": "Confirmation du mot de passe"
  },
  "ESPACE": {
    "TITLE": "Mon espace",
    "HELLO": "Bonjour {nom}",
    "MANAGE_ACCOUNT": "Gérer mon compte",
    "MANAGE_ACCOUNT_HELP": "Modifier mes informations, mon mot de passe, ou supprimer mon compte.",
    "VIEW_AGENDA": "Voir un agenda",
    "VIEW_AGENDA_HELP": "Consulter l'agenda d'un médecin et réserver un créneau.",
    "MY_APPOINTMENT": "Mon rendez-vous",
    "SEE_IN_AGENDA": "voir dans l'agenda",
    "CANCEL_APPOINTMENT": "Annuler ce rendez-vous",
    "NO_APPOINTMENT": "Vous n'avez aucun rendez-vous à venir."
  },
  "MON_COMPTE": {
    "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"
  }
}
  • les sections regroupent les textes par page ([LOGIN], [AGENDA], [ESPACE]...) ou par thème ([ERRORS], [CONFIRMATION]). Certains noms ([DOCTOR_DAY_PICKER], [BOOKING_DIALOG]) sont ceux des composants du client [Angular] dont la version [NestJS] a repris les dictionnaires ;
  • ligne 80 : un texte avec deux paramètres, [{nom}] et [{login}], remplacés au moment de la traduction ;
  • lignes 98-136 : les messages d'erreur. Ce sont les clés qu'utilisent les [MetierException] de la couche métier, les attributs de validation des formulaires ([ErrorMessage = "ERRORS.FIRSTNAME_REQUIRED"]), les contrôleurs et le filtre d'exceptions. Trois d'entre eux ont un paramètre : [{minutes}] et [{min}].

11.18.2. i18n/en.json

{
  "APP": {
    "TITLE": "RdvMedecins",
    "LOGOUT": "Log out",
    "MENU_AGENDA": "Schedule",
    "MENU_DOCTORS": "Doctors",
    "MENU_PATIENTS": "Patients",
    "MENU_MY_SPACE": "My space"
  },
  "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_AGENDA": "View schedule"
  },
  "AGENDA": {
    "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 {horaire}",
    "PATIENT_LABEL": "Patient",
    "CHOOSE_PATIENT": "-- choose a patient --",
    "CANCEL": "Cancel",
    "CONFIRM": "Confirm"
  },
  "PERSONNES": {
    "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"
  },
  "MEDECINS": {
    "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 {nom} has been added (login: {login}).",
    "UPDATED": "Doctor {nom} has been updated.",
    "DELETED": "Doctor {nom} 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 {nom} has been added (login: {login}).",
    "UPDATED": "Patient {nom} has been updated.",
    "DELETED": "Patient {nom} 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."
  },
  "INSCRIPTION": {
    "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}."
  },
  "COMPTE": {
    "CONFIRMATION_LABEL": "Confirm password"
  },
  "ESPACE": {
    "TITLE": "My space",
    "HELLO": "Hello {nom}",
    "MANAGE_ACCOUNT": "Manage my account",
    "MANAGE_ACCOUNT_HELP": "Change my details or password, or delete my account.",
    "VIEW_AGENDA": "View a schedule",
    "VIEW_AGENDA_HELP": "See a doctor's schedule and book a slot.",
    "MY_APPOINTMENT": "My appointment",
    "SEE_IN_AGENDA": "see in the schedule",
    "CANCEL_APPOINTMENT": "Cancel this appointment",
    "NO_APPOINTMENT": "You have no upcoming appointment."
  },
  "MON_COMPTE": {
    "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 space",
    "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"
  }
}

Le dictionnaire anglais a exactement les mêmes clés.

11.19. Les fichiers statiques

Le dossier [wwwroot] est servi par [UseStaticFiles] (cf. exemple 15). Le dossier [wwwroot/lib/bootstrap] contient [Bootstrap] (sa feuille de style [css/bootstrap.min.css] et sa licence), qu'on ne montre pas.

11.19.1. wwwroot/css/styles.css

/* =========================================================================
   wwwroot/css/styles.css - quelques réglages en plus de Bootstrap
   (repris de src/styles.css du client Angular, via le projet NestJS).
   ========================================================================= */

body {
  background-color: #f5f7fa;
}

.creneau-libre:hover {
  background-color: #e9f7ef;
}

/* -------------------------------------------------------------------------
   .zone-erreur : les messages d'erreur, en bas de page, sur fond rose léger
   (cf. Views/Shared/_Erreurs.cshtml).
   position: sticky + bottom : si la page est plus haute que la fenêtre (un
   agenda de 12 créneaux, par exemple), la zone reste collée au bas de la
   fenêtre au lieu de disparaître sous la ligne de flottaison - sans jamais
   recouvrir le contenu quand la page est courte.
   z-index : au-dessus du fond grisé de la fenêtre de réservation (.modal-backdrop).
   ------------------------------------------------------------------------- */
.zone-erreur {
  position: sticky;
  bottom: 1rem;
  z-index: 1060;
  margin-top: 1.5rem;
  padding: 0.75rem 1rem;
  background-color: #fdecef; /* rose léger */
  border: 1px solid #f5c2c7;
  border-radius: 0.375rem;
  color: #842029;
}

/* les deux grandes options de la page "Mon espace" du patient */
.carte-option {
  transition: border-color 0.15s, box-shadow 0.15s;
}

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

/* l'image du captcha (SVG de 150 x 50 pixels) */
.captcha-image svg {
  display: block;
  border: 1px solid #dee2e6;
  border-radius: 0.375rem;
}
  • lignes 10-12 : un créneau réservable change de couleur au survol ;
  • lignes 23-33 : la zone d'erreur, sur fond rose léger. [position: sticky] et [bottom: 1rem] : si la page est plus haute que la fenêtre, la zone reste collée en bas de la fenêtre au lieu de disparaître hors de l'écran ; [z-index] la place au-dessus du fond grisé de la fenêtre de réservation ;
  • lignes 36-43 : les deux options de « Mon espace » réagissent au survol ;
  • lignes 46-50 : le cadre de l'image du captcha.

11.19.2. wwwroot/js/app.js

// =========================================================================
// wwwroot/js/app.js - le SEUL JavaScript de l'application.
// -------------------------------------------------------------------------
// Toute l'application fonctionne sans JavaScript. Ce script ajoute un
// confort : avant d'envoyer un formulaire qui porte l'attribut
// data-confirmation="...", une fenêtre modale demande "Êtes-vous sûr ?"
// (la fenêtre elle-même est dans la page : Views/Shared/_Confirmation.cshtml).
// Si le JavaScript est désactivé, le formulaire part simplement sans
// confirmation - rien ne casse (principe d'"amélioration progressive").
//
// Pourquoi pas window.confirm() ? Sa boîte ne se met pas en forme, et ses
// boutons restent dans la langue du navigateur, pas dans celle de la page.
// =========================================================================

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

  let formulaireEnAttente = null; // le formulaire à envoyer si l'on confirme
  let focusPrecedent = null; // l'élément à qui rendre le focus à la fermeture

  function ouvrir(formulaire) {
    formulaireEnAttente = formulaire;
    focusPrecedent = document.activeElement;
    // textContent (et non innerHTML) : le message est affiché comme du TEXTE,
    // jamais interprété comme du HTML
    message.textContent = formulaire.dataset.confirmation;
    modale.classList.remove('d-none');
    // le focus va sur "Non" : un appui distrait sur Entrée ne supprime rien
    boutonNon.focus();
  }

  function fermer() {
    modale.classList.add('d-none');
    formulaireEnAttente = null;
    if (focusPrecedent) {
      focusPrecedent.focus();
    }
  }

  // 1. on intercepte l'envoi des formulaires à confirmer
  document.querySelectorAll('form[data-confirmation]').forEach((formulaire) => {
    formulaire.addEventListener('submit', (evenement) => {
      evenement.preventDefault();
      ouvrir(formulaire);
    });
  });

  // 2. les boutons de la fenêtre (Oui / Non / croix de fermeture)
  modale.addEventListener('click', (evenement) => {
    const action = evenement.target.closest('[data-action]')?.dataset.action;
    if (action === 'oui') {
      const formulaire = formulaireEnAttente;
      fermer();
      // formulaire.submit() envoie le formulaire SANS redéclencher
      // l'événement 'submit' : pas de nouvelle demande de confirmation
      formulaire.submit();
    } else if (action === 'non') {
      fermer();
    } else if (evenement.target.classList.contains('modal')) {
      fermer(); // clic sur le fond grisé, en dehors de la fenêtre
    }
  });

  // 3. clavier : Échap ferme la fenêtre ; Tab reste DANS la fenêtre
  //    (sinon le focus partirait sur la page grisée, derrière)
  modale.addEventListener('keydown', (evenement) => {
    if (evenement.key === 'Escape') {
      fermer();
    } else if (evenement.key === 'Tab') {
      const boutons = [...modale.querySelectorAll('button')];
      const premier = boutons[0];
      const dernier = boutons[boutons.length - 1];
      if (evenement.shiftKey && document.activeElement === premier) {
        evenement.preventDefault();
        dernier.focus();
      } else if (!evenement.shiftKey && document.activeElement === dernier) {
        evenement.preventDefault();
        premier.focus();
      }
    }
  });
})();

Le script de la fenêtre de confirmation de l'exemple 21, complété par la gestion du clavier. Il suit le même principe d'amélioration progressive : sans [JavaScript], les formulaires partent sans confirmation, et l'application fonctionne toujours.

  • lignes 16-19 : une page sans fenêtre de confirmation n'a rien à faire ;
  • ligne 24 : l'élément qui avait le focus clavier (celui qui reçoit les frappes) avant l'ouverture : on le lui rend à la fermeture ;
  • lignes 26-35 : le focus est placé sur « Non, revenir » : un appui distrait sur Entrée ne supprime rien ;
  • lignes 37-43 : la fenêtre est cachée, et le focus rendu ;
  • lignes 46-51 : on intercepte l'envoi des formulaires qui portent l'attribut [data-confirmation], comme dans l'exemple 21 ;
  • lignes 54-67 : « Oui », « Non », la croix de fermeture, et le clic en dehors de la fenêtre. [closest('[data-action]')] trouve l'élément porteur de l'attribut, même si l'on a cliqué sur un élément qu'il contient. L'opérateur [?.] rend [undefined] au lieu d'échouer quand aucun élément n'est trouvé ;
  • lignes 71-86 : la touche Échap ferme la fenêtre ; la touche Tab (et Maj+Tab) fait tourner le focus entre les boutons de la fenêtre, au lieu de le laisser partir sur la page grisée, derrière.