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). Elle reprend la base de données et les couches métier du serveur [NestJS] des cours Un exemple de client / serveur Flutter / NestJS et Un exemple de client / serveur Angular / NestJS, et affiche les mêmes pages que le client [Angular] ; mais au lieu de servir du JSON à un client [JavaScript] ou [Flutter], elle fabrique elle-même ses pages HTML.
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

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 :

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

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 :


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 :

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) :

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 :

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

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

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 :

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

11.2.4. La gestion des médecins et des clients
L'administrateur a deux menus supplémentaires, Médecins et Clients :


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

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

La suppression d'un client demande une confirmation :

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é :


11.3. Installation et exécution
Le dossier de l'application :
| rdvmedecins-nestjs-html/
├── database/
│ ├── README.md
│ └── dbrdvmedecins2.sql
├── i18n/
│ ├── en.json
│ └── fr.json
├── public/
│ ├── css/
│ │ └── styles.css
│ └── js/
│ └── app.js
├── src/
│ ├── auth/
│ │ ├── README.md
│ │ ├── auth.constants.ts
│ │ ├── auth.controller.ts
│ │ ├── auth.module.ts
│ │ ├── auth.service.ts
│ │ ├── captcha.service.ts
│ │ ├── jwt-auth.guard.ts
│ │ ├── jwt.strategy.ts
│ │ ├── limiteur-tentatives.service.ts
│ │ ├── passe-inscription.service.ts
│ │ ├── roles.decorator.ts
│ │ └── roles.guard.ts
│ ├── config/
│ │ ├── README.md
│ │ ├── chemins.ts
│ │ └── database.config.ts
│ ├── domain/
│ │ ├── README.md
│ │ ├── agenda-medecin-jour.ts
│ │ └── creneau-medecin-jour.ts
│ ├── entities/
│ │ ├── README.md
│ │ ├── abstract.entity.ts
│ │ ├── client.entity.ts
│ │ ├── creneau.entity.ts
│ │ ├── medecin.entity.ts
│ │ ├── personne.entity.ts
│ │ ├── rv.entity.ts
│ │ └── user.entity.ts
│ ├── metier/
│ │ ├── README.md
│ │ ├── dates.ts
│ │ ├── metier.exception.ts
│ │ ├── metier.interface.ts
│ │ └── metier.service.ts
│ ├── repositories/
│ │ ├── README.md
│ │ ├── client.repository.ts
│ │ ├── creneau.repository.ts
│ │ ├── medecin.repository.ts
│ │ ├── rv.repository.ts
│ │ └── user.repository.ts
│ ├── web/
│ │ ├── controllers/
│ │ │ ├── agenda.controller.ts
│ │ │ ├── clients.controller.ts
│ │ │ ├── espace-patient.controller.ts
│ │ │ ├── inscription.controller.ts
│ │ │ ├── langue.controller.ts
│ │ │ ├── medecins.controller.ts
│ │ │ └── personnes.controller.ts
│ │ ├── helpers/
│ │ │ ├── dates.helper.ts
│ │ │ ├── formulaire.helper.ts
│ │ │ └── patient.helper.ts
│ │ ├── models/
│ │ │ └── formulaire-personne.dto.ts
│ │ ├── vues/
│ │ │ └── helpers-handlebars.ts
│ │ ├── README.md
│ │ ├── application-model.service.ts
│ │ ├── contexte-vue.ts
│ │ ├── html-exception.filter.ts
│ │ ├── i18n.ts
│ │ └── utilisateur-vue.interceptor.ts
│ ├── README.md
│ ├── app.module.ts
│ └── main.ts
├── views/
│ ├── compte/
│ │ └── inscription.hbs
│ ├── partials/
│ │ ├── captcha.hbs
│ │ ├── champsPersonne.hbs
│ │ ├── confirmation.hbs
│ │ ├── erreurs.hbs
│ │ ├── flash.hbs
│ │ ├── navbar.hbs
│ │ └── reservation.hbs
│ ├── patient/
│ │ ├── compte.hbs
│ │ └── espace.hbs
│ ├── personnes/
│ │ ├── formulaire.hbs
│ │ └── liste.hbs
│ ├── README.md
│ ├── agenda.hbs
│ ├── erreur.hbs
│ ├── layout.hbs
│ └── login.hbs
├── .env.example
├── README.md
├── nest-cli.json
├── package-lock.json
├── package.json
└── tsconfig.json
|
Les fichiers [README.md] décrivent le rôle de chaque dossier ; ils ne sont pas reproduits ici. Pour installer et lancer l'application :
- créer la base [dbrdvmedecins2] en exécutant le script [database/dbrdvmedecins2.sql] (cf. annexes) ;
- dans une console ouverte sur le dossier de l'application, installer les dépendances : [npm install] ;
- copier le fichier [.env.example] en [.env], et y adapter les paramètres de connexion à [MySQL] ;
- lancer l'application : [npm run start:dev], puis ouvrir [http://localhost:8081].
Les comptes de démonstration ont pour mot de passe leur login : [admin] (ADMIN), [pelissier], [tatou], [dupont] (DOCTOR), [martin], [durand], [bistrou], [german] (USER). Au démarrage :
| [Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [NestFactory] Starting Nest application...
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [InstanceLoader] TypeOrmModule dependencies initialized +69ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [InstanceLoader] PassportModule dependencies initialized +0ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [InstanceLoader] JwtModule dependencies initialized +0ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [InstanceLoader] TypeOrmCoreModule dependencies initialized +26ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [InstanceLoader] TypeOrmModule dependencies initialized +0ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [InstanceLoader] TypeOrmModule dependencies initialized +0ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [InstanceLoader] AuthModule dependencies initialized +1ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [InstanceLoader] AppModule dependencies initialized +1ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [RoutesResolver] AgendaController {/agenda}: +13ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [RouterExplorer] Mapped {/agenda, GET} route +2ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [RouterExplorer] Mapped {/agenda/reserver, POST} route +1ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [RouterExplorer] Mapped {/agenda/annuler, POST} route +0ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [RoutesResolver] MedecinsController {/medecins}: +0ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [RouterExplorer] Mapped {/medecins, GET} route +0ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [RouterExplorer] Mapped {/medecins/ajouter, GET} route +1ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [RouterExplorer] Mapped {/medecins/ajouter, POST} route +0ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [RouterExplorer] Mapped {/medecins/:id/modifier, GET} route +0ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [RouterExplorer] Mapped {/medecins/:id/modifier, POST} route +1ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [RouterExplorer] Mapped {/medecins/:id/supprimer, POST} route +0ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [RoutesResolver] ClientsController {/clients}: +0ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [RouterExplorer] Mapped {/clients, GET} route +0ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [RouterExplorer] Mapped {/clients/ajouter, GET} route +1ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [RouterExplorer] Mapped {/clients/ajouter, POST} route +0ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [RouterExplorer] Mapped {/clients/:id/modifier, GET} route +0ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [RouterExplorer] Mapped {/clients/:id/modifier, POST} route +0ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [RouterExplorer] Mapped {/clients/:id/supprimer, POST} route +0ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LOG [RoutesResolver] LangueController {/langue}: +0ms
[Nest] 17495 - 09/23/2026, 4:23:55 PM LO
|
(les requêtes SQL affichées par [TypeORM] ont été retirées)
11.4. L'architecture de l'application

- [src/main.ts] configure l'application : moteur de vues, fichiers statiques, cookies, filtre d'exceptions ;
- [src/auth] : la connexion, les jetons JWT, les rôles, le captcha, la limitation des tentatives ;
- [src/web] : la couche web. Ses contrôleurs ([src/web/controllers]) passent tous par un service unique, [ApplicationModelService], qui garde en mémoire les listes de médecins et de clients et relaie les autres demandes à la couche métier ;
- [src/metier] : les règles de gestion ;
- [src/repositories] : la couche DAO, une classe par table ;
- [src/entities] : les entités [TypeORM] ; [src/domain] : les objets de l'agenda, calculés par la couche métier ;
- [views] : les vues [Handlebars] ; [i18n] : les dictionnaires ; [public] : la feuille de style et le script de la page.
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
Le fichier [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 NestJS 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 : comme à l'exemple 28, la collation [utf8mb4_unicode_ci] ignore majuscules et accents dans les comparaisons ;
- lignes 34-38 : les tables sont supprimées des « filles » vers les « mères » : une table référencée par une clé étrangère ne peut pas être supprimée avant celles qui la référencent ;
- lignes 43-52 : les comptes des utilisateurs. [password] contient le hachage bcrypt du mot de passe (cf. exemple 25). Ligne 51 : un login est unique ;
- lignes 57-69 : les médecins. Chacun a un compte (colonne [user], clé étrangère vers [users], ligne 64). Le nom [user] est un mot réservé de [MySQL] : il est écrit entre apostrophes inverses. Ligne 66 : un compte n'appartient qu'à un médecin ; ligne 68 : pas deux médecins de mêmes prénom et nom ;
- lignes 74-86 : les clients, sur le même modèle ;
- lignes 91-100 : les créneaux horaires de chaque médecin (heure et minute de début et de fin) ;
- lignes 105-115 : les rendez-vous : un jour, un créneau, un client. Ligne 114 : un créneau n'est réservé qu'une fois par jour ;
- lignes 127-137 : les comptes de démonstration ; le mot de passe de chacun est son login ;
- 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 générés ;
- lignes 167-168 : un rendez-vous pour le jour même ([CURDATE()]), pour voir d'emblée un créneau occupé.
11.6. La configuration du projet
Le fichier [package.json] :
| {
"name": "rdvmedecins-nestjs-html",
"version": "1.0.0",
"description": "Application web NestJS (pages HTML générées côté serveur) de prise de rendez-vous médicaux [RdvMedecins] - base [dbrdvmedecins2], rôles ADMIN / DOCTOR / USER",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch --watchAssets",
"start:debug": "nest start --debug --watch --watchAssets",
"start:prod": "node dist/main"
},
"dependencies": {
"@nestjs/common": "^10.4.0",
"@nestjs/core": "^10.4.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.0",
"@nestjs/typeorm": "^10.0.2",
"bcryptjs": "^3.0.3",
"bootstrap": "^5.3.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cookie-parser": "^1.4.7",
"dotenv": "^17.4.2",
"hbs": "^4.2.0",
"mysql2": "^3.11.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"svg-captcha": "^1.4.0",
"typeorm": "^0.3.20"
},
"devDependencies": {
"@nestjs/cli": "^10.4.5",
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.7",
"@types/express": "^4.17.21",
"@types/node": "^20.14.0",
"@types/passport-jwt": "^4.0.1",
"typescript": "^5.5.0"
}
}
|
- lignes 7-13 : les commandes [npm run ...]. [start:dev] lance l'application en mode « surveillance » : le code est recompilé et le serveur relancé à chaque modification ; l'option [--watchAssets] surveille aussi les fichiers qui ne sont pas du code ;
- lignes 14-34 : les bibliothèques de l'application, toutes rencontrées dans les exemples. [dotenv] lit le fichier [.env] ; [mysql2] est le pilote [MySQL] utilisé par [TypeORM] ; [reflect-metadata] et [rxjs] sont nécessaires à [NestJS] ;
- lignes 35-43 : les outils de développement : l'outil [nest], le compilateur [TypeScript], et les déclarations de types ([@types/...]) des bibliothèques écrites en [JavaScript].
Le fichier [tsconfig.json] :
| {
"compilerOptions": {
"module": "commonjs",
"declaration": false,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2022",
"lib": ["ES2022"],
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": false
}
}
|
Ce fichier est celui que génère l'outil [nest] pour un nouveau projet. Par rapport aux exemples : ligne 5, les commentaires ne sont pas recopiés dans le code compilé ; ligne 14, seuls les fichiers modifiés sont recompilés ; ligne 17, une variable sans type explicite ni déductible est acceptée (elle est alors de type [any]) : c'est ce qui permet d'importer le paquet [hbs], qui n'a pas de déclarations de types.
Le fichier [nest-cli.json] :
| {
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
|
Identique à celui des exemples.
Le fichier [.env.example] :
| # -----------------------------------------------------------------------
# Fichier d'exemple de configuration.
# Copier ce fichier en [.env] puis adapter les valeurs à votre machine.
# Le fichier [.env] ne doit jamais être partagé ou commité (mots de passe).
# -----------------------------------------------------------------------
# port d'écoute du serveur NestJS. Le serveur JSON écoute déjà sur 8080 :
# on choisit un autre port pour pouvoir faire tourner les deux en même temps.
PORT=8081
# paramètres de connexion à la base de données MySQL [dbrdvmedecins2]
# (script : database/dbrdvmedecins2.sql)
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=
DB_DATABASE=dbrdvmedecins2
# -----------------------------------------------------------------------
# authentification (cf. src/auth/)
# -----------------------------------------------------------------------
# clé secrète utilisée pour signer/vérifier les jetons JWT - à changer
# impérativement en production (une longue chaîne aléatoire, jamais commitée).
JWT_SECRET=change-moi-en-une-longue-chaine-aleatoire
# durée de validité d'un jeton JWT, en secondes (7200 s = 2 h). Le cookie
# qui transporte le jeton a la même durée de vie.
JWT_EXPIRES_IN_SECONDS=7200
# mettre à true en production (site servi en HTTPS) : le cookie
# d'authentification ne sera alors jamais envoyé sur une connexion non chiffrée.
COOKIE_SECURE=false
# -----------------------------------------------------------------------
# protection contre les robots (cf. src/auth/limiteur-tentatives.service.ts)
# -----------------------------------------------------------------------
# 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...
LOGIN_MAX_ECHECS=5
# ... pendant cette durée (une adresse IP est aussi bloquée si elle cumule
# 5 fois plus d'échecs, tous logins confondus)
LOGIN_BLOCAGE_MINUTES=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.
INSCRIPTION_MAX_PAR_HEURE=10
|
- un fichier [.env] contient la configuration propre à une machine : il n'est jamais partagé (il contient des mots de passe). Le fichier [.env.example] en est le modèle, à copier ;
- ligne 9 : le port de l'application ;
- lignes 13-17 : la connexion à la base ;
- lignes 24-30 : la clé des jetons, leur durée de vie (deux heures) et l'option [secure] du cookie (en production, le site est servi en HTTPS, et le cookie ne doit jamais voyager sur une connexion non chiffrée) ;
- lignes 37-44 : les seuils de la protection contre les robots.
Le fichier [src/config/database.config.ts] :
| // =========================================================================
// config/database.config.ts
// -------------------------------------------------------------------------
// Configuration de la connexion à la base de données MySQL, utilisée par
// TypeORM. C'est l'équivalent de la classe [DomainAndPersitenceConfig] du
// projet Spring original, qui définissait un [DataSource] (bloc @Bean).
//
// Ici, pas de classe : NestJS/TypeORM préfère une simple fonction qui
// renvoie un objet de configuration, lu depuis les variables d'environnement
// (fichier .env à la racine du projet, chargé automatiquement par NestJS
// via le module @nestjs/config si on l'installe - ici on lit directement
// process.env pour rester simple et pédagogique).
// =========================================================================
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { Medecin } from '../entities/medecin.entity';
import { Client } from '../entities/client.entity';
import { Creneau } from '../entities/creneau.entity';
import { Rv } from '../entities/rv.entity';
import { User } from '../entities/user.entity';
export function getDatabaseConfig(): TypeOrmModuleOptions {
return {
// 'mysql' : nous utilisons le pilote MySQL, comme le projet original (MySQL5 -> MySQL8 ici).
type: 'mysql',
host: process.env.DB_HOST ?? 'localhost',
port: process.env.DB_PORT ? parseInt(process.env.DB_PORT, 10) : 3306,
username: process.env.DB_USERNAME ?? 'root',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_DATABASE ?? 'dbrdvmedecins2',
// la liste des entités (classes @Entity) que TypeORM doit gérer.
// C'est l'équivalent du "scan" automatique des classes @Entity que faisait Spring Boot.
entities: [Medecin, Client, Creneau, Rv, User],
// IMPORTANT : synchronize=false en permanence dans ce cours.
// Si synchronize valait true, TypeORM modifierait tout seul le schéma de la base
// à partir des entités : pratique pour un prototype jetable, dangereux dès qu'on a
// des données réelles (risque de perte de données). Nous préférons, comme le ferait
// une vraie équipe, garder la main sur le schéma via le script database/dbrdvmedecins2.sql.
synchronize: false,
// affiche les requêtes SQL générées par TypeORM dans la console : très utile pour
// comprendre ce que fait l'ORM à votre place, exactement comme les logs Hibernate
// du projet Spring original.
logging: process.env.NODE_ENV !== 'production',
};
}
|
La connexion à la base (cf. exemple 28). Ligne 46 : les requêtes SQL sont affichées, sauf en production.
Le fichier [src/config/chemins.ts] :
| // =========================================================================
// config/chemins.ts
// -------------------------------------------------------------------------
// Les dossiers NON compilés du projet (vues Handlebars, fichiers statiques,
// dictionnaires de traduction) se trouvent à la racine du projet, à côté de
// src/ et dist/. Une fois compilé, ce fichier-ci se trouve dans
// dist/config/ : la racine du projet est donc deux niveaux au-dessus.
// =========================================================================
import { join, resolve } from 'path';
export const RACINE_PROJET = resolve(__dirname, '..', '..');
export const DOSSIER_VUES = join(RACINE_PROJET, 'views'); // modèles de pages (.hbs)
export const DOSSIER_PARTIELS = join(DOSSIER_VUES, 'partials'); // morceaux de pages réutilisables
export const DOSSIER_PUBLIC = join(RACINE_PROJET, 'public'); // CSS, JS, images
export const DOSSIER_I18N = join(RACINE_PROJET, 'i18n'); // fr.json, en.json
export const DOSSIER_BOOTSTRAP = join(RACINE_PROJET, 'node_modules', 'bootstrap', 'dist');
|
- ligne 12 : une fois compilé, ce fichier est dans [dist/config] : la racine du projet est deux niveaux au-dessus ;
- lignes 14-18 : les dossiers non compilés (vues, fichiers statiques, dictionnaires), et celui de [Bootstrap].
11.7. Le démarrage de l'application
Le fichier [src/main.ts] :
| // =========================================================================
// main.ts
// -------------------------------------------------------------------------
// Point d'entrée de l'application. Par rapport au serveur JSON, le
// démarrage configure en plus tout ce qu'il faut pour FABRIQUER DES PAGES
// HTML côté serveur :
// - un moteur de vues (Handlebars, fichiers views/*.hbs) ;
// - les fichiers statiques (Bootstrap, CSS, un peu de JavaScript) ;
// - la lecture des cookies (jeton JWT, langue, messages flash) ;
// - un filtre qui transforme les erreurs en pages HTML.
// Il n'y a plus de CORS à autoriser : pages et données viennent maintenant
// du MÊME serveur.
// =========================================================================
import 'dotenv/config'; // DOIT rester le tout premier import (cf. serveur JSON)
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import * as cookieParser from 'cookie-parser';
import * as hbs from 'hbs';
import { AppModule } from './app.module';
import { DOSSIER_BOOTSTRAP, DOSSIER_PARTIELS, DOSSIER_PUBLIC, DOSSIER_VUES } from './config/chemins';
import { enregistrerHelpers } from './web/vues/helpers-handlebars';
import { contexteVueMiddleware } from './web/contexte-vue';
import { HtmlExceptionFilter } from './web/html-exception.filter';
import { UtilisateurVueInterceptor } from './web/utilisateur-vue.interceptor';
async function bootstrap() {
// NestExpressApplication : on précise que la plate-forme sous-jacente est
// Express, pour avoir accès à ses méthodes propres (setViewEngine,
// useStaticAssets...) - elles n'existent pas pour la plate-forme Fastify.
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// 1. fichiers statiques : /css/..., /js/... (dossier public/) et
// /bootstrap/css/bootstrap.min.css (directement depuis node_modules :
// pas besoin de recopier Bootstrap dans le projet)
app.useStaticAssets(DOSSIER_PUBLIC);
app.useStaticAssets(DOSSIER_BOOTSTRAP, { prefix: '/bootstrap/' });
// 2. le moteur de vues. Le fichier views/layout.hbs est utilisé
// AUTOMATIQUEMENT par hbs comme gabarit commun à toutes les pages :
// chaque vue est insérée à l'emplacement {{{body}}} de ce gabarit.
app.setBaseViewsDir(DOSSIER_VUES);
app.setViewEngine('hbs');
enregistrerHelpers(hbs);
// les "partiels" (views/partials/*.hbs) : des morceaux de page réutilisables,
// insérés avec {{> nom}}. registerPartials lit le dossier de façon
// asynchrone : on attend qu'il ait fini avant d'accepter des requêtes.
await new Promise<void>((fini) => hbs.registerPartials(DOSSIER_PARTIELS, () => fini()));
// 3. cookie-parser remplit req.cookies ; puis notre middleware prépare la
// langue et le message flash de chaque page (cf. web/contexte-vue.ts)
app.use(cookieParser());
app.use(contexteVueMiddleware);
// 4. l'utilisateur connecté, visible de toutes les vues ; et les erreurs
// transformées en pages HTML (ou en redirection vers /login)
app.useGlobalInterceptors(new UtilisateurVueInterceptor());
app.useGlobalFilters(new HtmlExceptionFilter());
const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 8081;
await app.listen(port);
// eslint-disable-next-line no-console
console.log(`[RdvMedecins] application web démarrée sur http://localhost:${port}`);
}
bootstrap();
|
Commentons ce code :
- ligne 15 : [dotenv/config] lit le fichier [.env] et place ses valeurs dans [process.env]. Cet import doit être le premier : les autres modules lisent [process.env] dès leur chargement ([JWT_SECRET], par exemple) ;
- lignes 37-38 : les fichiers statiques (cf. exemple 15) ;
- lignes 43-49 : le moteur de vues, les helpers, les partiels (cf. exemples 15 et 16) ;
- lignes 53-54 : la lecture des cookies, puis le middleware qui prépare la langue et le message flash de chaque page (cf. [web/contexte-vue.ts]) ;
- lignes 58-59 : un intercepteur global expose l'utilisateur connecté aux vues, et un filtre global transforme les exceptions en pages HTML (cf. chapitre 7) ;
- ligne 61 : le port, lu dans [.env] (8081 par défaut).
Le fichier [src/app.module.ts] :
| // =========================================================================
// app.module.ts
// -------------------------------------------------------------------------
// Le module racine. Mêmes couches que le serveur JSON (DAO, métier,
// cache/façade) ; seuls les contrôleurs changent : ils fabriquent
// maintenant des pages HTML au lieu de réponses JSON.
// =========================================================================
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { getDatabaseConfig } from './config/database.config';
import { Medecin } from './entities/medecin.entity';
import { Client } from './entities/client.entity';
import { Creneau } from './entities/creneau.entity';
import { Rv } from './entities/rv.entity';
import { MedecinRepository } from './repositories/medecin.repository';
import { ClientRepository } from './repositories/client.repository';
import { CreneauRepository } from './repositories/creneau.repository';
import { RvRepository } from './repositories/rv.repository';
import { METIER_TOKEN } from './metier/metier.interface';
import { MetierService } from './metier/metier.service';
import { ApplicationModelService } from './web/application-model.service';
import { AgendaController } from './web/controllers/agenda.controller';
import { MedecinsController } from './web/controllers/medecins.controller';
import { ClientsController } from './web/controllers/clients.controller';
import { LangueController } from './web/controllers/langue.controller';
import { InscriptionController } from './web/controllers/inscription.controller';
import { EspacePatientController } from './web/controllers/espace-patient.controller';
import { AuthModule } from './auth/auth.module';
@Module({
imports: [
TypeOrmModule.forRoot(getDatabaseConfig()),
TypeOrmModule.forFeature([Medecin, Client, Creneau, Rv]),
// AuthModule apporte /login et /logout ; il exporte UserRepository
// (injecté dans MetierService), AuthService, CaptchaService et
// LimiteurTentativesService (injectés dans les contrôleurs de src/web/)
AuthModule,
],
controllers: [
AgendaController,
MedecinsController,
ClientsController,
LangueController,
InscriptionController, // "Créer un compte client" (public)
EspacePatientController, // "Mon espace", "Gérer mon compte" (USER)
],
providers: [
MedecinRepository,
ClientRepository,
CreneauRepository,
RvRepository,
{ provide: METIER_TOKEN, useClass: MetierService },
ApplicationModelService,
],
})
export class AppModule {}
|
- ligne 38 : la connexion à la base ;
- ligne 39 : les repositories des quatre entités gérées par ce module (celui de [User] est déclaré par [AuthModule]) ;
- ligne 43 : le module d'authentification, avec ses propres contrôleur et services ;
- lignes 45-52 : les contrôleurs de la couche web ;
- lignes 53-60 : les services : la couche DAO (les quatre repositories), la couche métier, et le cache de la couche web. Ligne 58 : la couche métier est enregistrée sous un jeton ([METIER_TOKEN]) plutôt que sous le nom de sa classe : ceux qui l'utilisent dépendent de l'interface [IMetier], pas de la classe [MetierService]. On pourrait remplacer l'implémentation (par une version de test, par exemple) en ne modifiant que cette ligne.
11.8. Les entités
Les entités [TypeORM] font correspondre les tables de la base et des classes [TypeScript] (cf. exemple 28). Leurs commentaires font souvent référence au projet [Java] / [Spring] dont l'application est l'héritière lointaine, et au « serveur JSON » : le serveur [NestJS] des cours client / serveur, dont l'étude de cas reprend les couches DAO et métier.
11.8.1. src/entities/abstract.entity.ts
| // =========================================================================
// entities/abstract.entity.ts
// -------------------------------------------------------------------------
// Équivalent de la classe [AbstractEntity] du projet Spring original,
// qui était annotée @MappedSuperclass. Elle regroupe les deux champs
// communs à toutes nos entités persistantes : l'identifiant [id] et le
// numéro de version [version] utilisé pour la gestion des accès concurrents.
//
// En JPA, @MappedSuperclass indique "cette classe n'est pas une table,
// mais ses champs doivent être ajoutés aux tables des classes filles".
// TypeORM propose exactement le même mécanisme : il suffit qu'une classe
// abstraite (non décorée par @Entity) soit héritée par une classe qui,
// elle, porte l'annotation @Entity. TypeORM ajoute alors automatiquement
// les colonnes héritées dans la table de la classe fille.
// =========================================================================
import { PrimaryGeneratedColumn, VersionColumn } from 'typeorm';
export abstract class AbstractEntity {
// équivalent de :
// @Id
// @GeneratedValue(strategy = GenerationType.AUTO)
// protected Long id;
//
// @PrimaryGeneratedColumn() déclare une clé primaire auto-incrémentée par le SGBD,
// exactement comme GenerationType.AUTO le faisait déléguer au SGBD.
@PrimaryGeneratedColumn()
id: number;
// équivalent de :
// @Version
// protected Long version;
//
// @VersionColumn() est LE mécanisme natif de TypeORM pour le verrouillage optimiste :
// TypeORM incrémente automatiquement cette colonne à chaque UPDATE, et lève une erreur
// si on tente de sauvegarder une entité dont la version ne correspond plus à celle en
// base (quelqu'un d'autre l'a modifiée entre-temps). C'est un mapping direct avec le
// rôle de @Version en JPA/Hibernate.
@VersionColumn()
version: number;
}
|
- ligne 19 : une classe abstraite, sans décorateur [@Entity] : elle ne correspond à aucune table, mais ses colonnes sont ajoutées à celles des classes filles ;
- lignes 27-28 : la clé primaire, générée par la base ;
- lignes 39-40 : la version de la ligne, incrémentée par [TypeORM] à chaque modification (verrou optimiste, cf. exemple 28).
11.8.2. src/entities/personne.entity.ts
| // =========================================================================
// entities/personne.entity.ts
// -------------------------------------------------------------------------
// Classe parente (non persistée elle-même) des entités [Medecin] et
// [Client] : un titre, un nom, un prénom - et, nouveauté de la base
// [dbrdvmedecins2], 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 : TypeORM ajoute donc la
// relation [user] à la table [medecins] ET à la table [clients].
// =========================================================================
import { Column, JoinColumn, OneToOne, RelationId } from 'typeorm';
import { AbstractEntity } from './abstract.entity';
import { User } from './user.entity';
export abstract class Personne extends AbstractEntity {
@Column({ length: 5 })
titre: string; // Melle, Mme, Mr
@Column({ length: 20 })
nom: string;
@Column({ length: 20 })
prenom: string;
// @OneToOne : un médecin (ou un client) a UN compte utilisateur, et un
// compte utilisateur appartient à au plus UNE personne (la colonne [user]
// est déclarée UNIQUE dans le script SQL).
// @JoinColumn({ name: 'user' }) : c'est CETTE table (medecins / clients)
// qui porte la clé étrangère, dans la colonne nommée [user].
// Comme pour les autres relations, [user] n'est chargé que sur demande
// (option `relations`) - cf. medecin.repository.ts.
@OneToOne(() => User)
@JoinColumn({ name: 'user' })
user: User;
// la valeur brute de la clé étrangère, sans charger le User complet
// (même technique que Creneau.idMedecin, cf. creneau.entity.ts)
@RelationId((personne: Personne) => personne.user)
idUser: number;
}
|
- ligne 17 : la classe mère de [Medecin] et [Client], elle-même fille de [AbstractEntity] ;
- lignes 18-25 : titre, nom, prénom ;
- lignes 34-36 : une relation un à un vers le compte utilisateur. [@JoinColumn] indique que c'est cette table qui porte la clé étrangère, dans la colonne [user]. La relation n'est chargée que si on le demande (option [relations] des requêtes) ;
- lignes 40-41 : [@RelationId] expose la valeur de la clé étrangère (l'identifiant du compte) sans charger le compte lui-même.
11.8.3. src/entities/medecin.entity.ts
| // =========================================================================
// entities/medecin.entity.ts
// -------------------------------------------------------------------------
// Équivalent de l'entité JPA [Medecin] :
//
// @Entity
// @Table(name = "medecins")
// public class Medecin extends Personne { ... }
// =========================================================================
import { Entity, OneToMany, Unique } from 'typeorm';
import { Personne } from './personne.entity';
import { Creneau } from './creneau.entity';
// @Entity({ name: 'medecins' }) : déclare la classe comme entité TypeORM,
// persistée dans la table [medecins]. C'est l'équivalent exact de
// @Entity + @Table(name = "medecins") en JPA.
@Entity({ name: 'medecins' })
// rappel de la contrainte SQL du script database/dbrdvmedecins2.sql : deux
// médecins ne peuvent pas avoir les mêmes prénom et nom. Comme pour unq1_rv
// (cf. rv.entity.ts), c'est une documentation : synchronize=false, c'est le
// script SQL qui crée réellement la contrainte - avec CE nom, que la couche
// métier reconnaît dans les messages d'erreur MySQL (cf. metier.service.ts).
@Unique('unq_medecins_prenom_nom', ['prenom', 'nom'])
export class Medecin extends Personne {
// relation inverse (facultative, pratique pour naviguer d'un médecin vers ses créneaux).
// Le projet original ne l'avait pas définie côté [Medecin] (seulement côté [Creneau]),
// mais TypeORM la rend quasi gratuite à ajouter et elle est pédagogiquement utile :
// elle montre la relation "1 médecin -> plusieurs créneaux" dans les deux sens.
@OneToMany(() => Creneau, (creneau) => creneau.medecin)
creneaux: Creneau[];
}
|
- ligne 18 : la classe correspond à la table [medecins] ;
- ligne 24 : la contrainte d'unicité, rappelée ici pour la documentation : c'est le script SQL qui la crée réellement ([synchronize: false]) ;
- lignes 30-31 : la relation un à plusieurs vers les créneaux du médecin, inverse de la relation [@ManyToOne] de [Creneau].
11.8.4. src/entities/client.entity.ts
| // =========================================================================
// entities/client.entity.ts
// -------------------------------------------------------------------------
// Équivalent de l'entité JPA [Client] :
//
// @Entity
// @Table(name = "clients")
// public class Client extends Personne { ... }
//
// 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 pour rester fidèle au document source.
// =========================================================================
import { Entity, Unique } from 'typeorm';
import { Personne } from './personne.entity';
@Entity({ name: 'clients' })
// deux clients ne peuvent pas avoir les mêmes prénom et nom
// (cf. la même contrainte sur Medecin, medecin.entity.ts)
@Unique('unq_clients_prenom_nom', ['prenom', 'nom'])
export class Client extends Personne {}
|
Un client (un patient) : une [Personne] de la table [clients], sans champ supplémentaire.
11.8.5. src/entities/creneau.entity.ts
| // =========================================================================
// entities/creneau.entity.ts
// -------------------------------------------------------------------------
// Équivalent de l'entité JPA [Creneau] :
//
// @Entity
// @Table(name = "creneaux")
// public class Creneau extends AbstractEntity {
// private int hdebut, mdebut, hfin, mfin;
// @ManyToOne(fetch = FetchType.LAZY)
// @JoinColumn(name = "id_medecin")
// private Medecin medecin;
// @Column(name = "id_medecin", insertable = false, updatable = false)
// private long idMedecin;
// }
// =========================================================================
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from 'typeorm';
import { AbstractEntity } from './abstract.entity';
import { Medecin } from './medecin.entity';
@Entity({ name: 'creneaux' })
export class Creneau extends AbstractEntity {
@Column() hdebut: number; // heure de début du créneau
@Column() mdebut: number; // minute de début du créneau
@Column() hfin: number; // heure de fin du créneau
@Column() mfin: number; // minute de fin du créneau
// équivalent de :
// @ManyToOne(fetch = FetchType.LAZY)
// @JoinColumn(name = "id_medecin")
// private Medecin medecin;
//
// @ManyToOne : plusieurs créneaux pour un même médecin.
// Par défaut, TypeORM ne charge PAS la relation [medecin] quand on lit un créneau
// (comportement "lazy" par défaut) : il faut le demander explicitement, via l'option
// `relations: ['medecin']` d'une requête - exactement comme FetchType.LAZY imposait
// en JPA un `left join fetch` explicite dans la requête JPQL pour ramener le médecin.
@ManyToOne(() => Medecin, (medecin) => medecin.creneaux)
@JoinColumn({ name: 'id_medecin' })
medecin: Medecin;
// équivalent de :
// @Column(name = "id_medecin", insertable = false, updatable = false)
// private long idMedecin;
//
// @RelationId : c'est l'équivalent EXACT et idiomatique, côté TypeORM, du champ
// "idMedecin" en lecture seule du projet original. Il expose directement la valeur
// de la clé étrangère, SANS avoir besoin de charger l'entité [Medecin] complète.
// Très utile pour comparer des identifiants sans charger les entités liées.
@RelationId((creneau: Creneau) => creneau.medecin)
idMedecin: number;
}
|
- lignes 24-27 : l'heure et la minute de début et de fin ;
- lignes 39-41 : la relation plusieurs à un vers le médecin : plusieurs créneaux pour un médecin. La clé étrangère est la colonne [id_medecin] ;
- lignes 51-52 : l'identifiant du médecin, sans charger le médecin.
11.8.6. src/entities/rv.entity.ts
| // =========================================================================
// entities/rv.entity.ts
// -------------------------------------------------------------------------
// Équivalent de l'entité JPA [Rv] (un rendez-vous) :
//
// @Entity
// @Table(name = "rv")
// public class Rv extends AbstractEntity {
// @Temporal(TemporalType.DATE) private Date jour;
// @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "id_client") private Client client;
// @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "id_creneau") private Creneau creneau;
// @Column(name = "id_client", insertable = false, updatable = false) private long idClient;
// @Column(name = "id_creneau", insertable = false, updatable = false) private long idCreneau;
// }
//
// La contrainte d'unicité SQL sur (jour, id_creneau) - qui empêche deux
// rendez-vous sur le même créneau le même jour - est définie directement
// dans le script database/dbrdvmedecins2.sql (CONSTRAINT unq1_rv UNIQUE).
// =========================================================================
import { Column, Entity, JoinColumn, ManyToOne, RelationId, Unique } from 'typeorm';
import { AbstractEntity } from './abstract.entity';
import { Client } from './client.entity';
import { Creneau } from './creneau.entity';
@Entity({ name: 'rv' })
// rappelle, côté TypeORM, la contrainte SQL du script de BDD. Attention : on référence ici
// le nom de la RELATION ('creneau'), pas le champ @RelationId ('idCreneau') - ce dernier est
// une colonne "virtuelle" (calculée), que TypeORM n'accepte pas dans une contrainte @Unique.
// TypeORM traduit lui-même 'creneau' vers sa colonne de clé étrangère réelle (id_creneau).
@Unique('unq1_rv', ['jour', 'creneau'])
export class Rv extends AbstractEntity {
// équivalent de : @Temporal(TemporalType.DATE) private Date jour;
// type 'date' (et non 'datetime') : on ne conserve que le jour, pas l'heure,
// exactement comme TemporalType.DATE le précisait côté JPA.
@Column({ type: 'date' })
jour: string; // format "yyyy-MM-dd", ex: "2026-09-13"
@ManyToOne(() => Client)
@JoinColumn({ name: 'id_client' })
client: Client;
@ManyToOne(() => Creneau)
@JoinColumn({ name: 'id_creneau' })
creneau: Creneau;
// équivalents des champs idClient / idCreneau en lecture seule du projet original
// (cf. explication détaillée dans creneau.entity.ts à propos de @RelationId)
@RelationId((rv: Rv) => rv.client)
idClient: number;
@RelationId((rv: Rv) => rv.creneau)
idCreneau: number;
}
|
- ligne 31 : un créneau n'est réservé qu'une fois par jour ;
- lignes 36-37 : une colonne de type [DATE], manipulée comme une chaîne [aaaa-mm-jj] : pas de problème de fuseau horaire, et deux dates se comparent comme des chaînes ;
- lignes 39-45 : le client et le créneau du rendez-vous ;
- lignes 49-53 : leurs identifiants.
11.8.7. src/entities/user.entity.ts
| // =========================================================================
// entities/user.entity.ts
// -------------------------------------------------------------------------
// Un utilisateur autorisé à se connecter à l'application (table [users]).
//
// Nouveauté de la base [dbrdvmedecins2] : chaque médecin et chaque client
// possède désormais SON PROPRE compte utilisateur (colonne [user] des tables
// [medecins] et [clients], cf. personne.entity.ts). D'où un troisième rôle :
// - 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.
//
// Les rôles restent mutuellement exclusifs : une simple colonne [role]
// suffit toujours (pas de table [roles] / [users_roles] comme dans le
// projet Spring d'origine).
// =========================================================================
import { Column, Entity, Unique } from 'typeorm';
import { AbstractEntity } from './abstract.entity';
export type Role = 'ADMIN' | 'DOCTOR' | 'USER';
@Entity({ name: 'users' })
// un login n'appartient qu'à un seul compte (contrainte SQL unq_users_login)
@Unique('unq_users_login', ['login'])
export class User extends AbstractEntity {
@Column({ length: 30 })
login: string;
// le mot de passe HACHÉ (bcrypt) - jamais en clair
@Column({ length: 100 })
password: string;
// le nom affiché une fois connecté - pour un médecin ou un client :
// "prénom NOM" (ex : "Marie PELISSIER")
@Column({ length: 40 })
nom: string;
@Column({ type: 'varchar', length: 10 })
role: Role;
}
|
- ligne 21 : un type union de chaînes : une variable de type [Role] ne peut valoir que l'une de ces trois chaînes ;
- lignes 31-32 : le hachage du mot de passe ;
- lignes 36-37 : le nom affiché dans la barre de navigation (« Marie PELISSIER ») ;
- lignes 39-40 : le rôle. Le type de la colonne est précisé : [TypeORM] ne sait pas déduire un type SQL d'un type union.
11.9. 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.
11.9.1. src/domain/creneau-medecin-jour.ts
| // =========================================================================
// domain/creneau-medecin-jour.ts
// -------------------------------------------------------------------------
// Équivalent de la classe [CreneauMedecinJour] du projet Spring original :
// une entité "métier" (pas persistée en base) qui associe un créneau
// horaire au rendez-vous éventuellement pris sur ce créneau.
//
// public class CreneauMedecinJour implements Serializable {
// private Creneau creneau;
// private Rv rv; // null si le créneau est libre
// }
//
// Ce genre de classe n'a pas de table en base : elle n'existe que le temps
// du calcul de l'agenda d'un médecin, que la couche web transforme ensuite
// en lignes du tableau HTML de l'agenda (cf. agenda.controller.ts).
// =========================================================================
import { Creneau } from '../entities/creneau.entity';
import { Rv } from '../entities/rv.entity';
export class CreneauMedecinJour {
constructor(
public creneau: Creneau,
public rv: Rv | null, // null si le créneau est libre ce jour-là
) {}
}
|
lignes 22-25 : un créneau, et le rendez-vous pris sur ce créneau ce jour-là ([null] si le créneau est libre). La syntaxe [public creneau: Creneau] dans les paramètres d'un constructeur déclare et initialise le champ en une fois.
11.9.2. src/domain/agenda-medecin-jour.ts
| // =========================================================================
// domain/agenda-medecin-jour.ts
// -------------------------------------------------------------------------
// Équivalent de la classe [AgendaMedecinJour] du projet Spring original :
// l'agenda complet d'un médecin pour un jour donné, ç-à-d la liste de ses
// créneaux horaires, chacun étant libre ou occupé par un rendez-vous.
//
// public class AgendaMedecinJour implements Serializable {
// private Medecin medecin;
// private Date jour;
// private CreneauMedecinJour[] creneauxMedecinJour;
// }
// =========================================================================
import { Medecin } from '../entities/medecin.entity';
import { CreneauMedecinJour } from './creneau-medecin-jour';
export class AgendaMedecinJour {
constructor(
public medecin: Medecin,
public jour: string, // format "yyyy-MM-dd"
public creneauxMedecinJour: CreneauMedecinJour[],
) {}
}
|
L'agenda : un médecin, un jour, et la liste de ses créneaux, libres ou occupés.
11.10. La couche DAO
Une classe par table. Toutes suivent le modèle de l'exemple 28 : un [Repository] [TypeORM] injecté, une méthode [repo(manager)] qui choisit le repository de la transaction en cours, et des méthodes courtes, sans règle de gestion.
11.10.1. src/repositories/medecin.repository.ts
| // =========================================================================
// repositories/medecin.repository.ts
// -------------------------------------------------------------------------
// Couche DAO de l'entité [Medecin]. Par rapport au serveur JSON, trois
// nouveautés, toutes liées à la gestion des médecins par l'ADMIN :
// - les lectures ramènent aussi le compte utilisateur associé (relation
// [user]), pour afficher le login dans la liste des médecins ;
// - save() / deleteById() : création, modification, suppression ;
// - chaque méthode d'écriture accepte un EntityManager FACULTATIF : quand
// la couche métier le fournit, l'opération s'exécute DANS la transaction
// de cette couche (cf. metier.service.ts, dataSource.transaction(...)).
// =========================================================================
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm';
import { Medecin } from '../entities/medecin.entity';
@Injectable()
export class MedecinRepository {
constructor(
@InjectRepository(Medecin)
private readonly repository: Repository<Medecin>,
) {}
// le Repository à utiliser : celui de la transaction en cours s'il y en a
// une, le Repository "ordinaire" sinon.
private repo(manager?: EntityManager): Repository<Medecin> {
return manager ? manager.getRepository(Medecin) : this.repository;
}
// liste triée par nom puis prénom, compte utilisateur compris
findAll(): Promise<Medecin[]> {
return this.repository.find({ relations: { user: true }, order: { nom: 'ASC', prenom: 'ASC' } });
}
findById(id: number, manager?: EntityManager): Promise<Medecin | null> {
return this.repo(manager).findOne({ where: { id }, relations: { user: true } });
}
// 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.
findByPrenomNom(prenom: string, nom: string, manager?: EntityManager): Promise<Medecin | null> {
return this.repo(manager).findOneBy({ prenom, nom });
}
save(medecin: Medecin, manager?: EntityManager): Promise<Medecin> {
return this.repo(manager).save(medecin);
}
async deleteById(id: number, manager?: EntityManager): Promise<void> {
await this.repo(manager).delete(id);
}
}
|
- lignes 28-30 : dans une transaction, le repository du [manager] de la transaction ;
- lignes 33-35 : tous les médecins, avec leur compte ([relations: { user: true }]), triés par nom puis prénom. Le compte sert à afficher le login dans la liste des médecins ;
- lignes 45-47 : un homonyme éventuel. La comparaison est faite par [MySQL], selon la collation de la base ;
- lignes 49-51 et 53-55 : écriture et suppression.
11.10.2. src/repositories/client.repository.ts
| // =========================================================================
// repositories/client.repository.ts
// -------------------------------------------------------------------------
// Couche DAO de l'entité [Client] (un PATIENT du cabinet). Rigoureusement
// le même principe que medecin.repository.ts (voir ses commentaires).
// =========================================================================
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm';
import { Client } from '../entities/client.entity';
@Injectable()
export class ClientRepository {
constructor(
@InjectRepository(Client)
private readonly repository: Repository<Client>,
) {}
private repo(manager?: EntityManager): Repository<Client> {
return manager ? manager.getRepository(Client) : this.repository;
}
findAll(): Promise<Client[]> {
return this.repository.find({ relations: { user: true }, order: { nom: 'ASC', prenom: 'ASC' } });
}
findById(id: number, manager?: EntityManager): Promise<Client | null> {
return this.repo(manager).findOne({ where: { id }, relations: { user: true } });
}
// le client (patient) dont le compte utilisateur est [idUser], ou null.
// where: { user: { id } } : TypeORM traduit ce filtre sur la relation par
// un simple "WHERE clients.user = ?" (pas besoin de jointure)
findByUserId(idUser: number): Promise<Client | null> {
return this.repository.findOne({ where: { user: { id: idUser } }, relations: { user: true } });
}
// 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.ajouterRv) : 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.
verrouiller(id: number, manager: EntityManager): Promise<Client | null> {
return manager.getRepository(Client).findOne({ where: { id }, lock: { mode: 'pessimistic_write' } });
}
// 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.
findByPrenomNom(prenom: string, nom: string, manager?: EntityManager): Promise<Client | null> {
return this.repo(manager).findOneBy({ prenom, nom });
}
save(client: Client, manager?: EntityManager): Promise<Client> {
return this.repo(manager).save(client);
}
async deleteById(id: number, manager?: EntityManager): Promise<void> {
await this.repo(manager).delete(id);
}
}
|
Les mêmes méthodes que pour les médecins, plus deux :
- lignes 35-37 : le client dont le compte est donné : c'est ainsi qu'un patient connecté (son jeton contient l'identifiant de son compte) retrouve sa fiche de client. [TypeORM] traduit le filtre [where: { user: { id: idUser } }] par [WHERE clients.user = ?] ;
- lignes 45-47 : une lecture avec verrou ([lock: { mode: 'pessimistic_write' }], soit [SELECT ... FOR UPDATE] en SQL). Jusqu'à la fin de la transaction, toute autre transaction qui demande le même verrou attend. La prise de rendez-vous s'en sert pour traiter l'une après l'autre deux réservations simultanées d'un même patient.
11.10.3. src/repositories/creneau.repository.ts
| // =========================================================================
// repositories/creneau.repository.ts
// -------------------------------------------------------------------------
// Couche DAO de l'entité [Creneau]. Nouveauté : deleteByMedecin(), utilisée
// lors de la suppression d'un médecin (ses créneaux horaires disparaissent
// avec lui - cf. MetierService.supprimerMedecin).
// =========================================================================
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm';
import { Creneau } from '../entities/creneau.entity';
@Injectable()
export class CreneauRepository {
constructor(
@InjectRepository(Creneau)
private readonly repository: Repository<Creneau>,
) {}
findById(id: number): Promise<Creneau | null> {
return this.repository.findOne({ where: { id }, relations: { medecin: true } });
}
// les créneaux d'un médecin, dans l'ordre chronologique
// (même requête que dans le serveur JSON - voir ses commentaires)
getAllCreneaux(idMedecin: number): Promise<Creneau[]> {
return this.repository
.createQueryBuilder('c')
.where('c.medecin = :idMedecin', { idMedecin })
.orderBy('c.hdebut', 'ASC')
.addOrderBy('c.mdebut', 'ASC')
.getMany();
}
// supprime tous les créneaux d'un médecin. delete({ medecin: { id } })
// génère : DELETE FROM creneaux WHERE id_medecin = ?
async deleteByMedecin(idMedecin: number, manager?: EntityManager): Promise<void> {
const repo = manager ? manager.getRepository(Creneau) : this.repository;
await repo.delete({ medecin: { id: idMedecin } });
}
}
|
- lignes 27-34 : les créneaux d'un médecin, dans l'ordre chronologique. La requête est écrite avec le QueryBuilder de [TypeORM], qui construit une requête SQL méthode par méthode : [createQueryBuilder('c')] (la table, avec l'alias [c]), [where] (avec un paramètre nommé [:idMedecin], jamais une concaténation de chaînes, qui ouvrirait la porte aux injections SQL), [orderBy], puis [getMany] qui exécute la requête ;
- lignes 38-41 : la suppression des créneaux d'un médecin, utilisée quand on supprime ce médecin.
11.10.4. src/repositories/rv.repository.ts
| // =========================================================================
// repositories/rv.repository.ts
// -------------------------------------------------------------------------
// Couche DAO de l'entité [Rv] (un rendez-vous). Nouveautés :
// - le nombre de rendez-vous d'un médecin, pour REFUSER la suppression
// d'un médecin qui a encore des rendez-vous (cf. MetierService) ;
// - les rendez-vous À VENIR d'un client (page "Mon espace" du patient, et
// règle "un seul rendez-vous à venir par patient") ;
// - la suppression de tous les rendez-vous d'un client (un client est
// supprimé avec ses rendez-vous, par l'ADMIN ou par lui-même).
// =========================================================================
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm';
import { Rv } from '../entities/rv.entity';
@Injectable()
export class RvRepository {
constructor(
@InjectRepository(Rv)
private readonly repository: Repository<Rv>,
) {}
private repo(manager?: EntityManager): Repository<Rv> {
return manager ? manager.getRepository(Rv) : this.repository;
}
findById(id: number): Promise<Rv | null> {
return this.repository.findOne({ where: { id }, relations: { client: true, creneau: true } });
}
save(rv: Rv, manager?: EntityManager): Promise<Rv> {
return this.repo(manager).save(rv);
}
async deleteById(id: number): Promise<void> {
await this.repository.delete(id);
}
// les rendez-vous d'un médecin un jour donné, client et créneau compris
// (même requête que dans le serveur JSON - voir ses commentaires)
getRvMedecinJour(idMedecin: number, jour: string): Promise<Rv[]> {
return this.repository
.createQueryBuilder('rv')
.leftJoinAndSelect('rv.client', 'client')
.leftJoinAndSelect('rv.creneau', 'creneau')
.where('creneau.medecin = :idMedecin', { idMedecin })
.andWhere('rv.jour = :jour', { jour })
.getMany();
}
// 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
// (innerJoinAndSelect en cascade : rv -> creneau -> medecin)
findAVenirByClient(idClient: number, depuis: string): Promise<Rv[]> {
return this.repository
.createQueryBuilder('rv')
.innerJoinAndSelect('rv.creneau', 'creneau')
.innerJoinAndSelect('creneau.medecin', 'medecin')
.where('rv.client = :idClient', { idClient })
.andWhere('rv.jour >= :depuis', { depuis })
.orderBy('rv.jour', 'ASC')
.addOrderBy('creneau.hdebut', 'ASC')
.addOrderBy('creneau.mdebut', 'ASC')
.getMany();
}
// nombre de rendez-vous d'un client à partir du jour [depuis] (inclus)
countAVenirByClient(idClient: number, depuis: string, manager?: EntityManager): Promise<number> {
return this.repo(manager)
.createQueryBuilder('rv')
.where('rv.client = :idClient', { idClient })
.andWhere('rv.jour >= :depuis', { depuis })
.getCount();
}
// supprime TOUS les rendez-vous (passés et futurs) d'un client :
// DELETE FROM rv WHERE id_client = ?
async deleteByClient(idClient: number, manager?: EntityManager): Promise<void> {
await this.repo(manager).delete({ client: { id: idClient } });
}
// nombre de rendez-vous (passés ou futurs) d'un médecin - il faut passer
// par ses créneaux, puisque [rv] ne référence pas directement [medecins] :
// SELECT COUNT(*) FROM rv INNER JOIN creneaux c ON ... WHERE c.id_medecin = ?
countByMedecin(idMedecin: number, manager?: EntityManager): Promise<number> {
return this.repo(manager)
.createQueryBuilder('rv')
.innerJoin('rv.creneau', 'creneau')
.where('creneau.medecin = :idMedecin', { idMedecin })
.getCount();
}
}
|
- lignes 43-51 : les rendez-vous d'un médecin pour un jour. [leftJoinAndSelect] fait une jointure et charge l'entité jointe : chaque rendez-vous arrive avec son client et son créneau ;
- lignes 56-67 : les rendez-vous d'un client à partir d'un jour donné, avec leur créneau et le médecin de ce créneau (deux jointures en cascade) : c'est ce qu'affiche « Mon espace » ;
- lignes 70-76 : leur nombre, pour la règle « un seul rendez-vous à venir » ;
- lignes 80-82 : la suppression de tous les rendez-vous d'un client ;
- lignes 87-93 : le nombre de rendez-vous d'un médecin. La table [rv] ne référence pas directement les médecins : il faut passer par les créneaux ([innerJoin], une jointure sans chargement).
11.10.5. src/repositories/user.repository.ts
| // =========================================================================
// repositories/user.repository.ts
// -------------------------------------------------------------------------
// Couche DAO de l'entité [User]. Nouveauté : save() / deleteById(), car
// créer (ou supprimer) un médecin ou un client crée (ou supprime) aussi son
// compte utilisateur - cf. MetierService.
// =========================================================================
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm';
import { User } from '../entities/user.entity';
@Injectable()
export class UserRepository {
constructor(
@InjectRepository(User)
private readonly repository: Repository<User>,
) {}
private repo(manager?: EntityManager): Repository<User> {
return manager ? manager.getRepository(User) : this.repository;
}
// renvoie null si aucun utilisateur ne porte ce login
findByLogin(login: string, manager?: EntityManager): Promise<User | null> {
return this.repo(manager).findOneBy({ login });
}
save(user: User, manager?: EntityManager): Promise<User> {
return this.repo(manager).save(user);
}
async deleteById(id: number, manager?: EntityManager): Promise<void> {
await this.repo(manager).delete(id);
}
}
|
lignes 26-28 : le compte d'un login, pour la connexion et pour vérifier qu'un login est libre.
11.11. La couche métier
11.11.1. src/metier/dates.ts
| // =========================================================================
// metier/dates.ts
// -------------------------------------------------------------------------
// La date du jour au format "yyyy-MM-dd" (celui de la colonne rv.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 (cf. web/helpers/dates.helper.ts).
//
// Deux dates "yyyy-MM-dd" se comparent directement comme des chaînes :
// "2026-09-23" < "2026-10-01" - l'ordre alphabétique est l'ordre chronologique.
// =========================================================================
// la date du jour, dans le fuseau horaire du serveur
export function aujourdhui(): string {
const d = new Date();
const deuxChiffres = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${deuxChiffres(d.getMonth() + 1)}-${deuxChiffres(d.getDate())}`;
}
|
lignes 14-18 : la date du jour au format [aaaa-mm-jj], celui de la colonne [rv.jour]. [padStart(2, '0')] complète un nombre à deux chiffres (9 devient [09]). Sous ce format, l'ordre alphabétique est l'ordre chronologique : deux dates se comparent avec [<].
11.11.2. src/metier/metier.exception.ts
| // =========================================================================
// metier/metier.exception.ts
// -------------------------------------------------------------------------
// 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).
//
// La couche métier reste ainsi indépendante de la langue de l'interface -
// comme elle était déjà indépendante du format de sortie (JSON ou HTML).
// =========================================================================
export class MetierException extends Error {
constructor(
public readonly cle: string, // clé de traduction du message
public readonly params: Record<string, string> = {}, // paramètres éventuels du message
) {
super(cle);
this.name = 'MetierException';
}
}
|
- ligne 17 : une [MetierException] ne transporte pas un message, mais une clé de traduction ([ERRORS.LOGIN_TAKEN]). La couche métier ignore ainsi la langue de l'interface : c'est la vue qui traduira la clé (cf. chapitre 5) ;
- ligne 18 : les paramètres éventuels du message ;
- ligne 21 : le nom de l'exception, affiché dans les traces d'erreur.
11.11.3. src/metier/metier.interface.ts
| // =========================================================================
// metier/metier.interface.ts
// -------------------------------------------------------------------------
// Le contrat de la couche métier. Aux 11 méthodes du serveur JSON (lecture
// des listes, agenda, ajout/suppression d'un rendez-vous) s'ajoutent 2
// méthodes pour l'espace du patient et 6 méthodes de gestion des médecins
// et des clients.
// =========================================================================
import { Client } from '../entities/client.entity';
import { Creneau } from '../entities/creneau.entity';
import { Medecin } from '../entities/medecin.entity';
import { Rv } from '../entities/rv.entity';
import { AgendaMedecinJour } from '../domain/agenda-medecin-jour';
// les données saisies dans le formulaire "médecin" ou "client"
export interface DonneesPersonne {
titre: string; // Mr, Mme, Melle
nom: string;
prenom: string;
// création : facultatif, vaut par défaut le nom en minuscules
// modification : facultatif, le login est inchangé s'il est absent
login?: string;
// création : facultatif, vaut par défaut le login
// modification : facultatif, le mot de passe est inchangé s'il est absent
password?: string;
// modification : la version de la fiche au moment où le formulaire a été
// affiché (verrou optimiste, cf. AbstractEntity.version)
version?: number;
}
export interface IMetier {
// ------------------------------------------------ lectures (inchangées)
getAllClients(): Promise<Client[]>;
getAllMedecins(): Promise<Medecin[]>;
getAllCreneaux(idMedecin: number): Promise<Creneau[]>;
getRvMedecinJour(idMedecin: number, jour: string): Promise<Rv[]>;
getClientById(id: number): Promise<Client | null>;
getMedecinById(id: number): Promise<Medecin | null>;
getRvById(id: number): Promise<Rv | null>;
getCreneauById(id: number): Promise<Creneau | null>;
getAgendaMedecinJour(idMedecin: number, jour: string): Promise<AgendaMedecinJour | null>;
/** le client (patient) dont le compte utilisateur est [idUser], ou null */
getClientByUserId(idUser: number): Promise<Client | null>;
/** les rendez-vous d'un client, d'aujourd'hui ou futurs, avec créneau et médecin */
getRvAVenirClient(idClient: number): Promise<Rv[]>;
// ------------------------------------------------ rendez-vous (ADMIN, DOCTOR, USER)
/**
* 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').
*/
ajouterRv(jour: string, creneau: Creneau, client: Client): Promise<Rv>;
supprimerRv(rv: Rv): Promise<void>;
// ------------------------------------------------ médecins (ADMIN)
/** crée le médecin ET son compte utilisateur (rôle DOCTOR) */
ajouterMedecin(donnees: DonneesPersonne): Promise<Medecin>;
/** modifie le médecin ET son compte utilisateur */
modifierMedecin(id: number, donnees: DonneesPersonne): Promise<Medecin>;
/** supprime le médecin, ses créneaux ET son compte - refusé s'il a des rendez-vous */
supprimerMedecin(id: number): Promise<void>;
// ------------------------------------------------ clients
// (l'ADMIN ; ajouterClient sert aussi à l'inscription d'un patient,
// modifierClient et supprimerClient au patient pour son propre compte)
/** crée le client ET son compte utilisateur (rôle USER) */
ajouterClient(donnees: DonneesPersonne): Promise<Client>;
modifierClient(id: number, donnees: DonneesPersonne): Promise<Client>;
/**
* 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").
*/
supprimerClient(id: number): Promise<void>;
}
export const METIER_TOKEN = 'IMetier';
|
- lignes 17-30 : les données d'un médecin ou d'un client à créer ou modifier. Login et mot de passe sont facultatifs : à la création, ils valent par défaut le nom en minuscules ; à la modification, ils ne changent que s'ils ont été saisis ;
- lignes 32-78 : le contrat de la couche métier : ce que les autres couches peuvent lui demander. Les commentaires [/** ... */] sont des commentaires de documentation, affichés par [VS Code] quand on survole la méthode ;
- ligne 80 : le jeton sous lequel l'implémentation est enregistrée dans [app.module.ts]. Une interface [TypeScript] disparaît à la compilation : elle ne peut pas servir à l'injection de dépendances. On injecte donc par ce jeton : [@Inject(METIER_TOKEN)].
11.11.4. src/metier/metier.service.ts
| // =========================================================================
// metier/metier.service.ts
// -------------------------------------------------------------------------
// Implémentation de la couche métier.
//
// Les lectures et l'agenda sont repris tels quels du serveur JSON. La
// nouveauté est la gestion des médecins et des clients, qui 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 xxxPersonne), et paramétré par un
// petit "descripteur" qui dit à quel type de personne on a affaire.
// =========================================================================
import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager, QueryFailedError } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import { DonneesPersonne, IMetier } from './metier.interface';
import { MetierException } from './metier.exception';
import { MedecinRepository } from '../repositories/medecin.repository';
import { ClientRepository } from '../repositories/client.repository';
import { CreneauRepository } from '../repositories/creneau.repository';
import { RvRepository } from '../repositories/rv.repository';
import { UserRepository } from '../repositories/user.repository';
import { Client } from '../entities/client.entity';
import { Creneau } from '../entities/creneau.entity';
import { Medecin } from '../entities/medecin.entity';
import { Personne } from '../entities/personne.entity';
import { Rv } from '../entities/rv.entity';
import { Role, User } from '../entities/user.entity';
import { AgendaMedecinJour } from '../domain/agenda-medecin-jour';
import { CreneauMedecinJour } from '../domain/creneau-medecin-jour';
import { aujourdhui } from './dates';
// nombre de tours de salage bcrypt - le même que dans le script SQL ($2b$10$...)
const TOURS_BCRYPT = 10;
// ce que les deux DAO MedecinRepository et ClientRepository ont en commun
// (TypeScript vérifie la compatibilité par la FORME des objets : les deux
// classes satisfont cette interface sans avoir à la déclarer)
interface PersonneDao<T extends Personne> {
findById(id: number, manager?: EntityManager): Promise<T | null>;
findByPrenomNom(prenom: string, nom: string, manager?: EntityManager): Promise<T | null>;
save(personne: T, manager?: EntityManager): Promise<T>;
deleteById(id: number, manager?: EntityManager): Promise<void>;
}
// tout ce qui distingue un médecin d'un client pour les règles de gestion
interface DescripteurPersonne<T extends Personne> {
dao: PersonneDao<T>;
fabriquer: () => T; // new Medecin() ou new Client()
role: Role; // DOCTOR ou USER
cleIntrouvable: string; // message si la personne n'existe pas
cleExisteDeja: string; // 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
compterRv?: (id: number, manager: EntityManager) => Promise<number>;
cleAvecRv?: string;
// facultatif : ce qu'il faut supprimer AVANT la personne (les lignes qui la
// référencent par une clé étrangère)
avantSuppression?: (id: number, manager: EntityManager) => Promise<void>;
}
@Injectable()
export class MetierService implements IMetier {
private readonly medecinDescripteur: DescripteurPersonne<Medecin>;
private readonly clientDescripteur: DescripteurPersonne<Client>;
constructor(
// DataSource : la connexion TypeORM elle-même, fournie par
// TypeOrmModule.forRoot(...). On ne s'en sert QUE pour ouvrir des
// transactions (dataSource.transaction(...)) - les requêtes, elles,
// restent dans la couche DAO.
private readonly dataSource: DataSource,
private readonly medecinRepository: MedecinRepository,
private readonly clientRepository: ClientRepository,
private readonly creneauRepository: CreneauRepository,
private readonly rvRepository: RvRepository,
private readonly userRepository: UserRepository,
) {
this.medecinDescripteur = {
dao: medecinRepository,
fabriquer: () => new Medecin(),
role: 'DOCTOR',
cleIntrouvable: 'ERRORS.DOCTOR_NOT_FOUND',
cleExisteDeja: 'ERRORS.DOCTOR_ALREADY_EXISTS',
compterRv: (id, manager) => rvRepository.countByMedecin(id, manager),
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: (id, manager) => creneauRepository.deleteByMedecin(id, manager),
};
this.clientDescripteur = {
dao: clientRepository,
fabriquer: () => new Client(),
role: 'USER',
cleIntrouvable: 'ERRORS.PATIENT_NOT_FOUND',
cleExisteDeja: '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: (id, manager) => rvRepository.deleteByClient(id, manager),
};
}
// =======================================================================
// lectures - inchangées par rapport au serveur JSON
// =======================================================================
getAllClients(): Promise<Client[]> {
return this.clientRepository.findAll();
}
getAllMedecins(): Promise<Medecin[]> {
return this.medecinRepository.findAll();
}
getAllCreneaux(idMedecin: number): Promise<Creneau[]> {
return this.creneauRepository.getAllCreneaux(idMedecin);
}
getRvMedecinJour(idMedecin: number, jour: string): Promise<Rv[]> {
return this.rvRepository.getRvMedecinJour(idMedecin, jour);
}
getClientById(id: number): Promise<Client | null> {
return this.clientRepository.findById(id);
}
getMedecinById(id: number): Promise<Medecin | null> {
return this.medecinRepository.findById(id);
}
getRvById(id: number): Promise<Rv | null> {
return this.rvRepository.findById(id);
}
getCreneauById(id: number): Promise<Creneau | null> {
return this.creneauRepository.findById(id);
}
getClientByUserId(idUser: number): Promise<Client | null> {
return this.clientRepository.findByUserId(idUser);
}
getRvAVenirClient(idClient: number): Promise<Rv[]> {
return this.rvRepository.findAVenirByClient(idClient, aujourdhui());
}
// même algorithme que le serveur JSON (et que le projet Spring d'origine)
async getAgendaMedecinJour(idMedecin: number, jour: string): Promise<AgendaMedecinJour | null> {
const medecin = await this.getMedecinById(idMedecin);
if (medecin === null) {
return null;
}
const creneauxHoraires = await this.getAllCreneaux(idMedecin);
const reservations = await this.getRvMedecinJour(idMedecin, jour);
const hReservations = new Map<number, Rv>();
for (const resa of reservations) {
hReservations.set(resa.idCreneau, resa);
}
const creneauxMedecinJour = creneauxHoraires.map(
(creneau) => new CreneauMedecinJour(creneau, hReservations.get(creneau.id) ?? null),
);
return new AgendaMedecinJour(medecin, jour, creneauxMedecinJour);
}
// =======================================================================
// 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 transaction().
// -----------------------------------------------------------------------
ajouterRv(jour: string, creneau: Creneau, client: Client): Promise<Rv> {
return this.transaction(async (manager) => {
const aujourd = aujourdhui();
if (jour < aujourd) {
throw new MetierException('ERRORS.PAST_DATE');
}
if ((await this.clientRepository.verrouiller(client.id, manager)) === null) {
throw new MetierException('ERRORS.PATIENT_NOT_FOUND');
}
if ((await this.rvRepository.countAVenirByClient(client.id, aujourd, manager)) > 0) {
throw new MetierException('ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT');
}
const rv = new Rv();
rv.jour = jour;
rv.creneau = creneau;
rv.client = client;
return this.rvRepository.save(rv, manager);
});
}
async supprimerRv(rv: Rv): Promise<void> {
await this.rvRepository.deleteById(rv.id);
}
// =======================================================================
// médecins et clients - les méthodes publiques délèguent aux méthodes
// privées communes, avec le bon descripteur
// =======================================================================
ajouterMedecin(donnees: DonneesPersonne): Promise<Medecin> {
return this.ajouterPersonne(this.medecinDescripteur, donnees);
}
modifierMedecin(id: number, donnees: DonneesPersonne): Promise<Medecin> {
return this.modifierPersonne(this.medecinDescripteur, id, donnees);
}
supprimerMedecin(id: number): Promise<void> {
return this.supprimerPersonne(this.medecinDescripteur, id);
}
ajouterClient(donnees: DonneesPersonne): Promise<Client> {
return this.ajouterPersonne(this.clientDescripteur, donnees);
}
modifierClient(id: number, donnees: DonneesPersonne): Promise<Client> {
return this.modifierPersonne(this.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)
supprimerClient(id: number): Promise<void> {
return this.supprimerPersonne(this.clientDescripteur, id);
}
// -----------------------------------------------------------------------
// création : le compte utilisateur d'abord (la personne le référence),
// puis la personne - le tout dans UNE transaction.
// -----------------------------------------------------------------------
private ajouterPersonne<T extends Personne>(d: DescripteurPersonne<T>, donnees: DonneesPersonne): Promise<T> {
const nom = donnees.nom.trim().toUpperCase();
const 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
const login = (donnees.login || nom).toLowerCase();
const password = donnees.password || login;
return this.transaction(async (manager) => {
await this.verifierPersonneUnique(d, prenom, nom, null, manager);
await this.verifierLoginLibre(login, manager);
const user = new User();
user.login = login;
user.password = await bcrypt.hash(password, TOURS_BCRYPT);
user.nom = `${prenom} ${nom}`;
user.role = d.role;
await this.userRepository.save(user, manager);
const personne = d.fabriquer();
personne.titre = donnees.titre;
personne.nom = nom;
personne.prenom = prenom;
personne.user = user;
return d.dao.save(personne, manager);
});
}
// -----------------------------------------------------------------------
// 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 modifierPersonne<T extends Personne>(
d: DescripteurPersonne<T>,
id: number,
donnees: DonneesPersonne,
): Promise<T> {
return this.transaction(async (manager) => {
const personne = await d.dao.findById(id, manager);
if (personne === null) {
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.
if (donnees.version !== undefined && donnees.version !== personne.version) {
throw new MetierException('ERRORS.CONCURRENT_UPDATE');
}
personne.titre = donnees.titre;
personne.nom = donnees.nom.trim().toUpperCase();
personne.prenom = donnees.prenom.trim();
await this.verifierPersonneUnique(d, personne.prenom, personne.nom, id, manager);
const user = personne.user;
user.nom = `${personne.prenom} ${personne.nom}`;
if (donnees.login && donnees.login.toLowerCase() !== user.login) {
const login = donnees.login.toLowerCase();
await this.verifierLoginLibre(login, manager);
user.login = login;
}
if (donnees.password) {
user.password = await bcrypt.hash(donnees.password, TOURS_BCRYPT);
}
await this.userRepository.save(user, manager);
return d.dao.save(personne, manager);
});
}
// -----------------------------------------------------------------------
// 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 supprimerPersonne<T extends Personne>(d: DescripteurPersonne<T>, id: number): Promise<void> {
return this.transaction(async (manager) => {
const personne = await d.dao.findById(id, manager);
if (personne === null) {
throw new MetierException(d.cleIntrouvable);
}
if (d.compterRv && (await d.compterRv(id, manager)) > 0) {
throw new MetierException(d.cleAvecRv ?? 'ERRORS.INVALID_FORM');
}
if (d.avantSuppression) {
await d.avantSuppression(id, manager);
}
await d.dao.deleteById(id, manager);
await this.userRepository.deleteById(personne.idUser, manager);
});
}
// 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 async verifierPersonneUnique<T extends Personne>(
d: DescripteurPersonne<T>,
prenom: string,
nom: string,
idCourant: number | null,
manager: EntityManager,
): Promise<void> {
const homonyme = await d.dao.findByPrenomNom(prenom, nom, manager);
if (homonyme !== null && homonyme.id !== idCourant) {
throw new MetierException(d.cleExisteDeja);
}
}
private async verifierLoginLibre(login: string, manager: EntityManager): Promise<void> {
if ((await this.userRepository.findByLogin(login, manager)) !== null) {
throw new MetierException('ERRORS.LOGIN_TAKEN');
}
}
// -----------------------------------------------------------------------
// exécute [travail] dans une transaction : dataSource.transaction() fait
// START TRANSACTION, appelle [travail] avec un EntityManager lié à cette
// transaction, puis COMMIT si tout s'est bien passé - ou ROLLBACK si une
// exception a été levée (MetierException comprise).
//
// 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 transaction<R>(travail: (manager: EntityManager) => Promise<R>): Promise<R> {
try {
return await this.dataSource.transaction(travail);
} catch (e) {
throw traduireDoublon(e);
}
}
}
// -------------------------------------------------------------------------
// Violations de contraintes d'unicité -> MetierException
//
// Quand une contrainte UNIQUE refuse une écriture, MySQL renvoie l'erreur
// ER_DUP_ENTRY (n° 1062), dont le 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 : chacune peut alors
// être traduite en son propre message.
// -------------------------------------------------------------------------
const MESSAGES_CONTRAINTES: Record<string, string> = {
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',
};
// renvoie la MetierException correspondant à la contrainte violée, ou
// l'erreur d'origine, inchangée, si ce n'est pas une violation connue
function traduireDoublon(e: unknown): unknown {
if (!(e instanceof QueryFailedError)) {
return e;
}
const erreurSql = e.driverError as { code?: string; sqlMessage?: string };
if (erreurSql?.code !== 'ER_DUP_ENTRY') {
return e;
}
// "... for key 'medecins.unq_medecins_prenom_nom'" -> "unq_medecins_prenom_nom"
const contrainte = /for key '(?:[^'.]*\.)?([^']+)'/.exec(erreurSql.sqlMessage ?? e.message)?.[1];
const cle = contrainte ? MESSAGES_CONTRAINTES[contrainte] : undefined;
return cle ? new MetierException(cle) : e;
}
|
C'est le fichier le plus riche de l'application. Commentons-le par parties.
11.11.4.1. Les descripteurs
- ligne 54 : le coût de bcrypt, le même que dans le script SQL ;
- lignes 59-64 : ce que les DAO des médecins et des clients ont en commun. [TypeScript] compare les types par leur forme : [MedecinRepository] et [ClientRepository] satisfont cette interface sans la déclarer ([implements]) ;
- lignes 67-80 : tout ce qui distingue un médecin d'un client pour les règles de gestion : son DAO, la façon de fabriquer une instance, le rôle de son compte, 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 fois, dans des méthodes génériques paramétrées par un descripteur ;
- lignes 87-98 : le constructeur reçoit la [DataSource] (pour ouvrir des transactions) et les cinq DAO. Il construit les deux descripteurs. Celui des médecins (lignes 99-111) refuse la suppression d'un médecin qui a des rendez-vous ([compterRv]), et supprime ses créneaux avant lui ([avantSuppression]). Celui des clients (lignes 112-122) n'a pas de [compterRv] : un client est supprimé avec ses rendez-vous.
11.11.4.2. Les lectures et l’agenda
- lignes 129-167 : les lectures délèguent à la couche DAO ;
- lignes 170-185 : le calcul de l'agenda. On lit les créneaux du médecin et les rendez-vous du jour ; les rendez-vous sont rangés dans une [Map] indexée par l'identifiant de leur créneau ; puis chaque créneau est associé à son rendez-vous, ou à [null]. La [Map] évite de parcourir la liste des rendez-vous pour chaque créneau.
11.11.4.3. La prise de rendez-vous
- lignes 203-221 : tout se passe dans une transaction ;
- lignes 206-208 : pas de rendez-vous dans le passé ;
- lignes 209-211 : la ligne du patient est verrouillée. Sans ce verrou, 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 le verrou, la seconde transaction attend la fin de la première, puis voit le rendez-vous qu'elle a créé ;
- lignes 212-214 : un seul rendez-vous à venir par patient ;
- lignes 215-219 : l'insertion. Si un autre patient vient de prendre le même créneau, c'est la contrainte [unq1_rv] qui refuse l'insertion, et la méthode [transaction] traduit l'erreur (cf. plus bas) ;
- lignes 223-225 : la suppression d'un rendez-vous.
11.11.4.4. La gestion des médecins et des clients
- lignes 232-256 : les six méthodes publiques délèguent aux trois méthodes génériques, avec le bon descripteur ;
- lignes 262-288 : la création. Le nom est mis en majuscules ; le login vaut par défaut le nom en minuscules, et le mot de passe le login (lignes 267-268). Dans la transaction : vérification des règles d'unicité, création du compte (le mot de passe est haché), puis de la personne, qui référence le compte. Si la seconde écriture échoue, la première est annulée : on n'obtient jamais un compte sans personne ;
- lignes 295-299 : la modification. Le verrou optimiste (lignes 308-310) refuse une fiche modifiée entre-temps. Le nom affiché du compte suit le prénom et le nom ; le login et le mot de passe ne changent que s'ils ont été saisis ;
- lignes 341-356 : la suppression, dans l'ordre imposé par les clés étrangères : refus éventuel (médecin qui a des rendez-vous), suppression de ce qui référence la personne (créneaux d'un médecin, rendez-vous d'un client), de la personne, puis de son compte ;
- lignes 361-367 : un homonyme est refusé, sauf s'il s'agit de la personne elle-même (modification) ;
- lignes 374-378 : un login déjà pris est refusé.
11.11.4.5. Les transactions et les contraintes d’unicité
- lignes 392-398 : toutes les écritures passent par cette méthode : [dataSource.transaction] valide ou annule la transaction (cf. exemple 28), et une erreur est traduite par [traduireDoublon] ;
- lignes 411-416 : à chaque contrainte d'unicité nommée du script SQL, son message d'erreur ;
- lignes 420-432 : une erreur SQL ([QueryFailedError]) de code [ER_DUP_ENTRY] est une violation de contrainte d'unicité. Son message cite le nom de la contrainte (préfixé par le nom de la table avec [MySQL] 8, sans préfixe avec [MariaDB]) : l'expression régulière de la ligne 429 l'extrait. Une contrainte connue devient une [MetierException] ; toute autre erreur est rendue telle quelle : c'est une vraie anomalie.
11.12. 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 de compte.
11.12.1. src/auth/auth.constants.ts
| // =========================================================================
// auth/auth.constants.ts
// -------------------------------------------------------------------------
// Où le jeton JWT voyage-t-il ?
//
// Avec le client Angular, c'était le code JavaScript du client qui gardait
// le jeton (localStorage) 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.
// =========================================================================
import { CookieOptions } from 'express';
export const COOKIE_JWT = 'rdvmedecins_jwt';
export const JWT_SECRET = process.env.JWT_SECRET ?? 'dev-secret-a-changer-en-production';
export const JWT_EXPIRES_IN_SECONDS = process.env.JWT_EXPIRES_IN_SECONDS
? parseInt(process.env.JWT_EXPIRES_IN_SECONDS, 10)
: 7200;
export function optionsCookieJwt(): CookieOptions {
return {
httpOnly: true,
sameSite: 'lax',
secure: process.env.COOKIE_SECURE === 'true',
maxAge: JWT_EXPIRES_IN_SECONDS * 1000, // en millisecondes pour Express
path: '/',
};
}
|
- lignes 4-21 : le commentaire rappelle pourquoi le jeton voyage dans un cookie, et le rôle de chacune de ses options (cf. exemple 25) ;
- ligne 28 : la clé, lue dans [.env]. La valeur par défaut ne doit servir qu'en développement ;
- lignes 30-32 : la durée de vie du jeton : deux heures par défaut ;
- lignes 34-42 : les options du cookie, réunies en un seul endroit (elles servent à la connexion et à la modification du compte d'un patient). [secure] n'est activé qu'en production, où le site est servi en HTTPS ; [maxAge] est en millisecondes.
11.12.2. src/auth/auth.service.ts
| // =========================================================================
// auth/auth.service.ts
// -------------------------------------------------------------------------
// La logique d'authentification :
// - validerUtilisateur() : vérifie un couple login / mot de passe ;
// - creerJeton() : fabrique le jeton JWT d'un utilisateur validé ;
// - lireJeton() : vérifie un jeton (page de connexion : si
// l'utilisateur est déjà connecté, inutile de lui
// redemander son mot de passe).
// =========================================================================
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { UserRepository } from '../repositories/user.repository';
import { User } from '../entities/user.entity';
import { JwtPayload } from './jwt.strategy';
@Injectable()
export class AuthService {
constructor(
private readonly userRepository: UserRepository,
private readonly jwtService: JwtService,
) {}
async validerUtilisateur(login: string, password: string): Promise<User | null> {
const user = await this.userRepository.findByLogin(login);
if (user === null) {
return null;
}
return (await bcrypt.compare(password, user.password)) ? user : null;
}
creerJeton(user: User): string {
const payload: JwtPayload = { sub: user.id, login: user.login, nom: user.nom, role: user.role };
return this.jwtService.sign(payload);
}
// renvoie le contenu du jeton s'il est valide (signature correcte, non
// expiré), null sinon
lireJeton(jeton: string | undefined): JwtPayload | null {
if (!jeton) {
return null;
}
try {
return this.jwtService.verify<JwtPayload>(jeton);
} catch {
return null;
}
}
}
|
- lignes 26-32 : le compte si le login et le mot de passe sont corrects ([bcrypt.compare]), [null] sinon ;
- lignes 34-37 : le jeton, qui contient l'identifiant, le login, le nom affiché et le rôle ;
- lignes 41-50 : [jwtService.verify] vérifie la signature et la date d'expiration d'un jeton, et lève une exception s'il est invalide. Cette méthode sert aux pages non protégées (la page de connexion) qui veulent savoir si l'utilisateur est déjà connecté.
11.12.3. src/auth/jwt.strategy.ts
| // =========================================================================
// auth/jwt.strategy.ts
// -------------------------------------------------------------------------
// La stratégie Passport qui authentifie CHAQUE requête protégée à partir du
// jeton JWT. Seule différence avec le serveur JSON : le jeton est lu dans le
// cookie [rdvmedecins_jwt] (cf. auth.constants.ts) au lieu de l'entête
// "Authorization: Bearer ...".
// =========================================================================
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Request } from 'express';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Role } from '../entities/user.entity';
import { COOKIE_JWT, JWT_SECRET } from './auth.constants';
// ce qu'on range DANS le jeton au moment de sa fabrication (cf. AuthService).
// Le nom affiché y figure aussi, pour que la barre de navigation puisse
// l'afficher sans interroger la base à chaque page.
export interface JwtPayload {
sub: number; // l'id de l'utilisateur (convention JWT)
login: string;
nom: string;
role: Role;
}
// ce que request.user contiendra une fois le jeton vérifié
export interface UtilisateurConnecte {
userId: number;
login: string;
nom: string;
role: Role;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor() {
super({
// fromExtractors([...]) : on fournit NOTRE fonction d'extraction du
// jeton - ici, la lecture d'un cookie (req.cookies est rempli par le
// middleware cookie-parser, cf. main.ts).
jwtFromRequest: ExtractJwt.fromExtractors([(req: Request) => req?.cookies?.[COOKIE_JWT] ?? null]),
ignoreExpiration: false,
secretOrKey: JWT_SECRET,
});
}
validate(payload: JwtPayload): UtilisateurConnecte {
return { userId: payload.sub, login: payload.login, nom: payload.nom, role: payload.role };
}
}
|
- lignes 20-25 : le contenu du jeton ;
- lignes 28-33 : ce que contiendra [req.user] ;
- lignes 38-45 : le jeton est lu dans le cookie ; expiré ou mal signé, il est refusé ;
- lignes 48-50 : le contenu du jeton devient [req.user].
11.12.4. src/auth/jwt-auth.guard.ts
| // =========================================================================
// auth/jwt-auth.guard.ts
// -------------------------------------------------------------------------
// Le garde qui protège toutes les pages de l'application (sauf /login) : il
// exige un jeton JWT valide (cf. jwt.strategy.ts, enregistrée sous le nom
// 'jwt'). Sans jeton, ou avec un jeton invalide/expiré, il lève une
// UnauthorizedException - que HtmlExceptionFilter transforme en redirection
// vers la page de connexion (cf. web/html-exception.filter.ts).
// =========================================================================
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
La garde qui exige un jeton valide (cf. exemple 25). Sans jeton, elle lève une [UnauthorizedException], que le filtre d'exceptions transforme en redirection vers [/login].
11.12.5. src/auth/roles.decorator.ts
| // =========================================================================
// auth/roles.decorator.ts
// -------------------------------------------------------------------------
// @Roles('ADMIN') est un décorateur MAISON (nous l'écrivons nous-mêmes,
// contrairement à @Get/@Post/@Injectable fournis par NestJS) : il se
// contente d'attacher une métadonnée ("cette méthode exige tel rôle") sur
// la méthode qu'il décore, sans rien vérifier lui-même - c'est RolesGuard
// (cf. roles.guard.ts) qui lira cette métadonnée et fera le contrôle.
//
// SetMetadata(clé, valeur) est la fonction bas niveau de NestJS qui permet
// d'écrire ce genre de décorateur "à soi" : on l'utilise ici pour ne pas
// avoir à répéter la chaîne 'roles' partout dans le code.
// =========================================================================
import { SetMetadata } from '@nestjs/common';
import { Role } from '../entities/user.entity';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
ligne 19 : le décorateur [@Roles(...)] (cf. exemple 26). Ses paramètres sont de type [Role] : [@Roles('ADMIM')] (faute de frappe) est refusé par le compilateur.
11.12.6. src/auth/roles.guard.ts
| // =========================================================================
// auth/roles.guard.ts
// -------------------------------------------------------------------------
// Second niveau de contrôle, APRÈS JwtAuthGuard : celui-ci vérifie
// seulement qu'on est bien authentifié (un jeton JWT valide) ; RolesGuard
// vérifie en plus que le rôle de l'utilisateur connecté (request.user.role,
// déposé par JwtStrategy) figure bien parmi ceux exigés par @Roles(...)
// sur la méthode visée.
//
// Inchangé par rapport au serveur JSON, sauf la destination de l'erreur :
// la ForbiddenException levée ici est transformée en PAGE "403 - accès
// interdit" par HtmlExceptionFilter (cf. web/html-exception.filter.ts).
//
// C'est l'équivalent, en esprit, de la règle Spring Security :
// http.authorizeRequests().antMatchers(...).hasRole("ADMIN")
// mais appliqué ici méthode par méthode plutôt qu'URL par URL, grâce au
// couple décorateur (@Roles) + Reflector (qui sait relire les métadonnées
// posées par un décorateur).
// =========================================================================
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from './roles.decorator';
import { Role } from '../entities/user.entity';
import { UtilisateurConnecte } from './jwt.strategy';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// on relit la métadonnée posée par @Roles(...) sur la méthode ciblée -
// getAllAndOverride cherche d'abord sur la méthode, puis sur la classe.
const rolesRequis = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [context.getHandler(), context.getClass()]);
// aucune métadonnée @Roles(...) sur cette méthode : pas de restriction
// de rôle supplémentaire (JwtAuthGuard, lui, aura déjà exigé une
// authentification valide, quel que soit le rôle).
if (!rolesRequis || rolesRequis.length === 0) {
return true;
}
// request.user a été déposé par JwtStrategy.validate() (cf. jwt.strategy.ts) -
// RolesGuard s'exécute donc TOUJOURS après JwtAuthGuard (cf. leur ordre
// dans @UseGuards(JwtAuthGuard, RolesGuard), cf. src/web/controllers/).
const request = context.switchToHttp().getRequest<{ user: UtilisateurConnecte }>();
const utilisateur = request.user;
if (!rolesRequis.includes(utilisateur.role)) {
throw new ForbiddenException(
`le rôle [${utilisateur.role}] ne permet pas cette action (rôle requis : ${rolesRequis.join(', ')})`,
);
}
return true;
}
}
|
La garde des rôles, identique à celle de l'exemple 26.
11.12.7. src/auth/captcha.service.ts
| // =========================================================================
// auth/captcha.service.ts
// -------------------------------------------------------------------------
// Un CAPTCHA ("test pour distinguer un humain d'un robot") fabriqué par le
// serveur lui-même, sans service extérieur : la bibliothèque svg-captcha
// dessine 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.
//
// 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.
//
// Limite assumée : la mémoire d'un seul processus Node. Avec plusieurs
// serveurs derrière un répartiteur de charge, il faudrait un stockage
// partagé (Redis, base de données...).
// =========================================================================
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'crypto';
import * as svgCaptcha from 'svg-captcha';
// durée de validité d'une image : on a 10 minutes pour remplir le formulaire
const DUREE_VIE_MS = 10 * 60 * 1000;
// garde-fou mémoire : au-delà, on oublie les captchas les plus anciens
const MAX_CAPTCHAS = 10_000;
export interface Captcha {
id: string; // à placer dans le champ caché "captchaId" du formulaire
svg: string; // l'image, à insérer telle quelle dans la page
}
@Injectable()
export class CaptchaService {
// id -> { réponse attendue, date d'expiration }. Une Map conserve l'ordre
// d'insertion : ses premières entrées sont les plus anciennes.
private readonly enAttente = new Map<string, { texte: string; expire: number }>();
generer(): Captcha {
this.purger();
const { text, data } = svgCaptcha.create({
size: 5, // 5 caractères
noise: 3, // 3 traits de brouillage
color: true,
background: '#f5f7fa',
// caractères trop faciles à confondre entre eux : exclus
ignoreChars: '0oO1iIlL',
});
const id = randomUUID();
this.enAttente.set(id, { texte: text.toLowerCase(), expire: Date.now() + DUREE_VIE_MS });
return { id, svg: data };
}
// vrai si [saisie] est la bonne réponse au captcha [id] (sans tenir compte
// des majuscules). Le captcha est consommé dans tous les cas.
verifier(id: unknown, saisie: unknown): boolean {
if (typeof id !== 'string' || typeof saisie !== 'string') {
return false;
}
const attendu = this.enAttente.get(id);
this.enAttente.delete(id);
return attendu !== undefined && attendu.expire > Date.now() && attendu.texte === saisie.trim().toLowerCase();
}
private purger(): void {
const maintenant = Date.now();
for (const [id, { expire }] of this.enAttente) {
if (expire <= maintenant || this.enAttente.size >= MAX_CAPTCHAS) {
this.enAttente.delete(id);
}
}
}
}
|
- lignes 29-31 : par rapport à l'exemple 27, un captcha a une durée de vie (dix minutes), et leur nombre en mémoire est plafonné : un robot qui demanderait la page de connexion des millions de fois ne pourrait pas épuiser la mémoire du serveur ;
- ligne 42 : une [Map] conserve l'ordre d'insertion : ses premières entrées sont les plus anciennes ;
- lignes 44-57 : l'image, sa réponse attendue (en minuscules) et sa date d'expiration ;
- lignes 61-68 : la réponse est comparée sans tenir compte des majuscules ; le captcha est consommé dans tous les cas ;
- lignes 70-77 : les captchas expirés sont oubliés, et les plus anciens aussi si le plafond est atteint.
11.12.8. src/auth/limiteur-tentatives.service.ts
| // =========================================================================
// auth/limiteur-tentatives.service.ts
// -------------------------------------------------------------------------
// 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 LOGIN_MAX_ECHECS échecs (mauvais captcha ou mauvais
// mot de passe) pour un même login depuis une même adresse IP, ce couple
// est bloqué LOGIN_BLOCAGE_MINUTES 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 INSCRIPTION_MAX_PAR_HEURE 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 le fichier .env.
//
// Comme pour le captcha, les compteurs vivent dans la mémoire du processus.
// =========================================================================
import { Injectable } from '@nestjs/common';
interface Compteur {
nombre: number; // tentatives comptées dans la fenêtre en cours
debut: number; // début de la fenêtre de comptage (ms)
bloqueJusqua: number; // 0 si pas bloqué
}
const MINUTE = 60 * 1000;
function entierEnv(nom: string, defaut: number): number {
const valeur = parseInt(process.env[nom] ?? '', 10);
return Number.isInteger(valeur) && valeur > 0 ? valeur : defaut;
}
@Injectable()
export class LimiteurTentativesService {
private readonly compteurs = new Map<string, Compteur>();
private readonly maxEchecsLogin = entierEnv('LOGIN_MAX_ECHECS', 5);
private readonly dureeBlocageLogin = entierEnv('LOGIN_BLOCAGE_MINUTES', 15) * MINUTE;
private readonly maxInscriptions = entierEnv('INSCRIPTION_MAX_PAR_HEURE', 10);
// ---------------------------------------------------------------- connexion
// nombre de minutes de blocage restantes (0 = la tentative est autorisée)
minutesBlocageLogin(ip: string, login: string): number {
return Math.max(this.minutesRestantes(cleLogin(ip, login)), this.minutesRestantes(cleIp(ip)));
}
echecLogin(ip: string, login: string): void {
this.compter(cleLogin(ip, login), this.maxEchecsLogin, this.dureeBlocageLogin, this.dureeBlocageLogin);
this.compter(cleIp(ip), this.maxEchecsLogin * 5, this.dureeBlocageLogin, this.dureeBlocageLogin);
}
// une connexion réussie remet à zéro le compteur de CE login
reussiteLogin(ip: string, login: string): void {
this.compteurs.delete(cleLogin(ip, login));
}
// ---------------------------------------------------------------- inscription
minutesBlocageInscription(ip: string): number {
return this.minutesRestantes(`inscription|${ip}`);
}
inscriptionReussie(ip: string): void {
// fenêtre d'une heure ; une fois le quota atteint, blocage jusqu'à la fin de l'heure
this.compter(`inscription|${ip}`, this.maxInscriptions, 60 * MINUTE, 60 * MINUTE);
}
// ---------------------------------------------------------------- mécanique commune
private minutesRestantes(cle: string): number {
const c = this.compteurs.get(cle);
const reste = c ? c.bloqueJusqua - Date.now() : 0;
return reste > 0 ? Math.ceil(reste / MINUTE) : 0;
}
// compte une tentative ; si le seuil est atteint dans la fenêtre, bloque
private compter(cle: string, max: number, fenetre: number, blocage: number): void {
const maintenant = Date.now();
this.purger(maintenant);
let c = this.compteurs.get(cle);
if (!c || maintenant - c.debut > fenetre) {
c = { nombre: 0, debut: maintenant, bloqueJusqua: 0 };
this.compteurs.set(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 purger(maintenant: number): void {
if (this.compteurs.size < 1000) {
return;
}
for (const [cle, c] of this.compteurs) {
if (c.bloqueJusqua < maintenant && maintenant - c.debut > 60 * MINUTE) {
this.compteurs.delete(cle);
}
}
}
}
function cleLogin(ip: string, login: string): string {
return `login|${ip}|${login.trim().toLowerCase()}`;
}
function cleIp(ip: string): string {
return `ip|${ip}`;
}
|
- lignes 26-30 : un compteur de tentatives, avec le début de sa fenêtre de comptage et sa date de fin de blocage ;
- lignes 34-37 : un seuil lu dans [.env], avec une valeur par défaut ;
- lignes 50-52 : le blocage porte sur le couple (adresse IP, login), et sur l'adresse IP seule quand elle cumule beaucoup d'échecs, tous logins confondus. En salle de cours, tous les postes sortent souvent par la même adresse IP : un étudiant qui se trompe de mot de passe ne doit pas bloquer ses voisins ;
- lignes 54-57 : un échec est compté pour les deux clés ;
- lignes 66-68 et 70-73 : le nombre de comptes créés par heure depuis une même adresse IP est limité ;
- lignes 84-98 : le comptage. Une fenêtre expirée repart de zéro ; au seuil, la clé est bloquée ;
- lignes 101-110 : le ménage des vieux compteurs, pour ne pas accumuler indéfiniment des clés en mémoire ;
- lignes 113-118 : la fabrication des clés de la [Map].
11.12.9. src/auth/passe-inscription.service.ts
| // =========================================================================
// auth/passe-inscription.service.ts
// -------------------------------------------------------------------------
// 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.
// =========================================================================
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { CookieOptions } from 'express';
export const COOKIE_PASSE_INSCRIPTION = 'rdvmedecins_inscription';
const DUREE_VIE_MS = 15 * 60 * 1000;
const MAX_PASSES = 10_000;
@Injectable()
export class PasseInscriptionService {
// id -> date d'expiration
private readonly passes = new Map<string, number>();
// délivre un nouveau laissez-passer ; renvoie son identifiant
emettre(): string {
this.purger();
const id = randomUUID();
this.passes.set(id, Date.now() + DUREE_VIE_MS);
return id;
}
// vrai si [id] est un laissez-passer existant et non expiré
estValide(id: unknown): boolean {
if (typeof id !== 'string') {
return false;
}
const expire = this.passes.get(id);
return expire !== undefined && expire > Date.now();
}
// le laissez-passer a servi : on l'oublie
consommer(id: unknown): void {
if (typeof id === 'string') {
this.passes.delete(id);
}
}
optionsCookie(): CookieOptions {
return { httpOnly: true, sameSite: 'lax', maxAge: DUREE_VIE_MS, path: '/inscription' };
}
private purger(): void {
const maintenant = Date.now();
for (const [id, expire] of this.passes) {
if (expire <= maintenant || this.passes.size >= MAX_PASSES) {
this.passes.delete(id);
}
}
}
}
|
La création d'un compte client est ouverte depuis la page de connexion, à condition d'avoir résolu son captcha. Il faut donc qu'une requête (le formulaire d'inscription) sache qu'une requête précédente a résolu le captcha. C'est le rôle du laissez-passer :
- lignes 38-43 : un identifiant aléatoire, valable 15 minutes, gardé en mémoire ; il sera déposé dans un cookie ;
- lignes 46-52 : le formulaire d'inscription n'est affiché, et accepté, qu'avec un laissez-passer valide ;
- lignes 55-59 : un compte créé consomme le laissez-passer : un captcha résolu donne droit à un compte, pas plus ;
- lignes 61-63 : l'option [path: '/inscription'] limite l'envoi du cookie aux URL qui commencent par [/inscription].
11.12.10. src/auth/auth.controller.ts
| // =========================================================================
// auth/auth.controller.ts
// -------------------------------------------------------------------------
// 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
//
// Le serveur JSON utilisait une garde Passport "local" pour POST /login :
// en cas d'échec, elle répondait directement 401, ce qui convient à un
// client JavaScript. Une page HTML, elle, doit RÉAFFICHER le formulaire
// avec un message d'erreur : on appelle donc AuthService nous-mêmes.
//
// Protection contre les robots, dans cet ordre (cf. captcha.service.ts et
// limiteur-tentatives.service.ts) :
// 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.
// =========================================================================
import { Body, Controller, Get, HttpCode, Post, Query, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { AuthService } from './auth.service';
import { CaptchaService } from './captcha.service';
import { LimiteurTentativesService } from './limiteur-tentatives.service';
import { COOKIE_JWT, optionsCookieJwt } from './auth.constants';
import { Role } from '../entities/user.entity';
// la page d'accueil de chaque rôle, juste après la connexion
export function pageAccueil(role: Role): string {
return role === 'USER' ? '/mon-espace' : '/agenda';
}
@Controller()
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly captchaService: CaptchaService,
private readonly limiteur: LimiteurTentativesService,
) {}
@Get()
accueil(@Req() req: Request, @Res() res: Response): void {
const jeton = this.authService.lireJeton(req.cookies?.[COOKIE_JWT]);
res.redirect(jeton ? pageAccueil(jeton.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)
@Get('login')
afficherLogin(@Req() req: Request, @Res() res: Response, @Query('login') login?: string): void {
// déjà connecté (jeton encore valide) : directement à la page d'accueil
const jeton = this.authService.lireJeton(req.cookies?.[COOKIE_JWT]);
if (jeton !== null) {
return res.redirect(pageAccueil(jeton.role));
}
this.afficherFormulaire(res, typeof login === 'string' ? login.slice(0, 30) : '');
}
// @HttpCode(200) : par défaut, NestJS répond 201 (Created) à un POST - ce qui
// n'a pas de sens quand on RÉAFFICHE le formulaire (une redirection, elle,
// impose de toute façon son propre code, 302).
@Post('login')
@HttpCode(200)
async seConnecter(
@Body() body: { login?: string; password?: string; captchaId?: string; captcha?: string },
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
const login = (body.login ?? '').trim();
const password = body.password ?? '';
const ip = req.ip ?? 'inconnue';
// 1. trop d'échecs récents ?
const minutes = this.limiteur.minutesBlocageLogin(ip, login);
if (minutes > 0) {
return this.afficherFormulaire(res, login, 'ERRORS.TOO_MANY_ATTEMPTS', { minutes: String(minutes) });
}
// 2. le captcha
if (!this.captchaService.verifier(body.captchaId, body.captcha)) {
this.limiteur.echecLogin(ip, login);
return this.afficherFormulaire(res, login, 'ERRORS.WRONG_CAPTCHA');
}
// 3. le mot de passe
const user = login && password ? await this.authService.validerUtilisateur(login, password) : null;
if (user === null) {
this.limiteur.echecLogin(ip, login);
return this.afficherFormulaire(res, login, 'LOGIN.ERROR');
}
this.limiteur.reussiteLogin(ip, login);
res.cookie(COOKIE_JWT, this.authService.creerJeton(user), optionsCookieJwt());
res.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, auth.constants.ts)
@Post('logout')
seDeconnecter(@Res() res: Response): void {
res.clearCookie(COOKIE_JWT, { path: '/' });
res.redirect('/login');
}
// le formulaire, toujours avec un NOUVEAU captcha (chaque image ne sert qu'une fois)
private afficherFormulaire(
res: Response,
login: string,
erreur?: string,
erreurParams?: Record<string, string>,
): void {
res.render('login', {
titrePage: 'LOGIN.TITLE',
login, // on réaffiche le login saisi, jamais le mot de passe
captcha: this.captchaService.generer(),
erreur,
erreurParams,
});
}
}
|
Commentons ce code :
- lignes 34-36 : la page d'accueil dépend du rôle : « Mon espace » pour un patient, l'agenda pour les autres ;
- lignes 46-50 : la racine du site redirige selon qu'on est connecté ou non. La route n'étant pas protégée par la garde, le jeton est vérifié par [lireJeton] ;
- lignes 54-62 : un utilisateur déjà connecté est envoyé à sa page d'accueil. Le paramètre [login] préremplit le champ (après la création d'un compte). [slice(0, 30)] borne sa longueur : une donnée venue de la requête n'est jamais utilisée sans précaution ;
- lignes 69-73 : la connexion, dans l'ordre : blocage éventuel, captcha, puis mot de passe (cf. exemple 27). [req.ip] est l'adresse IP du client. En cas de succès : compteur remis à zéro, cookie, redirection vers la page d'accueil du rôle ;
- lignes 104-108 : la déconnexion ;
- lignes 111-116 : la page de connexion, toujours avec un nouveau captcha. L'erreur est une clé de traduction, avec ses paramètres (le nombre de minutes de blocage).
11.12.11. src/auth/auth.module.ts
| // =========================================================================
// auth/auth.module.ts
// -------------------------------------------------------------------------
// Le module de l'authentification. Il EXPORTE :
// - UserRepository, dont la couche métier a besoin (créer, modifier,
// supprimer le compte d'un médecin ou d'un client - cf. metier.service.ts) ;
// - AuthService, CaptchaService, LimiteurTentativesService et
// PasseInscriptionService, utilisés aussi par les pages "Créer un compte"
// et "Gérer mon compte" (src/web/).
// =========================================================================
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../entities/user.entity';
import { UserRepository } from '../repositories/user.repository';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
import { CaptchaService } from './captcha.service';
import { LimiteurTentativesService } from './limiteur-tentatives.service';
import { PasseInscriptionService } from './passe-inscription.service';
import { JWT_EXPIRES_IN_SECONDS, JWT_SECRET } from './auth.constants';
@Module({
imports: [
TypeOrmModule.forFeature([User]),
PassportModule,
JwtModule.register({
secret: JWT_SECRET,
signOptions: { expiresIn: JWT_EXPIRES_IN_SECONDS },
}),
],
controllers: [AuthController],
providers: [
AuthService,
UserRepository,
JwtStrategy,
CaptchaService,
LimiteurTentativesService,
PasseInscriptionService,
],
exports: [UserRepository, AuthService, CaptchaService, LimiteurTentativesService, PasseInscriptionService],
})
export class AuthModule {}
|
- ligne 28 : le repository de l'entité [User] ;
- lignes 30-33 : la configuration de [JwtService] ;
- ligne 44 : les services exportés vers le module racine (cf. exemple 06) : [UserRepository] pour la couche métier (créer le compte d'un médecin ou d'un client), les autres pour les contrôleurs de la couche web (création de compte, gestion de son compte par le patient).
11.13. La couche web : les services communs
Avant les contrôleurs, voici ce qu'ils ont en commun : la traduction, le contexte de chaque page, l'intercepteur, le filtre d'exceptions, le cache des listes, les helpers.
11.13.1. src/web/i18n.ts
| // =========================================================================
// web/i18n.ts
// -------------------------------------------------------------------------
// Traduction français / anglais de l'interface.
//
// Le client Angular chargeait ses dictionnaires (public/i18n/fr.json et
// en.json) dans le NAVIGATEUR avec @ngx-translate. Ici, les pages sont
// fabriquées sur le SERVEUR : ce sont donc les mêmes dictionnaires (mêmes
// clés, complétées), mais lus une seule fois par le serveur au démarrage.
// Une vue Handlebars traduit une clé avec le helper {{t 'CLE'}}
// (cf. vues/helpers-handlebars.ts), l'équivalent du pipe `| translate`.
// =========================================================================
import { readFileSync } from 'fs';
import { join } from 'path';
import { DOSSIER_I18N } from '../config/chemins';
export type Langue = 'fr' | 'en';
export const LANGUES: Langue[] = ['fr', 'en'];
export const LANGUE_PAR_DEFAUT: Langue = 'fr';
// un dictionnaire est un objet JSON imbriqué : { "LOGIN": { "TITLE": "Connexion" } }
type Dictionnaire = { [cle: string]: string | Dictionnaire };
const dictionnaires: Record<Langue, Dictionnaire> = {
fr: JSON.parse(readFileSync(join(DOSSIER_I18N, 'fr.json'), 'utf-8')),
en: JSON.parse(readFileSync(join(DOSSIER_I18N, 'en.json'), 'utf-8')),
};
export function estUneLangue(valeur: unknown): valeur is Langue {
return typeof valeur === 'string' && (LANGUES as string[]).includes(valeur);
}
// -------------------------------------------------------------------------
// traduire('fr', 'MEDECINS.ADDED', { nom: 'Marie PELISSIER' })
// -> cherche MEDECINS puis ADDED dans le dictionnaire français, 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 dans les dictionnaires).
// -------------------------------------------------------------------------
export function traduire(langue: Langue, cle: string, params: Record<string, unknown> = {}): string {
const texte = chercher(dictionnaires[langue], cle) ?? chercher(dictionnaires[LANGUE_PAR_DEFAUT], cle) ?? cle;
return texte.replace(/\{(\w+)\}/g, (tout, nom: string) => (nom in params ? String(params[nom]) : tout));
}
function chercher(dictionnaire: Dictionnaire, cle: string): string | null {
let noeud: string | Dictionnaire | undefined = dictionnaire;
for (const morceau of cle.split('.')) {
if (typeof noeud !== 'object' || noeud === null) {
return null;
}
noeud = noeud[morceau];
}
return typeof noeud === 'string' ? noeud : null;
}
|
Le module de traduction de l'exemple 22, avec une précision (ligne 42) : une clé absente du dictionnaire anglais est cherchée dans le dictionnaire français, et, absente partout, la clé elle-même est affichée.
11.13.2. src/web/contexte-vue.ts
| // =========================================================================
// web/contexte-vue.ts
// -------------------------------------------------------------------------
// Ce que TOUTES les pages ont besoin de connaître, quelle que soit la route
// qui les affiche : la langue de l'interface, l'éventuel message "flash" à
// afficher, et l'utilisateur connecté (pour la barre de navigation).
//
// Express offre pour cela [res.locals] : un objet attaché à la réponse, dont
// le contenu est AUTOMATIQUEMENT fusionné avec le modèle de chaque vue au
// moment du res.render(...). Les contrôleurs n'ont donc pas à répéter ces
// informations dans chacun de leurs modèles.
//
// Rappel : le client Angular gardait ces informations dans le navigateur
// (signaux + localStorage). Un serveur HTML, lui, doit les retrouver à
// CHAQUE requête - ici dans des cookies.
// =========================================================================
import { NextFunction, Request, Response } from 'express';
import { Langue, LANGUE_PAR_DEFAUT, estUneLangue } from './i18n';
import { UtilisateurConnecte } from '../auth/jwt.strategy';
export const COOKIE_LANGUE = 'rdvmedecins_langue';
export const COOKIE_FLASH = 'rdvmedecins_flash';
// un message à afficher UNE fois, sur la page suivante
export interface MessageFlash {
type: 'success' | 'danger'; // classes Bootstrap alert-success / alert-danger
cle: string; // clé de traduction
params?: Record<string, string>;
}
// l'utilisateur connecté, tel que les vues le voient
export interface UtilisateurVue {
login: string;
nom: string;
role: string;
estAdmin: boolean; // ADMIN : menus Médecins / Clients
estPatient: boolean; // USER : menu Mon espace
}
// -------------------------------------------------------------------------
// middleware Express, exécuté AVANT toute route (cf. main.ts) :
// - res.locals.lang : langue choisie (cookie), français par défaut ;
// - res.locals.flash : le message flash éventuel, que l'on efface
// aussitôt (il ne doit s'afficher qu'une fois) ;
// - res.locals.chemin : l'URL demandée (menu actif de la barre de navigation).
// -------------------------------------------------------------------------
export function contexteVueMiddleware(req: Request, res: Response, next: NextFunction): void {
res.locals.lang = langueDe(req);
res.locals.chemin = req.path;
res.locals.flash = null;
res.locals.utilisateur = null;
const brut = req.cookies?.[COOKIE_FLASH];
if (brut) {
try {
res.locals.flash = JSON.parse(brut) as MessageFlash;
} catch {
// cookie illisible : on l'ignore
}
res.clearCookie(COOKIE_FLASH);
}
next();
}
export function langueDe(req: Request): Langue {
const valeur = req.cookies?.[COOKIE_LANGUE];
return estUneLangue(valeur) ? valeur : LANGUE_PAR_DEFAUT;
}
// -------------------------------------------------------------------------
// dépose un message flash, qui sera affiché par la PROCHAINE page. 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 : un cookie de courte
// durée fait l'affaire.
// -------------------------------------------------------------------------
export function flash(res: Response, message: MessageFlash): void {
res.cookie(COOKIE_FLASH, JSON.stringify(message), { httpOnly: true, sameSite: 'lax', maxAge: 60_000 });
}
// -------------------------------------------------------------------------
// expose l'utilisateur connecté (request.user, déposé par JwtStrategy) aux
// vues. Appelé par UtilisateurVueInterceptor (routes normales) et par
// HtmlExceptionFilter (pages d'erreur).
// -------------------------------------------------------------------------
export function exposerUtilisateur(req: Request, res: Response): void {
const user = (req as Request & { user?: UtilisateurConnecte }).user;
if (!user) {
return;
}
const utilisateur: UtilisateurVue = {
login: user.login,
nom: user.nom,
role: user.role,
estAdmin: user.role === 'ADMIN',
estPatient: user.role === 'USER',
};
res.locals.utilisateur = utilisateur;
}
|
Commentons ce code :
- lignes 22-23 : les noms des cookies de la langue et du message flash ;
- lignes 26-30 : un message flash : un type (succès ou erreur), une clé de traduction, et ses paramètres. Il est rangé dans le cookie sous forme de JSON ;
- lignes 33-39 : l'utilisateur connecté, tel que les vues le voient. Les booléens [estAdmin] et [estPatient] évitent aux vues de comparer des chaînes ;
- lignes 48-64 : le middleware exécuté avant toute route (cf. exemples 20 et 22) : la langue, le chemin (pour le menu actif), le message flash, lu puis effacé. [JSON.parse] est protégé par un [try] : un cookie modifié à la main ne doit pas faire échouer la page ;
- lignes 66-69 : la langue, d'après son cookie ;
- lignes 80-82 : dépose un message flash (cf. exemple 20) ;
- lignes 89-102 : recopie l'utilisateur connecté ([req.user]) dans [res.locals], pour la barre de navigation. Cette fonction n'est pas appelée par le middleware : celui-ci s'exécute avant les gardes, donc avant que [req.user] n'existe (cf. chapitre 7). Elle est appelée par l'intercepteur qui suit, et par le filtre d'exceptions.
11.13.3. src/web/utilisateur-vue.interceptor.ts
| // =========================================================================
// web/utilisateur-vue.interceptor.ts
// -------------------------------------------------------------------------
// Pourquoi un intercepteur et pas simplement le middleware de
// contexte-vue.ts ? Parce que l'ORDRE d'exécution compte :
//
// middlewares -> gardes (JwtAuthGuard...) -> intercepteurs -> contrôleur
//
// C'est la garde JwtAuthGuard qui dépose request.user : un middleware passe
// trop tôt pour le voir. Un intercepteur, lui, s'exécute APRÈS les gardes et
// AVANT le contrôleur - le bon moment pour recopier l'utilisateur connecté
// dans res.locals, où toutes les vues le trouveront.
// =========================================================================
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Request, Response } from 'express';
import { Observable } from 'rxjs';
import { exposerUtilisateur } from './contexte-vue';
@Injectable()
export class UtilisateurVueInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const http = context.switchToHttp();
exposerUtilisateur(http.getRequest<Request>(), http.getResponse<Response>());
return next.handle();
}
}
|
lignes 22-26 : un intercepteur s'exécute après les gardes et avant l'action : c'est le bon moment pour exposer l'utilisateur déposé par [JwtAuthGuard]. Il n'a rien à faire après l'action : il rend directement [next.handle()].
11.13.4. src/web/html-exception.filter.ts
| // =========================================================================
// web/html-exception.filter.ts
// -------------------------------------------------------------------------
// Un "filtre d'exceptions" NestJS reçoit toute exception qui n'a pas été
// traitée par le code des contrôleurs. Par défaut, NestJS répond en JSON
// ({"statusCode":403,"message":...}) - parfait pour le serveur JSON, mais
// illisible dans un navigateur. Ce filtre fabrique à la place une réponse
// adaptée à une application HTML :
//
// 401 (pas connecté, ou jeton expiré) -> redirection vers /login
// 403 (rôle insuffisant) -> page d'erreur "accès interdit"
// 404 (page inconnue) -> page d'erreur "page introuvable"
// 400 (paramètre invalide) -> page d'erreur "requête invalide"
// tout le reste -> page d'erreur 500
//
// @Catch() sans argument : le filtre attrape TOUTES les exceptions. Il est
// enregistré une fois pour toute l'application dans main.ts.
// =========================================================================
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { Request, Response } from 'express';
import { COOKIE_JWT } from '../auth/auth.constants';
import { exposerUtilisateur, flash } from './contexte-vue';
const MESSAGES: Record<number, string> = {
[HttpStatus.BAD_REQUEST]: 'ERRORS.BAD_REQUEST',
[HttpStatus.FORBIDDEN]: 'ERRORS.FORBIDDEN',
[HttpStatus.NOT_FOUND]: 'ERRORS.NOT_FOUND',
};
@Catch()
export class HtmlExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HtmlExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const http = host.switchToHttp();
const req = http.getRequest<Request>();
const res = http.getResponse<Response>();
const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
if (status === HttpStatus.UNAUTHORIZED) {
// un cookie était présent mais n'est plus valide : la session a expiré
if (req.cookies?.[COOKIE_JWT]) {
res.clearCookie(COOKIE_JWT, { path: '/' });
flash(res, { type: 'danger', cle: 'ERRORS.SESSION_EXPIRED' });
}
res.redirect('/login');
return;
}
if (status >= 500) {
// une vraie anomalie : on garde la trace complète dans la console du
// serveur, sans jamais l'afficher à l'utilisateur
this.logger.error(exception instanceof Error ? exception.stack : String(exception));
}
// la page d'erreur garde la barre de navigation de l'utilisateur connecté
// (s'il l'est - une 403 intervient APRÈS l'authentification)
exposerUtilisateur(req, res);
res.status(status).render('erreur', {
titrePage: 'ERROR_PAGE.TITLE',
code: status,
message: MESSAGES[status] ?? 'ERRORS.SERVER_ERROR',
});
}
}
|
- lignes 25-29 : la clé du message de chaque code d'erreur ;
- lignes 42-50 : une 401 redirige vers la page de connexion. Si un cookie de jeton était présent, c'est qu'il a expiré (ou que le compte a été supprimé) : on l'efface et on prévient l'utilisateur par un message flash ;
- lignes 52-56 : une vraie anomalie est journalisée dans la console du serveur, avec sa pile d'appels, mais n'est jamais détaillée à l'utilisateur : ces détails aideraient un attaquant ;
- lignes 60-65 : la page d'erreur, avec la barre de navigation de l'utilisateur connecté.
11.13.5. src/web/application-model.service.ts
| // =========================================================================
// web/application-model.service.ts
// -------------------------------------------------------------------------
// 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.
//
// Nouveauté : ces listes peuvent maintenant CHANGER (l'ADMIN ajoute,
// modifie, supprime des médecins et des clients). Un cache qui ne serait
// jamais mis à jour afficherait des données périmées : chaque écriture
// passe donc par ce service, qui recharge la liste concernée juste après.
// C'est tout l'intérêt d'avoir un point d'entrée UNIQUE vers la couche
// métier : la règle "écriture => rechargement du cache" n'est écrite qu'ici.
// =========================================================================
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { DonneesPersonne, IMetier, METIER_TOKEN } from '../metier/metier.interface';
import { Client } from '../entities/client.entity';
import { Medecin } from '../entities/medecin.entity';
import { Creneau } from '../entities/creneau.entity';
import { Rv } from '../entities/rv.entity';
import { AgendaMedecinJour } from '../domain/agenda-medecin-jour';
@Injectable()
export class ApplicationModelService implements IMetier, OnModuleInit {
private medecins: Medecin[] = [];
private clients: Client[] = [];
constructor(@Inject(METIER_TOKEN) private readonly metier: IMetier) {}
// si la base est inaccessible au démarrage, l'exception arrête le serveur
// avec un message explicite - plus clair, pour une application HTML, que
// de démarrer "à vide" et d'afficher des listes vides.
async onModuleInit(): Promise<void> {
await this.rechargerMedecins();
await this.rechargerClients();
}
private async rechargerMedecins(): Promise<void> {
this.medecins = await this.metier.getAllMedecins();
}
private async rechargerClients(): Promise<void> {
this.clients = await this.metier.getAllClients();
}
// ------------------------------------------------ lectures depuis le cache
async getAllMedecins(): Promise<Medecin[]> {
return this.medecins;
}
async getAllClients(): Promise<Client[]> {
return this.clients;
}
// le médecin dont le compte utilisateur est [idUser] - sert à présélectionner,
// pour un médecin connecté, son propre agenda
getMedecinByUserId(idUser: number): Medecin | null {
return this.medecins.find((m) => m.idUser === idUser) ?? null;
}
// ------------------------------------------------ simples délégations
getAllCreneaux(idMedecin: number): Promise<Creneau[]> {
return this.metier.getAllCreneaux(idMedecin);
}
getRvMedecinJour(idMedecin: number, jour: string): Promise<Rv[]> {
return this.metier.getRvMedecinJour(idMedecin, jour);
}
getClientById(id: number): Promise<Client | null> {
return this.metier.getClientById(id);
}
getMedecinById(id: number): Promise<Medecin | null> {
return this.metier.getMedecinById(id);
}
getRvById(id: number): Promise<Rv | null> {
return this.metier.getRvById(id);
}
getCreneauById(id: number): Promise<Creneau | null> {
return this.metier.getCreneauById(id);
}
getAgendaMedecinJour(idMedecin: number, jour: string): Promise<AgendaMedecinJour | null> {
return this.metier.getAgendaMedecinJour(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)
getClientByUserId(idUser: number): Promise<Client | null> {
return this.metier.getClientByUserId(idUser);
}
getRvAVenirClient(idClient: number): Promise<Rv[]> {
return this.metier.getRvAVenirClient(idClient);
}
ajouterRv(jour: string, creneau: Creneau, client: Client): Promise<Rv> {
return this.metier.ajouterRv(jour, creneau, client);
}
supprimerRv(rv: Rv): Promise<void> {
return this.metier.supprimerRv(rv);
}
// ------------------------------------------------ écritures => rechargement
async ajouterMedecin(donnees: DonneesPersonne): Promise<Medecin> {
const medecin = await this.metier.ajouterMedecin(donnees);
await this.rechargerMedecins();
return medecin;
}
async modifierMedecin(id: number, donnees: DonneesPersonne): Promise<Medecin> {
const medecin = await this.metier.modifierMedecin(id, donnees);
await this.rechargerMedecins();
return medecin;
}
async supprimerMedecin(id: number): Promise<void> {
await this.metier.supprimerMedecin(id);
await this.rechargerMedecins();
}
async ajouterClient(donnees: DonneesPersonne): Promise<Client> {
const client = await this.metier.ajouterClient(donnees);
await this.rechargerClients();
return client;
}
async modifierClient(id: number, donnees: DonneesPersonne): Promise<Client> {
const client = await this.metier.modifierClient(id, donnees);
await this.rechargerClients();
return client;
}
async supprimerClient(id: number): Promise<void> {
await this.metier.supprimerClient(id);
await this.rechargerClients();
}
}
|
Commentons ce code :
- ligne 25 : le service a la même interface que la couche métier : pour les contrôleurs, il est la couche métier. Il s'intercale entre les deux pour garder en mémoire (portée application, cf. chapitre 6) les listes des médecins et des clients, affichées sur presque toutes les pages ;
- ligne 29 : la couche métier est injectée par son jeton ;
- lignes 34-37 : [onModuleInit] est appelée par [NestJS] une fois le module construit, avant le démarrage du serveur : les listes sont lues au démarrage. Si la base est inaccessible, l'application s'arrête avec un message clair ;
- lignes 49-55 : les listes sont servies depuis la mémoire ;
- lignes 59-61 : le médecin d'un compte : un médecin connecté voit d'emblée son propre agenda ;
- lignes 65-109 : les autres demandes sont relayées telles quelles ;
- lignes 113-145 : toute écriture sur les médecins ou les clients recharge la liste concernée. Ce service étant le seul point d'entrée 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é.
11.13.6. src/web/vues/helpers-handlebars.ts
| // =========================================================================
// web/vues/helpers-handlebars.ts
// -------------------------------------------------------------------------
// Handlebars est volontairement un langage de modèles SANS LOGIQUE : pas
// d'expressions JavaScript dans les vues, seulement des {{variables}}, des
// blocs {{#if}} / {{#each}}... Quand une vue a besoin d'un peu plus, on lui
// fournit un "helper" : une fonction TypeScript, appelable depuis la vue.
//
// L'application n'a besoin que de quatre helpers :
// {{t 'CLE'}} traduction (l'équivalent du pipe `| translate`)
// {{t 'CLE' nom=personne.nom}} traduction avec paramètre(s)
// {{#if (eq a b)}} comparaison d'égalité
// {{#if (ou a b)}} vrai si l'un au moins des arguments est vrai
// {{actif chemin '/medecins'}} "active" si la page courante est sous ce chemin
// =========================================================================
import { HelperOptions } from 'handlebars';
import { Langue, LANGUE_PAR_DEFAUT, estUneLangue, traduire } from '../i18n';
// le module 'hbs' n'a pas de déclarations de types : on décrit ici le peu
// qu'on en utilise
interface Hbs {
registerHelper(nom: string, fonction: (...args: any[]) => unknown): void;
}
export function enregistrerHelpers(hbs: Hbs): void {
// Le dernier argument reçu par un helper est toujours l'objet [options] de
// Handlebars : options.hash contient les paramètres nommés (nom=...), et
// options.data.root le modèle COMPLET de la page - donc aussi res.locals.lang,
// la langue choisie (cf. contexte-vue.ts), même à l'intérieur d'un {{#each}}.
hbs.registerHelper('t', (cle: unknown, options: HelperOptions) => {
const lang = options.data?.root?.lang;
const langue: Langue = estUneLangue(lang) ? lang : LANGUE_PAR_DEFAUT;
// le texte renvoyé est ensuite échappé par Handlebars ({{ }} et non {{{ }}}) :
// un nom saisi par l'utilisateur ne peut donc pas injecter de HTML
return typeof cle === 'string' ? traduire(langue, cle, options.hash) : '';
});
hbs.registerHelper('eq', (a: unknown, b: unknown) => a === b);
// ...args : tous les arguments ; le dernier est l'objet [options] de
// Handlebars (cf. plus haut), qu'on écarte avant de tester les autres
hbs.registerHelper('ou', (...args: unknown[]) => args.slice(0, -1).some(Boolean));
hbs.registerHelper('actif', (chemin: unknown, prefixe: unknown) =>
typeof chemin === 'string' && typeof prefixe === 'string' && chemin.startsWith(prefixe) ? 'active' : '',
);
}
|
- lignes 31-37 : le helper de traduction (cf. exemple 22) ;
- ligne 39 : l'égalité (cf. exemple 16) ;
- ligne 43 : vrai si l'un des arguments est vrai. [...args] reçoit tous les arguments ; le dernier est l'objet [options] de [Handlebars], qu'on écarte ;
- lignes 45-47 : la classe [active] du menu courant (cf. exemple 16).
| // =========================================================================
// web/models/formulaire-personne.dto.ts
// -------------------------------------------------------------------------
// Les champs des formulaires "personne" et leurs contraintes. Trois
// formulaires partagent les mêmes champs d'identité (titre, prénom, nom) :
//
// ChampsPersonneDto titre, prénom, nom
// ├─ FormulairePersonneDto + login / mot de passe FACULTATIFS, version
// │ (l'ADMIN ajoute ou modifie un médecin / client)
// ├─ FormulaireInscriptionDto + login / mot de passe / confirmation OBLIGATOIRES
// │ (un patient crée son compte)
// └─ FormulaireMonCompteDto + login, nouveau mot de passe facultatif,
// mot de passe ACTUEL obligatoire, version
// (un patient modifie son compte)
//
// L'héritage de classes vaut aussi pour les décorateurs de class-validator :
// une classe fille hérite des contraintes des champs de sa classe mère.
//
// Les messages d'erreur sont des CLÉS de traduction (cf. i18n/*.json).
// @Transform(...) (class-transformer) nettoie la saisie AVANT la
// validation : espaces superflus retirés, champs facultatifs laissés vides
// ramenés à `undefined`, login mis en minuscules, version convertie en nombre
// (tout ce qui vient d'un formulaire HTML est une chaîne de caractères).
//
// La vérification "mot de passe = confirmation" porte sur DEUX champs : elle
// est faite par le contrôleur, après la validation de chaque champ.
// =========================================================================
import { Transform } from 'class-transformer';
import { IsIn, IsInt, IsNotEmpty, IsOptional, Matches, MaxLength, MinLength } from 'class-validator';
export const TITRES = ['Mr', 'Mme', 'Melle'];
const nettoyer = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value);
const videEnUndefined = ({ value }: { value: unknown }) =>
typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
const loginNettoye = ({ value }: { value: unknown }) => videEnUndefined({ value })?.toLowerCase();
// un mot de passe n'est pas "nettoyé" (les espaces peuvent en faire partie)
const motDePasseOuUndefined = ({ value }: { value: unknown }) =>
typeof value === 'string' && value !== '' ? value : undefined;
const nombreOuUndefined = ({ value }: { value: unknown }) =>
value === undefined || value === '' ? undefined : Number(value);
// login : lettres minuscules, chiffres, point, tiret, souligné - 30 caractères au plus
const FORMAT_LOGIN = /^[a-z0-9._-]{1,30}$/;
// longueur minimale d'un mot de passe CHOISI par un patient
export const MOT_DE_PASSE_MIN = 6;
// ------------------------------------------------------------------ base commune
export class ChampsPersonneDto {
@IsIn(TITRES, { message: 'ERRORS.INVALID_TITLE' })
titre: string;
// longueurs maximales = celles des colonnes SQL (VARCHAR(20))
@Transform(nettoyer)
@IsNotEmpty({ message: 'ERRORS.FIRSTNAME_REQUIRED' })
@MaxLength(20, { message: 'ERRORS.FIRSTNAME_TOO_LONG' })
prenom: string;
@Transform(nettoyer)
@IsNotEmpty({ message: 'ERRORS.LASTNAME_REQUIRED' })
@MaxLength(20, { message: 'ERRORS.LASTNAME_TOO_LONG' })
nom: string;
}
// ------------------------------------------------------------------ ADMIN
export class FormulairePersonneDto extends ChampsPersonneDto {
// facultatif (cf. DonneesPersonne, metier.interface.ts)
@Transform(loginNettoye)
@IsOptional()
@Matches(FORMAT_LOGIN, { message: 'ERRORS.INVALID_LOGIN' })
login?: string;
// facultatif ; 72 caractères au plus (au-delà, bcrypt ignore la suite)
@Transform(motDePasseOuUndefined)
@IsOptional()
@MaxLength(72, { message: 'ERRORS.PASSWORD_TOO_LONG' })
password?: string;
// champ caché du formulaire de modification (verrou optimiste)
@Transform(nombreOuUndefined)
@IsOptional()
@IsInt({ message: 'ERRORS.INVALID_FORM' })
version?: number;
}
// ------------------------------------------------------------------ inscription d'un patient
export class FormulaireInscriptionDto extends ChampsPersonneDto {
@Transform(loginNettoye)
@IsNotEmpty({ message: 'ERRORS.LOGIN_REQUIRED' })
@Matches(FORMAT_LOGIN, { message: 'ERRORS.INVALID_LOGIN' })
login: string;
@Transform(motDePasseOuUndefined)
@IsNotEmpty({ message: 'ERRORS.PASSWORD_REQUIRED' })
@MinLength(MOT_DE_PASSE_MIN, { message: 'ERRORS.PASSWORD_TOO_SHORT' })
@MaxLength(72, { message: 'ERRORS.PASSWORD_TOO_LONG' })
password: string;
@Transform(motDePasseOuUndefined)
@IsNotEmpty({ message: 'ERRORS.CONFIRMATION_REQUIRED' })
confirmation: string;
}
// ------------------------------------------------------------------ "Gérer mon compte"
export class FormulaireMonCompteDto extends ChampsPersonneDto {
@Transform(loginNettoye)
@IsNotEmpty({ message: 'ERRORS.LOGIN_REQUIRED' })
@Matches(FORMAT_LOGIN, { message: 'ERRORS.INVALID_LOGIN' })
login: string;
// facultatif : vide = mot de passe inchangé
@Transform(motDePasseOuUndefined)
@IsOptional()
@MinLength(MOT_DE_PASSE_MIN, { message: 'ERRORS.PASSWORD_TOO_SHORT' })
@MaxLength(72, { message: 'ERRORS.PASSWORD_TOO_LONG' })
password?: string;
@Transform(motDePasseOuUndefined)
@IsOptional()
confirmation?: string;
// le mot de passe ACTUEL, exigé pour toute modification du compte
@Transform(motDePasseOuUndefined)
@IsNotEmpty({ message: 'ERRORS.CURRENT_PASSWORD_REQUIRED' })
motDePasseActuel: string;
@Transform(nombreOuUndefined)
@IsOptional()
@IsInt({ message: 'ERRORS.INVALID_FORM' })
version?: number;
}
|
Commentons ce code :
- ligne 32 : les titres possibles ;
- lignes 34-42 : les fonctions de nettoyage appliquées par [@Transform] avant la validation : suppression des espaces, champ vide ramené à [undefined] (pour que [@IsOptional] le laisse passer), login en minuscules, version convertie en nombre. Un mot de passe n'est pas nettoyé : ses espaces en font partie ;
- ligne 45 : le format d'un login, décrit par une expression régulière ;
- lignes 50-64 : les champs d'identité, communs à trois formulaires. Les longueurs maximales sont celles des colonnes SQL. Les messages sont des clés de traduction ;
- lignes 67-85 : le formulaire de l'ADMIN (médecin ou client) : login et mot de passe facultatifs, et la version pour le verrou optimiste. [extends] : la classe hérite des champs et des décorateurs de sa mère. 72 caractères au plus pour un mot de passe : bcrypt ignore les suivants ;
- lignes 88-103 : la création d'un compte par un patient : login et mot de passe obligatoires, six caractères au moins, et confirmation ;
- lignes 106-132 : la modification de son compte par un patient : nouveau mot de passe facultatif, mot de passe actuel obligatoire.
| // =========================================================================
// web/helpers/formulaire.helper.ts
// -------------------------------------------------------------------------
// Validation d'un formulaire HTML avec class-validator.
//
// Le serveur JSON confiait la validation au ValidationPipe global : en cas
// d'erreur, il répondait lui-même 400 Bad Request - ce qui convient à un
// client JavaScript, qui affiche ensuite l'erreur à sa façon. Pour une page
// HTML, on veut au contraire RÉAFFICHER le formulaire, avec les valeurs
// déjà saisies et un message sous chaque champ erroné. On appelle donc
// class-validator nous-mêmes, pour garder la main sur la suite.
// =========================================================================
import { ClassConstructor, plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { TITRES } from '../models/formulaire-personne.dto';
// erreurs par champ : { nom: 'ERRORS.LASTNAME_REQUIRED', ... } - chaque
// message est une CLÉ de traduction (cf. les décorateurs des DTO)
export type ErreursFormulaire = Record<string, string>;
export interface ResultatValidation<T> {
dto: T; // les données, converties et nettoyées (cf. @Transform des DTO)
erreurs: ErreursFormulaire | null; // null = formulaire valide
}
export async function validerFormulaire<T extends object>(
classe: ClassConstructor<T>,
corps: Record<string, unknown>,
): Promise<ResultatValidation<T>> {
// plainToInstance : transforme le simple objet reçu ({ nom: ' dupont ' })
// en une instance de la classe DTO, en appliquant ses @Transform(...)
const dto = plainToInstance(classe, corps);
// validate : vérifie toutes les contraintes (@IsNotEmpty, @MaxLength...) ;
// forbidUnknownValues:false car on valide un objet construit par nous
const violations = await validate(dto, { forbidUnknownValues: false });
if (violations.length === 0) {
return { dto, erreurs: null };
}
const erreurs: ErreursFormulaire = {};
for (const violation of violations) {
// on ne garde que le premier message de chaque champ
erreurs[violation.property] = Object.values(violation.constraints ?? {})[0] ?? 'ERRORS.INVALID_FORM';
}
return { dto, 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
// -------------------------------------------------------------------------
export const CHAMPS_ERREURS_METIER: Record<string, string[]> = {
'ERRORS.LOGIN_TAKEN': ['login'],
'ERRORS.DOCTOR_ALREADY_EXISTS': ['prenom', 'nom'],
'ERRORS.PATIENT_ALREADY_EXISTS': ['prenom', 'nom'],
};
// { prenom: true, nom: true } : les champs à encadrer en rouge (is-invalid) -
// ceux qui ont un message de validation, plus d'éventuels autres
export function champsInvalides(erreurs: ErreursFormulaire, autres: string[] = []): Record<string, boolean> {
const champs: Record<string, boolean> = {};
for (const champ of [...Object.keys(erreurs), ...autres]) {
champs[champ] = true;
}
return champs;
}
// les options de la liste déroulante "Titre", celle de [valeur] sélectionnée
export function optionsTitres(valeur: string): { valeur: string; selectionne: boolean }[] {
return TITRES.map((t) => ({ valeur: t, selectionne: t === valeur }));
}
|
- lignes 27-30 : la validation d'un formulaire, comme à l'exemple 19. L'option [forbidUnknownValues: false] est nécessaire parce qu'on valide un objet fabriqué par [plainToInstance], et non reçu d'un [ValidationPipe] ;
- lignes 52-56 : certaines erreurs métier concernent des champs précis (un login déjà pris) : ces champs seront encadrés en rouge, le message étant en bas de page ;
- lignes 60-66 : les champs à encadrer en rouge ;
- lignes 69-71 : les options de la liste des titres, celle qui est choisie étant marquée (cf. exemple 18).
11.13.9. src/web/helpers/dates.helper.ts
| // =========================================================================
// web/helpers/dates.helper.ts
// -------------------------------------------------------------------------
// Petites fonctions sur les dates au format "yyyy-MM-dd" (celui de la
// colonne rv.jour et du champ <input type="date"> des formulaires).
// =========================================================================
// la date du jour : définie dans la couche métier, qui en a besoin pour
// ses règles de gestion - on la ré-exporte simplement ici
export { aujourdhui } from '../../metier/dates';
// vrai si [jour] est une date qui existe au format yyyy-MM-dd
// (même contrôle que le serveur JSON : "2026-02-30" est refusé)
export function estUneDateValide(jour: unknown): jour is string {
if (typeof jour !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(jour)) {
return false;
}
const date = new Date(`${jour}T00:00:00Z`);
return !isNaN(date.getTime()) && date.toISOString().slice(0, 10) === jour;
}
// "8:05" plutôt que "8:5"
export function formaterHeure(h: number, m: number): string {
return `${h}:${deuxChiffres(m)}`;
}
// "mercredi 23 septembre 2026" / "Wednesday 23 September 2026", selon la langue
// (Intl.DateTimeFormat : l'API de formatage de dates intégrée à JavaScript)
export function formaterJour(jour: string, langue: string): string {
return new Intl.DateTimeFormat(langue === 'en' ? 'en-GB' : 'fr-FR', {
dateStyle: 'full',
timeZone: 'UTC', // "2026-09-23T00:00:00Z" doit rester le 23, quel que soit le fuseau du serveur
}).format(new Date(`${jour}T00:00:00Z`));
}
function deuxChiffres(n: number): string {
return n.toString().padStart(2, '0');
}
|
- ligne 10 : la date du jour est définie dans la couche métier ; ce module la ré-exporte : les contrôleurs importent toutes les fonctions de dates d'un même endroit ;
- lignes 14-20 : une date [aaaa-mm-jj] qui existe (cf. exemple 12) ;
- lignes 29-34 : une date en toutes lettres, selon la langue. L'option [timeZone: 'UTC'] empêche un décalage d'un jour quand le serveur n'est pas à l'heure de Greenwich.
11.13.10. src/web/helpers/patient.helper.ts
| // =========================================================================
// web/helpers/patient.helper.ts
// -------------------------------------------------------------------------
// 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
// (par le patient lui-même dans un autre onglet, ou par l'ADMIN) alors que
// son jeton est encore valide : on le traite comme une session expirée
// (UnauthorizedException -> retour à la page de connexion, cf.
// HtmlExceptionFilter).
// =========================================================================
import { UnauthorizedException } from '@nestjs/common';
import { ApplicationModelService } from '../application-model.service';
import { UtilisateurConnecte } from '../../auth/jwt.strategy';
import { Client } from '../../entities/client.entity';
import { Personne } from '../../entities/personne.entity';
// "Mme Alice DURAND"
export function libelle(p: Personne): string {
return `${p.titre} ${p.prenom} ${p.nom}`;
}
export async function patientConnecte(
application: ApplicationModelService,
utilisateur: UtilisateurConnecte,
): Promise<Client> {
const client = await application.getClientByUserId(utilisateur.userId);
if (client === null) {
throw new UnauthorizedException();
}
return client;
}
|
- lignes 23-25 : « Mme Alice DURAND » ;
- lignes 27-30 : la fiche du patient connecté, retrouvée à partir de son compte. Si elle n'existe plus (compte supprimé dans un autre onglet, ou par l'ADMIN, alors que le jeton est encore valide), c'est une 401 : retour à la page de connexion.
11.14. La couche web : les contrôleurs
11.14.1. src/web/controllers/langue.controller.ts
| // =========================================================================
// web/controllers/langue.controller.ts
// -------------------------------------------------------------------------
// GET /langue/fr, GET /langue/en : les deux boutons FR / EN de la barre de
// navigation. Le choix est mémorisé dans un cookie (le client Angular le
// gardait dans localStorage), puis on revient sur la page d'où l'on vient.
// Route non protégée : on doit pouvoir changer de langue avant de se connecter.
// =========================================================================
import { Controller, Get, NotFoundException, Param, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { estUneLangue } from '../i18n';
import { COOKIE_LANGUE } from '../contexte-vue';
const UN_AN = 365 * 24 * 3600 * 1000;
@Controller('langue')
export class LangueController {
@Get(':langue')
changer(@Param('langue') langue: string, @Req() req: Request, @Res() res: Response): void {
if (!estUneLangue(langue)) {
throw new NotFoundException();
}
res.cookie(COOKIE_LANGUE, langue, { maxAge: UN_AN, sameSite: 'lax', httpOnly: true });
res.redirect(pageDOrigine(req));
}
}
// 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").
function pageDOrigine(req: Request): string {
const referer = req.get('referer');
if (!referer) {
return '/';
}
try {
const url = new URL(referer);
return url.pathname.startsWith('/langue/') ? '/' : url.pathname + url.search;
} catch {
return '/';
}
}
|
Le contrôleur de l'exemple 22. Une précision : si la page d'origine est elle-même une page [/langue/...] (ligne 40), on revient à la racine du site, pour ne pas boucler.
11.14.2. src/web/controllers/agenda.controller.ts
| // =========================================================================
// web/controllers/agenda.controller.ts
// -------------------------------------------------------------------------
// 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.
// C'est l'équivalent, côté serveur, des composants Angular
// <app-doctor-day-picker>, <app-agenda> et <app-booking-dialog>.
//
// 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).
// =========================================================================
import { BadRequestException, Body, Controller, Get, Post, Query, Render, Req, Res, UseGuards } from '@nestjs/common';
import { Request, Response } from 'express';
import { ApplicationModelService } from '../application-model.service';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { RolesGuard } from '../../auth/roles.guard';
import { Roles } from '../../auth/roles.decorator';
import { UtilisateurConnecte } from '../../auth/jwt.strategy';
import { MetierException } from '../../metier/metier.exception';
import { flash } from '../contexte-vue';
import { aujourdhui, estUneDateValide, formaterHeure } from '../helpers/dates.helper';
import { libelle, patientConnecte } from '../helpers/patient.helper';
import { Client } from '../../entities/client.entity';
import { Personne } from '../../entities/personne.entity';
type RequeteAuthentifiee = Request & { user: UtilisateurConnecte };
// ----------------------------------------------------------------- modèle de la vue
// (ce que views/agenda.hbs affiche - uniquement des données déjà mises en
// forme ET déjà filtrées selon le rôle : la vue ne décide de rien)
interface OptionVue {
id: number;
libelle: string; // "Mme Marie PELISSIER"
selectionne: boolean;
}
interface LigneAgendaVue {
idCreneau: number;
horaire: string; // "8:00 - 8:20"
libre: boolean;
patient: string | null; // nom affiché pour un créneau occupé ; null = « Réservé »
moi: boolean; // le rendez-vous du patient connecté
reservable: boolean; // afficher le bouton "Réserver" ?
urlReserver: string;
idRv: number | null; // renseigné seulement si le bouton "Annuler" est affiché
}
interface AgendaVue {
medecin: string;
jour: string;
lignes: LigneAgendaVue[];
}
interface ReservationVue {
idCreneau: number;
horaire: string;
// ADMIN / DOCTOR : la liste des patients parmi lesquels choisir
clients: OptionVue[];
// USER : le patient, c'est lui-même
patient: string | null;
}
export interface ModeleAgenda {
titrePage: string;
medecins: OptionVue[];
idMedecin: number | null;
jour: string;
agenda: AgendaVue | null;
reservation: ReservationVue | null;
urlRetour: string; // 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"
info: string | null;
erreur: string | null;
}
// ce que le contrôleur sait de l'utilisateur, pour décider de l'affichage
interface Droits {
gestionnaire: boolean; // ADMIN ou DOCTOR
patient: Client | null; // le client connecté (rôle USER), sinon null
patientPeutReserver: boolean; // USER sans rendez-vous à venir
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Controller('agenda')
export class AgendaController {
constructor(private readonly application: ApplicationModelService) {}
// -----------------------------------------------------------------------
// GET /agenda
// @Render('agenda') : l'objet renvoyé par la méthode devient le modèle de
// la vue views/agenda.hbs (+ le contenu de res.locals, cf. contexte-vue.ts).
// -----------------------------------------------------------------------
@Get()
@Render('agenda')
async afficher(
@Req() req: RequeteAuthentifiee,
@Query('idMedecin') idMedecinBrut?: string,
@Query('jour') jourBrut?: string,
@Query('reserver') reserverBrut?: string,
): Promise<ModeleAgenda> {
let erreur: string | null = null;
const droits = await this.droits(req.user);
// le jour : celui de l'URL s'il est valide, sinon aujourd'hui
let jour = aujourdhui();
if (jourBrut !== undefined) {
if (estUneDateValide(jourBrut)) {
jour = jourBrut;
} else {
erreur = 'ERRORS.INVALID_DATE';
}
}
const jourReservable = jour >= aujourdhui();
// le médecin : celui de l'URL ; à défaut, pour un médecin connecté, lui-même
let idMedecin = entierPositif(idMedecinBrut);
if (idMedecinBrut === undefined && req.user.role === 'DOCTOR') {
idMedecin = this.application.getMedecinByUserId(req.user.userId)?.id ?? null;
}
const medecins = (await this.application.getAllMedecins()).map((m) => option(m, m.id === idMedecin));
// l'agenda, si un médecin est choisi (et la date valide)
let agenda: AgendaVue | null = null;
if (idMedecin !== null && erreur === null) {
const agendaMedecin = await this.application.getAgendaMedecinJour(idMedecin, jour);
if (agendaMedecin === null) {
erreur = 'ERRORS.DOCTOR_NOT_FOUND';
} else {
agenda = {
medecin: libelle(agendaMedecin.medecin),
jour,
lignes: agendaMedecin.creneauxMedecinJour.map(({ creneau, rv }) => {
const libre = rv === null;
// le rendez-vous du patient connecté ?
const moi = !libre && droits.patient !== null && rv.idClient === droits.patient.id;
return {
idCreneau: creneau.id,
horaire: `${formaterHeure(creneau.hdebut, creneau.mdebut)} - ${formaterHeure(creneau.hfin, creneau.mfin)}`,
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: libre ? null : droits.gestionnaire || moi ? libelle(rv.client) : null,
moi,
reservable: libre && jourReservable && (droits.gestionnaire || droits.patientPeutReserver),
urlReserver: urlAgenda(idMedecin as number, jour, creneau.id),
idRv: !libre && droits.gestionnaire ? rv.id : null,
};
}),
};
}
}
// la fenêtre de réservation : seulement si le créneau demandé est
// réservable par cet utilisateur
let reservation: ReservationVue | null = null;
const idCreneau = entierPositif(reserverBrut);
const ligne = agenda?.lignes.find((l) => l.idCreneau === idCreneau && l.reservable);
if (ligne) {
reservation = {
idCreneau: ligne.idCreneau,
horaire: ligne.horaire,
clients: droits.gestionnaire ? (await this.application.getAllClients()).map((c) => option(c, false)) : [],
patient: droits.patient ? libelle(droits.patient) : null,
};
}
return {
titrePage: 'AGENDA.PAGE_TITLE',
medecins,
idMedecin,
jour,
agenda,
reservation,
urlRetour: idMedecin !== null ? urlAgenda(idMedecin, jour) : '/agenda',
info: droits.patient !== null && !droits.patientPeutReserver ? 'AGENDA.ALREADY_BOOKED_INFO' : null,
erreur,
};
}
// -----------------------------------------------------------------------
// 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. flash(), contexte-vue.ts).
// -----------------------------------------------------------------------
@Post('reserver')
@Roles('ADMIN', 'DOCTOR', 'USER')
async reserver(
@Body() body: Record<string, string>,
@Req() req: RequeteAuthentifiee,
@Res() res: Response,
): Promise<void> {
const { idMedecin, jour } = lireAgendaDemande(body);
const idCreneau = entierPositif(body.idCreneau);
if (idCreneau === null) {
throw new BadRequestException();
}
let client: Client | null;
if (req.user.role === 'USER') {
client = await patientConnecte(this.application, req.user);
} else {
const idClient = entierPositif(body.idClient);
if (idClient === null) {
// aucun patient choisi : on rouvre la fenêtre de réservation
flash(res, { type: 'danger', cle: 'ERRORS.CHOOSE_PATIENT' });
return res.redirect(urlAgenda(idMedecin, jour, idCreneau));
}
client = await this.application.getClientById(idClient);
}
const creneau = await this.application.getCreneauById(idCreneau);
if (client === null) {
flash(res, { type: 'danger', cle: 'ERRORS.PATIENT_NOT_FOUND' });
} else if (creneau === null || creneau.idMedecin !== idMedecin) {
flash(res, { type: 'danger', cle: '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 this.application.ajouterRv(jour, creneau, client);
flash(res, { type: 'success', cle: 'AGENDA.BOOKED' });
} catch (e) {
// 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... »)
if (
req.user.role === 'USER' &&
e instanceof MetierException &&
e.cle === 'ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT'
) {
flash(res, { type: 'danger', cle: 'AGENDA.ALREADY_BOOKED_INFO' });
} else {
flashErreurMetier(res, e);
}
}
}
res.redirect(urlAgenda(idMedecin, jour));
}
// -----------------------------------------------------------------------
// POST /agenda/annuler champs : idRv, idMedecin, jour
// (un patient annule SON rendez-vous depuis "Mon espace", pas d'ici)
// -----------------------------------------------------------------------
@Post('annuler')
@Roles('ADMIN', 'DOCTOR')
async annuler(@Body() body: Record<string, string>, @Res() res: Response): Promise<void> {
const { idMedecin, jour } = lireAgendaDemande(body);
const idRv = entierPositif(body.idRv);
if (idRv === null) {
throw new BadRequestException();
}
const rv = await this.application.getRvById(idRv);
if (rv === null) {
// déjà annulé (par quelqu'un d'autre, ou double clic)
flash(res, { type: 'danger', cle: 'ERRORS.APPOINTMENT_NOT_FOUND' });
} else {
await this.application.supprimerRv(rv);
flash(res, { type: 'success', cle: 'AGENDA.CANCELLED' });
}
res.redirect(urlAgenda(idMedecin, jour));
}
// -----------------------------------------------------------------------
// qui est connecté, et qu'a-t-il le droit de faire sur cette page ?
// -----------------------------------------------------------------------
private async droits(utilisateur: UtilisateurConnecte): Promise<Droits> {
if (utilisateur.role !== 'USER') {
return { gestionnaire: true, patient: null, patientPeutReserver: false };
}
const patient = await patientConnecte(this.application, utilisateur);
const rvAVenir = await this.application.getRvAVenirClient(patient.id);
return { gestionnaire: false, patient, patientPeutReserver: rvAVenir.length === 0 };
}
}
// ----------------------------------------------------------------- fonctions utilitaires
// "3" -> 3 ; "", "abc", "-2", undefined -> null
function entierPositif(valeur: unknown): number | null {
const n = Number(valeur);
return typeof valeur === 'string' && valeur !== '' && Number.isInteger(n) && n > 0 ? n : null;
}
// les champs cachés idMedecin / jour, communs aux deux formulaires POST
function lireAgendaDemande(body: Record<string, string>): { idMedecin: number; jour: string } {
const idMedecin = entierPositif(body.idMedecin);
if (idMedecin === null || !estUneDateValide(body.jour)) {
throw new BadRequestException(); // -> page "requête invalide" (cf. HtmlExceptionFilter)
}
return { idMedecin, jour: body.jour };
}
function urlAgenda(idMedecin: number, jour: string, reserver?: number): string {
const params = new URLSearchParams({ idMedecin: String(idMedecin), jour });
if (reserver !== undefined) {
params.set('reserver', String(reserver));
}
return `/agenda?${params.toString()}`;
}
function option(p: Personne, selectionne: boolean): OptionVue {
return { id: p.id, libelle: libelle(p), selectionne };
}
// une MetierException devient un message flash ; toute autre erreur est une
// vraie anomalie, que l'on laisse remonter jusqu'à HtmlExceptionFilter
function flashErreurMetier(res: Response, e: unknown): void {
if (e instanceof MetierException) {
flash(res, { type: 'danger', cle: e.cle, params: e.params });
return;
}
throw e;
}
|
La page principale de l'application. Commentons ce code par parties.
11.14.2.1. Les URL de la page
Le commentaire des lignes 9-14 décrit les routes. 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 d'afficher 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.
11.14.2.2. Le modèle de la vue
- ligne 56 : le type de la requête d'une route protégée : une requête [Express] dont le champ [user] a été déposé par [JwtAuthGuard] ;
- lignes 62-106 : le modèle de la vue est typé. La vue ne reçoit que des données déjà mises en forme ([horaire: "8:00 - 8:20"]) et déjà filtrées selon le rôle : elle ne décide de rien. Dans [LigneAgendaVue] (lignes 68-77), [patient] vaut [null] quand l'utilisateur n'a pas le droit de voir le nom du patient ; [reservable] et [idRv] disent si les boutons Réserver et Annuler doivent être affichés ;
- lignes 109-113 : ce que le contrôleur sait de l'utilisateur connecté.
11.14.2.3. GET /agenda
- lignes 115-116 : toutes les routes exigent un utilisateur connecté. Sans [@Roles] sur la classe, tous les rôles ont accès à la page ;
- lignes 127-132 : les trois paramètres de l'URL, tous facultatifs, reçus sous forme de chaînes et convertis plus loin ;
- lignes 137-145 : le jour demandé, s'il est valide ; aujourd'hui sinon. On ne peut réserver qu'à partir d'aujourd'hui ;
- lignes 148-151 : le médecin demandé ; à défaut, un médecin connecté voit son propre agenda ;
- ligne 153 : la liste déroulante des médecins, depuis le cache ;
- lignes 157-184 : l'agenda, calculé par la couche métier, est transformé en lignes de tableau. La ligne 175 est le point de confidentialité de l'application : un patient ne reçoit le nom que de son 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 ;
- lignes 188-198 : la fenêtre de réservation n'est ouverte que si le créneau demandé est réservable par cet utilisateur. Un gestionnaire choisit le patient dans une liste ; un patient réserve pour lui-même ;
- lignes 200-210 : le modèle. [info] explique à un patient qui a déjà un rendez-vous pourquoi il n'a pas de bouton Réserver.
11.14.2.4. POST /agenda/reserver
- ligne 225 : les trois rôles peuvent réserver ;
- ligne 231 : le médecin et le jour, champs cachés du formulaire, permettent de revenir à l'agenda affiché ;
- lignes 238-239 : 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 ;
- lignes 242-246 : un gestionnaire qui n'a pas choisi de patient retrouve la fenêtre de réservation, avec un message ;
- lignes 251-254 : le créneau doit exister et appartenir au médecin de l'agenda ;
- lignes 256-261 : les règles de gestion sont vérifiées par la couche métier. Pour un patient, le message « ce patient a déjà un rendez-vous » est remplacé par un message à la deuxième personne ;
- ligne 275 : le schéma PRG : quel que soit le résultat, message flash et redirection vers l'agenda.
11.14.2.5. POST /agenda/annuler
Lignes 282-299 : réservée aux gestionnaires ; un patient annule son rendez-vous depuis « Mon espace ». Un rendez-vous déjà annulé (double clic, ou par un autre gestionnaire) donne un message d'erreur, pas une page d'erreur.
11.14.2.6. Les droits et les fonctions utilitaires
- lignes 304-311 : un gestionnaire a tous les droits ; un patient peut réserver s'il n'a pas de rendez-vous à venir ;
- lignes 317-320 : la conversion prudente d'un paramètre en identifiant ;
- lignes 323-329 : des champs cachés invalides ne peuvent venir que d'une requête fabriquée à la main : erreur 400 ;
- lignes 331-337 : [URLSearchParams] fabrique une chaîne de requête correctement encodée ;
- lignes 345-351 : une [MetierException] devient un message flash ; toute autre exception remonte jusqu'au filtre d'exceptions.
11.14.3. src/web/controllers/personnes.controller.ts
| // =========================================================================
// web/controllers/personnes.controller.ts
// -------------------------------------------------------------------------
// 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 routes, on les écrit une fois dans cette
// classe ABSTRAITE, dont héritent MedecinsController (@Controller('medecins'))
// et ClientsController (@Controller('clients')). Chaque sous-classe n'a
// qu'à fournir sa "configuration" : quelles méthodes appeler, quels
// libellés afficher.
//
// Les décorateurs de route (@Get, @Post...) posés ICI sont hérités : NestJS
// parcourt toute la chaîne des prototypes de la classe de contrôleur. On
// obtient donc, pour chacune des deux sous-classes :
//
// 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.hbs et views/personnes/formulaire.hbs.
// =========================================================================
import { Body, Get, HttpCode, NotFoundException, Param, ParseIntPipe, Post, Render, Res } from '@nestjs/common';
import { Response } from 'express';
import { ApplicationModelService } from '../application-model.service';
import { DonneesPersonne } from '../../metier/metier.interface';
import { MetierException } from '../../metier/metier.exception';
import { Personne } from '../../entities/personne.entity';
import { flash } from '../contexte-vue';
import {
CHAMPS_ERREURS_METIER,
ErreursFormulaire,
champsInvalides,
optionsTitres,
validerFormulaire,
} from '../helpers/formulaire.helper';
import { FormulairePersonneDto } from '../models/formulaire-personne.dto';
// les clés de traduction propres à chaque type de personne
export interface ClesPersonnes {
titre: string; // "Médecins"
ajouter: string; // "Ajouter un médecin"
modifier: string; // "Modifier un médecin"
vide: string; // "Aucun médecin."
confirmerSuppression: string; // "Supprimer ce médecin et son compte ?"
ajoute: string; // "Le médecin {nom} a été ajouté (login : {login})."
modifie: string;
supprime: string;
}
export interface ConfigPersonnes<T extends Personne> {
url: string; // '/medecins' ou '/clients'
cles: ClesPersonnes;
lister(): Promise<T[]>;
trouver(id: number): Promise<T | null>;
ajouter(donnees: DonneesPersonne): Promise<T>;
modifier(id: number, donnees: DonneesPersonne): Promise<T>;
supprimer(id: number): Promise<void>;
}
// les valeurs affichées dans les champs du formulaire
interface ValeursFormulaire {
titre: string;
prenom: string;
nom: string;
login: string;
version: number | null;
}
export abstract class PersonnesController<T extends Personne> {
protected abstract readonly config: ConfigPersonnes<T>;
constructor(protected readonly application: ApplicationModelService) {}
// -----------------------------------------------------------------------
// GET /medecins (ou /clients)
// -----------------------------------------------------------------------
@Get()
@Render('personnes/liste')
async liste() {
const personnes = await this.config.lister();
return {
titrePage: this.config.cles.titre,
cles: this.config.cles,
urlAjouter: `${this.config.url}/ajouter`,
personnes: personnes.map((p) => ({
titre: p.titre,
prenom: p.prenom,
nom: p.nom,
login: p.user?.login ?? '',
urlModifier: `${this.config.url}/${p.id}/modifier`,
urlSupprimer: `${this.config.url}/${p.id}/supprimer`,
})),
};
}
// -----------------------------------------------------------------------
// GET /medecins/ajouter
// -----------------------------------------------------------------------
@Get('ajouter')
formulaireAjout(@Res() res: Response): void {
this.afficherFormulaire(res, null, { titre: 'Mr', prenom: '', nom: '', login: '', version: null });
}
// -----------------------------------------------------------------------
// POST /medecins/ajouter
// -----------------------------------------------------------------------
@Post('ajouter')
@HttpCode(200) // si le formulaire est réaffiché (cf. auth.controller.ts)
async ajouter(@Body() body: Record<string, unknown>, @Res() res: Response): Promise<void> {
const { dto, erreurs } = await validerFormulaire(FormulairePersonneDto, body);
if (erreurs !== null) {
return this.afficherFormulaire(res, null, valeursSaisies(dto), erreurs);
}
try {
const personne = await this.config.ajouter(dto);
flash(res, { type: 'success', cle: this.config.cles.ajoute, params: parametres(personne) });
res.redirect(this.config.url);
} catch (e) {
this.afficherErreurMetier(res, null, valeursSaisies(dto), e);
}
}
// -----------------------------------------------------------------------
// GET /medecins/:id/modifier
// ParseIntPipe : si :id n'est pas un entier, NestJS lève une
// BadRequestException (page "requête invalide", cf. HtmlExceptionFilter)
// -----------------------------------------------------------------------
@Get(':id/modifier')
async formulaireModification(@Param('id', ParseIntPipe) id: number, @Res() res: Response): Promise<void> {
const personne = await this.config.trouver(id);
if (personne === null) {
throw new NotFoundException();
}
this.afficherFormulaire(res, id, {
titre: personne.titre,
prenom: personne.prenom,
nom: personne.nom,
login: personne.user?.login ?? '',
version: personne.version,
});
}
// -----------------------------------------------------------------------
// POST /medecins/:id/modifier
// -----------------------------------------------------------------------
@Post(':id/modifier')
@HttpCode(200)
async modifier(
@Param('id', ParseIntPipe) id: number,
@Body() body: Record<string, unknown>,
@Res() res: Response,
): Promise<void> {
const { dto, erreurs } = await validerFormulaire(FormulairePersonneDto, body);
if (erreurs !== null) {
return this.afficherFormulaire(res, id, valeursSaisies(dto), erreurs);
}
try {
const personne = await this.config.modifier(id, dto);
flash(res, { type: 'success', cle: this.config.cles.modifie, params: parametres(personne) });
res.redirect(this.config.url);
} catch (e) {
this.afficherErreurMetier(res, id, valeursSaisies(dto), e);
}
}
// -----------------------------------------------------------------------
// POST /medecins/:id/supprimer
// (la confirmation "Êtes-vous sûr ?" est demandée par le navigateur avant
// l'envoi du formulaire, cf. public/js/app.js)
// -----------------------------------------------------------------------
@Post(':id/supprimer')
async supprimer(@Param('id', ParseIntPipe) id: number, @Res() res: Response): Promise<void> {
const personne = await this.config.trouver(id);
try {
await this.config.supprimer(id);
flash(res, { type: 'success', cle: this.config.cles.supprime, params: parametres(personne) });
} catch (e) {
if (!(e instanceof MetierException)) {
throw e;
}
flash(res, { type: 'danger', cle: e.cle, params: e.params });
}
res.redirect(this.config.url);
}
// ----------------------------------------------------------------- méthodes privées
// id === null : formulaire de création ; sinon : formulaire de modification
// erreurs : message sous chaque champ refusé par la validation
// erreur : LE message d'erreur de la page, affiché en bas de page
// (cf. views/partials/erreurs.hbs)
// autresChamps : champs à encadrer en rouge SANS message propre (ex :
// prénom + nom d'un homonyme - le message est en bas de page)
private afficherFormulaire(
res: Response,
id: number | null,
valeurs: ValeursFormulaire,
erreurs: ErreursFormulaire = {},
erreur: string | null = null,
autresChamps: string[] = [],
): void {
const creation = id === null;
res.render('personnes/formulaire', {
titrePage: creation ? this.config.cles.ajouter : this.config.cles.modifier,
creation,
action: creation ? `${this.config.url}/ajouter` : `${this.config.url}/${id}/modifier`,
urlListe: this.config.url,
valeurs,
titres: optionsTitres(valeurs.titre),
erreurs,
champsInvalides: champsInvalides(erreurs, autresChamps),
erreur: erreur ?? (Object.keys(erreurs).length > 0 ? 'ERRORS.INVALID_FORM' : null),
});
}
// 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 afficherErreurMetier(res: Response, id: number | null, valeurs: ValeursFormulaire, e: unknown): void {
if (!(e instanceof MetierException)) {
throw e; // vraie anomalie -> HtmlExceptionFilter (page 500)
}
if (e.cle === 'ERRORS.DOCTOR_NOT_FOUND' || e.cle === 'ERRORS.PATIENT_NOT_FOUND') {
// supprimé entre-temps par un autre ADMIN : retour à la liste
flash(res, { type: 'danger', cle: e.cle });
return res.redirect(this.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
this.afficherFormulaire(res, id, valeurs, {}, e.cle, CHAMPS_ERREURS_METIER[e.cle] ?? []);
}
}
// ce que l'utilisateur a saisi (mot de passe exclu : on ne le renvoie jamais au navigateur)
function valeursSaisies(dto: FormulairePersonneDto): ValeursFormulaire {
return {
titre: dto.titre ?? '',
prenom: typeof dto.prenom === 'string' ? dto.prenom : '',
nom: typeof dto.nom === 'string' ? dto.nom : '',
login: dto.login ?? '',
version: dto.version ?? null,
};
}
// {nom} et {login} des messages "ajouté / modifié / supprimé"
function parametres(personne: Personne | null): Record<string, string> {
if (personne === null) {
return { nom: '', login: '' };
}
return { 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 classe abstraite dont héritent les deux contrôleurs.
- lignes 44-53 : les textes propres à chaque type de personne (des clés de traduction) ;
- lignes 55-63 : la configuration que chaque sous-classe fournit : l'URL de base, les textes, et les fonctions à appeler pour lister, trouver, ajouter, modifier, supprimer ;
- ligne 75 : un champ abstrait : chaque sous-classe doit le définir ;
- lignes 82-99 : la liste. Les décorateurs de route posés sur une classe mère sont hérités : [NestJS] les trouve en parcourant la chaîne des prototypes. Le modèle contient, pour chaque personne, les URL de ses boutons ;
- lignes 104-107 et 112-126 : l'ajout, en trois temps, comme à l'exemple 28 : validation, couche métier, PRG ;
- lignes 133-146 et 151-157 : la modification. [ParseIntPipe] refuse un identifiant non numérique (erreur 400) ;
- lignes 176-189 : la suppression. La personne est lue avant, pour afficher son nom dans le message de confirmation ;
- lignes 199-206 : le formulaire, pour la création ([id === null]) comme pour la modification ;
- lignes 223-235 : une personne supprimée entre-temps renvoie à la liste ; les autres erreurs métier réaffichent le formulaire, les champs concernés encadrés en rouge ;
- lignes 239-247 : les valeurs à réafficher (jamais le mot de passe).
11.14.4. src/web/controllers/medecins.controller.ts
| // =========================================================================
// web/controllers/medecins.controller.ts
// -------------------------------------------------------------------------
// Gestion des médecins (rôle ADMIN uniquement). Toutes les routes sont
// héritées de PersonnesController (cf. personnes.controller.ts) : il ne
// reste ici qu'à dire QUOI appeler et QUELS libellés afficher.
//
// @Roles('ADMIN') posé sur la CLASSE s'applique à toutes ses routes, y
// compris celles héritées (RolesGuard cherche la métadonnée sur la méthode,
// puis sur la classe - cf. auth/roles.guard.ts).
// =========================================================================
import { Controller, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { RolesGuard } from '../../auth/roles.guard';
import { Roles } from '../../auth/roles.decorator';
import { ApplicationModelService } from '../application-model.service';
import { Medecin } from '../../entities/medecin.entity';
import { ConfigPersonnes, PersonnesController } from './personnes.controller';
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
@Controller('medecins')
export class MedecinsController extends PersonnesController<Medecin> {
protected readonly config: ConfigPersonnes<Medecin> = {
url: '/medecins',
cles: {
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: () => this.application.getAllMedecins(),
trouver: (id) => this.application.getMedecinById(id),
ajouter: (donnees) => this.application.ajouterMedecin(donnees),
modifier: (id, donnees) => this.application.modifierMedecin(id, donnees),
supprimer: (id) => this.application.supprimerMedecin(id),
};
// un constructeur explicite, pour que TypeScript émette sur CETTE classe
// les métadonnées de type des paramètres, dont NestJS a besoin pour
// savoir quoi injecter (la classe parente, abstraite et sans décorateur,
// n'en a pas)
constructor(application: ApplicationModelService) {
super(application);
}
}
|
- lignes 21-22 : réservé à l'ADMIN. [@Roles] posé sur la classe s'applique à toutes ses routes, héritées comprises ;
- lignes 25-42 : la configuration : les clés de traduction des médecins, et les méthodes du cache à appeler ;
- lignes 48-50 : un constructeur explicite, bien qu'il ne fasse qu'appeler celui de la classe mère : c'est ce qui permet au compilateur d'émettre, pour cette classe, les métadonnées de type dont [NestJS] a besoin pour savoir quoi injecter.
11.14.5. src/web/controllers/clients.controller.ts
| // =========================================================================
// web/controllers/clients.controller.ts
// -------------------------------------------------------------------------
// Gestion des clients (les PATIENTS du cabinet), rôle ADMIN uniquement.
// Même principe que MedecinsController (voir ses commentaires).
// =========================================================================
import { Controller, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { RolesGuard } from '../../auth/roles.guard';
import { Roles } from '../../auth/roles.decorator';
import { ApplicationModelService } from '../application-model.service';
import { Client } from '../../entities/client.entity';
import { ConfigPersonnes, PersonnesController } from './personnes.controller';
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
@Controller('clients')
export class ClientsController extends PersonnesController<Client> {
protected readonly config: ConfigPersonnes<Client> = {
url: '/clients',
cles: {
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: () => this.application.getAllClients(),
trouver: (id) => this.application.getClientById(id),
ajouter: (donnees) => this.application.ajouterClient(donnees),
modifier: (id, donnees) => this.application.modifierClient(id, donnees),
supprimer: (id) => this.application.supprimerClient(id),
};
constructor(application: ApplicationModelService) {
super(application);
}
}
|
Le même contrôleur, pour les clients.
11.14.6. src/web/controllers/inscription.controller.ts
| // =========================================================================
// web/controllers/inscription.controller.ts
// -------------------------------------------------------------------------
// "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/passe-inscription.service.ts) ;
// 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/limiteur-tentatives.service.ts).
//
// Le compte créé est exactement celui que créerait l'ADMIN depuis la page
// "Clients" (même méthode de la couche métier : ajouterClient), si ce n'est
// que le patient CHOISIT son login et son mot de passe (obligatoires, avec
// confirmation).
// =========================================================================
import { Body, Controller, Get, HttpCode, Post, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { ApplicationModelService } from '../application-model.service';
import { CaptchaService } from '../../auth/captcha.service';
import { LimiteurTentativesService } from '../../auth/limiteur-tentatives.service';
import { COOKIE_PASSE_INSCRIPTION, PasseInscriptionService } from '../../auth/passe-inscription.service';
import { MetierException } from '../../metier/metier.exception';
import { flash } from '../contexte-vue';
import {
CHAMPS_ERREURS_METIER,
ErreursFormulaire,
champsInvalides,
optionsTitres,
validerFormulaire,
} from '../helpers/formulaire.helper';
import { FormulaireInscriptionDto, MOT_DE_PASSE_MIN } from '../models/formulaire-personne.dto';
interface ValeursInscription {
titre: string;
prenom: string;
nom: string;
login: string;
}
@Controller('inscription')
export class InscriptionController {
constructor(
private readonly application: ApplicationModelService,
private readonly captchaService: CaptchaService,
private readonly limiteur: LimiteurTentativesService,
private readonly passes: PasseInscriptionService,
) {}
// -----------------------------------------------------------------------
// POST /inscription/acces champs : captchaId, captcha (ceux de la page
// d'authentification ; login et mot de passe, envoyés aussi, sont ignorés)
// -----------------------------------------------------------------------
@Post('acces')
acces(@Body() body: Record<string, unknown>, @Req() req: Request, @Res() res: Response): void {
const ip = req.ip ?? 'inconnue';
// trop de comptes créés récemment depuis cette adresse : inutile d'aller plus loin
const minutes = this.limiteur.minutesBlocageInscription(ip);
if (minutes > 0) {
flash(res, { type: 'danger', cle: 'ERRORS.TOO_MANY_ACCOUNTS', params: { minutes: String(minutes) } });
return res.redirect('/login');
}
// captcha absent ou faux : retour à la page d'authentification, avec
// une nouvelle image (un échec compte comme un échec de connexion)
if (!this.captchaService.verifier(body.captchaId, body.captcha)) {
this.limiteur.echecLogin(ip, '');
flash(res, { type: 'danger', cle: 'ERRORS.CAPTCHA_BEFORE_ACCOUNT' });
return res.redirect('/login');
}
// captcha correct : on délivre le laissez-passer et on ouvre le formulaire
res.cookie(COOKIE_PASSE_INSCRIPTION, this.passes.emettre(), this.passes.optionsCookie());
res.redirect('/inscription');
}
// -----------------------------------------------------------------------
// GET /inscription
// -----------------------------------------------------------------------
@Get()
formulaire(@Req() req: Request, @Res() res: Response): void {
if (!this.aUnLaissezPasser(req, res)) {
return;
}
this.afficher(res, { titre: 'Mr', prenom: '', nom: '', login: '' });
}
// -----------------------------------------------------------------------
// POST /inscription
// -----------------------------------------------------------------------
@Post()
@HttpCode(200) // si le formulaire est réaffiché (cf. auth.controller.ts)
async creer(@Body() body: Record<string, unknown>, @Req() req: Request, @Res() res: Response): Promise<void> {
if (!this.aUnLaissezPasser(req, res)) {
return;
}
const ip = req.ip ?? 'inconnue';
const { dto, erreurs } = await validerFormulaire(FormulaireInscriptionDto, body);
const valeurs: ValeursInscription = {
titre: dto.titre ?? 'Mr',
prenom: typeof dto.prenom === 'string' ? dto.prenom : '',
nom: typeof dto.nom === 'string' ? dto.nom : '',
login: dto.login ?? '',
};
// 1. trop de comptes créés récemment depuis cette adresse ?
const minutes = this.limiteur.minutesBlocageInscription(ip);
if (minutes > 0) {
return this.afficher(res, valeurs, {}, 'ERRORS.TOO_MANY_ACCOUNTS', [], { minutes: String(minutes) });
}
// 2. toutes les erreurs de saisie d'un coup (champs + confirmation) :
// l'utilisateur les corrige toutes en une fois
const toutesErreurs: ErreursFormulaire = { ...(erreurs ?? {}) };
if (!toutesErreurs.password && !toutesErreurs.confirmation && dto.password !== dto.confirmation) {
toutesErreurs.confirmation = 'ERRORS.PASSWORDS_DIFFER';
}
if (Object.keys(toutesErreurs).length > 0) {
return this.afficher(res, valeurs, toutesErreurs);
}
// 3. la création (client + compte USER, dans une transaction)
try {
await this.application.ajouterClient({
titre: dto.titre,
nom: dto.nom,
prenom: dto.prenom,
login: dto.login,
password: dto.password,
});
} catch (e) {
if (!(e instanceof MetierException)) {
throw e;
}
// login ou prénom + nom déjà pris : le laissez-passer reste valable
return this.afficher(res, valeurs, {}, e.cle, CHAMPS_ERREURS_METIER[e.cle] ?? []);
}
// 4. le laissez-passer a servi : il ne permettra pas de créer un 2e compte
this.passes.consommer(req.cookies?.[COOKIE_PASSE_INSCRIPTION]);
res.clearCookie(COOKIE_PASSE_INSCRIPTION, { path: '/inscription' });
this.limiteur.inscriptionReussie(ip);
// 5. retour à la page de connexion, login pré-rempli
flash(res, { type: 'success', cle: 'INSCRIPTION.DONE', params: { login: dto.login } });
res.redirect(`/login?${new URLSearchParams({ login: dto.login }).toString()}`);
}
// ----------------------------------------------------------------- méthodes privées
// pas de laissez-passer valide : retour à la page d'authentification
private aUnLaissezPasser(req: Request, res: Response): boolean {
if (this.passes.estValide(req.cookies?.[COOKIE_PASSE_INSCRIPTION])) {
return true;
}
flash(res, { type: 'danger', cle: 'ERRORS.CAPTCHA_BEFORE_ACCOUNT' });
res.redirect('/login');
return false;
}
// le formulaire ; jamais de mot de passe renvoyé au navigateur
private afficher(
res: Response,
valeurs: ValeursInscription,
erreurs: ErreursFormulaire = {},
erreur: string | null = null,
autresChamps: string[] = [],
erreurParams?: Record<string, string>,
): void {
res.render('compte/inscription', {
titrePage: 'INSCRIPTION.TITLE',
valeurs,
titres: optionsTitres(valeurs.titre),
erreurs,
champsInvalides: champsInvalides(erreurs, autresChamps),
erreur: erreur ?? (Object.keys(erreurs).length > 0 ? 'ERRORS.INVALID_FORM' : null),
erreurParams,
motDePasseMin: MOT_DE_PASSE_MIN,
});
}
}
|
Commentons ce code :
- lignes 69-91 : le bouton Créer un compte client de la page de connexion envoie le formulaire de connexion à cette adresse (attribut [formaction], cf. exemple 20). Le captcha de la page est vérifié ; s'il est bon, un laissez-passer est déposé dans un cookie et le navigateur est redirigé vers le formulaire ;
- lignes 96-102 : le formulaire, seulement avec un laissez-passer valide ;
- lignes 109-163 : la création : laissez-passer, limite horaire, validation (y compris l'égalité du mot de passe et de sa confirmation), création par la couche métier. Le laissez-passer est ensuite consommé, et le patient renvoyé à la page de connexion, son login prérempli ;
- lignes 168-175 : sans laissez-passer, retour à la page de connexion avec un message ;
- lignes 178-185 : le formulaire. [motDePasseMin] sert au texte d'aide (« Au moins 6 caractères »).
11.14.7. src/web/controllers/espace-patient.controller.ts
| // =========================================================================
// web/controllers/espace-patient.controller.ts
// -------------------------------------------------------------------------
// 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 (patientConnecte), 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).
// =========================================================================
import { Body, Controller, Get, HttpCode, Post, Render, Req, Res, UseGuards } from '@nestjs/common';
import { Request, Response } from 'express';
import { ApplicationModelService } from '../application-model.service';
import { AuthService } from '../../auth/auth.service';
import { LimiteurTentativesService } from '../../auth/limiteur-tentatives.service';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { RolesGuard } from '../../auth/roles.guard';
import { Roles } from '../../auth/roles.decorator';
import { UtilisateurConnecte } from '../../auth/jwt.strategy';
import { COOKIE_JWT, optionsCookieJwt } from '../../auth/auth.constants';
import { MetierException } from '../../metier/metier.exception';
import { Client } from '../../entities/client.entity';
import { flash } from '../contexte-vue';
import { formaterHeure, formaterJour } from '../helpers/dates.helper';
import { libelle, patientConnecte } from '../helpers/patient.helper';
import {
CHAMPS_ERREURS_METIER,
ErreursFormulaire,
champsInvalides,
optionsTitres,
validerFormulaire,
} from '../helpers/formulaire.helper';
import { FormulaireMonCompteDto, MOT_DE_PASSE_MIN } from '../models/formulaire-personne.dto';
type RequeteAuthentifiee = Request & { user: UtilisateurConnecte };
interface ValeursMonCompte {
titre: string;
prenom: string;
nom: string;
login: string;
version: number | null;
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('USER')
@Controller()
export class EspacePatientController {
constructor(
private readonly application: ApplicationModelService,
private readonly authService: AuthService,
private readonly limiteur: LimiteurTentativesService,
) {}
// -----------------------------------------------------------------------
// GET /mon-espace
// @Res({ passthrough: true }) : on accède à la réponse (pour lire la langue
// dans res.locals) SANS renoncer au fonctionnement normal de @Render.
// -----------------------------------------------------------------------
@Get('mon-espace')
@Render('patient/espace')
async espace(@Req() req: RequeteAuthentifiee, @Res({ passthrough: true }) res: Response) {
const patient = await patientConnecte(this.application, req.user);
const rendezVous = await this.application.getRvAVenirClient(patient.id);
return {
titrePage: 'ESPACE.TITLE',
patient: libelle(patient),
rendezVous: rendezVous.map((rv) => ({
id: rv.id,
jour: formaterJour(rv.jour, res.locals.lang),
horaire: `${formaterHeure(rv.creneau.hdebut, rv.creneau.mdebut)} - ${formaterHeure(rv.creneau.hfin, rv.creneau.mfin)}`,
medecin: libelle(rv.creneau.medecin),
// lien vers l'agenda du médecin ce jour-là : le patient y voit son nom
urlAgenda: `/agenda?${new URLSearchParams({ idMedecin: String(rv.creneau.medecin.id), jour: rv.jour })}`,
})),
};
}
// -----------------------------------------------------------------------
// POST /mon-espace/annuler champ : idRv
// -----------------------------------------------------------------------
@Post('mon-espace/annuler')
async annulerRv(
@Body() body: Record<string, string>,
@Req() req: RequeteAuthentifiee,
@Res() res: Response,
): Promise<void> {
const patient = await patientConnecte(this.application, req.user);
const rv = await this.application.getRvById(Number(body.idRv) || 0);
// 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 === null || rv.idClient !== patient.id) {
flash(res, { type: 'danger', cle: 'ERRORS.APPOINTMENT_NOT_FOUND' });
} else {
await this.application.supprimerRv(rv);
flash(res, { type: 'success', cle: 'AGENDA.CANCELLED' });
}
res.redirect('/mon-espace');
}
// -----------------------------------------------------------------------
// GET /mon-compte
// -----------------------------------------------------------------------
@Get('mon-compte')
async formulaireCompte(@Req() req: RequeteAuthentifiee, @Res() res: Response): Promise<void> {
const patient = await patientConnecte(this.application, req.user);
this.afficher(res, {
titre: patient.titre,
prenom: patient.prenom,
nom: patient.nom,
login: patient.user.login,
version: patient.version,
});
}
// -----------------------------------------------------------------------
// POST /mon-compte
// -----------------------------------------------------------------------
@Post('mon-compte')
@HttpCode(200) // si le formulaire est réaffiché (cf. auth.controller.ts)
async modifierCompte(
@Body() body: Record<string, unknown>,
@Req() req: RequeteAuthentifiee,
@Res() res: Response,
): Promise<void> {
const patient = await patientConnecte(this.application, req.user);
const { dto, erreurs } = await validerFormulaire(FormulaireMonCompteDto, body);
const valeurs: ValeursMonCompte = {
titre: dto.titre ?? patient.titre,
prenom: typeof dto.prenom === 'string' ? dto.prenom : '',
nom: typeof dto.nom === 'string' ? dto.nom : '',
login: dto.login ?? '',
version: dto.version ?? null,
};
const toutesErreurs: ErreursFormulaire = { ...(erreurs ?? {}) };
if (!toutesErreurs.password && dto.password !== undefined && dto.password !== dto.confirmation) {
toutesErreurs.confirmation = 'ERRORS.PASSWORDS_DIFFER';
}
if (Object.keys(toutesErreurs).length > 0) {
return this.afficher(res, valeurs, toutesErreurs);
}
// le mot de passe actuel
const erreurMotDePasse = await this.verifierMotDePasseActuel(req, patient, dto.motDePasseActuel);
if (erreurMotDePasse !== null) {
return this.afficher(
res,
valeurs,
erreurMotDePasse.cle === 'ERRORS.WRONG_CURRENT_PASSWORD' ? { motDePasseActuel: erreurMotDePasse.cle } : {},
erreurMotDePasse.cle,
[],
erreurMotDePasse.params,
);
}
try {
const client = await this.application.modifierClient(patient.id, {
titre: dto.titre,
nom: dto.nom,
prenom: dto.prenom,
login: dto.login,
password: dto.password, // undefined = inchangé
version: dto.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
res.cookie(COOKIE_JWT, this.authService.creerJeton(client.user), optionsCookieJwt());
flash(res, { type: 'success', cle: 'MON_COMPTE.UPDATED' });
res.redirect('/mon-espace');
} catch (e) {
if (!(e instanceof MetierException)) {
throw e;
}
this.afficher(res, valeurs, {}, e.cle, CHAMPS_ERREURS_METIER[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é.
// -----------------------------------------------------------------------
@Post('mon-compte/supprimer')
async supprimerCompte(
@Body() body: Record<string, string>,
@Req() req: RequeteAuthentifiee,
@Res() res: Response,
): Promise<void> {
const patient = await patientConnecte(this.application, req.user);
const erreurMotDePasse = await this.verifierMotDePasseActuel(req, patient, body.motDePasseSuppression);
if (erreurMotDePasse !== null) {
flash(res, { type: 'danger', cle: erreurMotDePasse.cle, params: erreurMotDePasse.params });
return res.redirect('/mon-compte');
}
await this.application.supprimerClient(patient.id);
res.clearCookie(COOKIE_JWT, { path: '/' });
flash(res, { type: 'success', cle: 'MON_COMPTE.DELETED' });
res.redirect('/login');
}
// ----------------------------------------------------------------- méthodes privées
// null si le mot de passe actuel est correct ; sinon la clé du message
private async verifierMotDePasseActuel(
req: Request,
patient: Client,
motDePasse: unknown,
): Promise<{ cle: string; params?: Record<string, string> } | null> {
const ip = req.ip ?? 'inconnue';
const login = patient.user.login;
const minutes = this.limiteur.minutesBlocageLogin(ip, login);
if (minutes > 0) {
return { cle: 'ERRORS.TOO_MANY_ATTEMPTS', params: { minutes: String(minutes) } };
}
const ok =
typeof motDePasse === 'string' &&
motDePasse !== '' &&
(await this.authService.validerUtilisateur(login, motDePasse)) !== null;
if (!ok) {
this.limiteur.echecLogin(ip, login);
return { cle: 'ERRORS.WRONG_CURRENT_PASSWORD' };
}
return null;
}
private afficher(
res: Response,
valeurs: ValeursMonCompte,
erreurs: ErreursFormulaire = {},
erreur: string | null = null,
autresChamps: string[] = [],
erreurParams?: Record<string, string>,
): void {
res.render('patient/compte', {
titrePage: 'MON_COMPTE.TITLE',
valeurs,
titres: optionsTitres(valeurs.titre),
erreurs,
champsInvalides: champsInvalides(erreurs, autresChamps),
erreur: erreur ?? (Object.keys(erreurs).length > 0 ? 'ERRORS.INVALID_FORM' : null),
erreurParams,
motDePasseMin: MOT_DE_PASSE_MIN,
});
}
}
|
Commentons ce code :
- lignes 59-60 : réservé aux patients ;
- 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 74-91 : la page « Mon espace ». [@Res({ passthrough: true })] donne accès à la réponse (pour lire la langue dans [res.locals] et formater la date) sans renoncer à [@Render] (cf. exemple 05) ;
- lignes 96-101 : l'annulation de son rendez-vous. Un rendez-vous inexistant 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 ;
- lignes 118-128 : le formulaire du compte, prérempli ;
- lignes 133-139 : la modification : validation, vérification du mot de passe actuel, puis couche métier. Le jeton contient le login et le nom affiché : après une modification, on délivre un nouveau jeton (ligne 182), sans quoi la barre de navigation garderait l'ancien nom ;
- lignes 198-203 : la suppression du compte, de la fiche et de tous les rendez-vous (la même méthode de la couche métier que pour l'ADMIN), puis la déconnexion ;
- lignes 219-223 : le mot de passe actuel est exigé pour toute modification : quelqu'un qui trouverait une session restée ouverte ne pourrait ni changer le mot de passe, ni supprimer le compte. Les erreurs comptent comme des échecs de connexion ;
- lignes 241-248 : le formulaire.
11.15. Les vues
Les vues n'ont presque rien à décider : leur modèle a été préparé par les contrôleurs. Chaque vue commence par un commentaire [Handlebars] [{{!-- ... --}}] qui décrit son rôle ; ces commentaires n'apparaissent pas dans les pages envoyées au navigateur.
11.15.1. Le gabarit et les partiels
11.15.1.1. views/layout.hbs
| {{!--
==========================================================================
views/layout.hbs
--------------------------------------------------------------------------
Le gabarit COMMUN à toutes les pages. hbs l'utilise automatiquement
(fichier nommé "layout.hbs" à la racine du dossier des vues) : la page
demandée par le contrôleur (agenda.hbs, login.hbs...) est insérée à
l'emplacement {{{body}}}.
Triple accolades {{{ }}} : le contenu est inséré TEL QUEL, sans
échappement - indispensable ici, puisque [body] est du HTML déjà fabriqué.
Partout ailleurs on utilise {{ }}, qui échappe les caractères < > & " '
(protection contre l'injection de HTML via un nom saisi, par exemple).
Messages : un succès s'affiche en HAUT de la page ({{> flash}}), une
erreur en BAS, sur fond rose léger ({{> erreurs}}).
Équivalent de src/index.html + app.html du client Angular.
==========================================================================
--}}
<!doctype html>
<html lang="{{lang}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{t titrePage}} - {{t 'APP.TITLE'}}</title>
<link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<div class="container py-4">
{{> navbar}}
{{> flash}}
{{{body}}}
{{> erreurs}}
</div>
{{!-- la fenêtre "Êtes-vous sûr ?", cachée tant qu'on n'en a pas besoin --}}
{{> confirmation}}
<script src="/js/app.js"></script>
</body>
</html>
|
- ligne 22 : la langue de la page (cf. exemple 22) ;
- ligne 26 : le titre, traduit : la clé [titrePage] est fournie par chaque action ;
- lignes 32-35 : la barre de navigation, le message de succès, la page, la zone d'erreur ;
- lignes 38-39 : la fenêtre de confirmation et son script (cf. exemple 21).
11.15.1.2. views/partials/navbar.hbs
| {{!--
views/partials/navbar.hbs - la barre de navigation (inséré par {{> navbar}}).
Même contenu que celle du client Angular (titre, FR/EN, rôle, nom,
déconnexion), plus 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 gardes des
contrôleurs (un USER qui tape /medecins à la main obtient une page 403).
Un patient (USER) a en plus son menu "Mon espace".
--}}
<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}}
<ul class="navbar-nav me-auto">
{{#if utilisateur.estPatient}}
<li class="nav-item"><a class="nav-link {{actif chemin '/mon-'}}" href="/mon-espace">{{t 'APP.MENU_MY_SPACE'}}</a></li>
{{/if}}
<li class="nav-item"><a class="nav-link {{actif chemin '/agenda'}}" href="/agenda">{{t 'APP.MENU_AGENDA'}}</a></li>
{{#if utilisateur.estAdmin}}
<li class="nav-item"><a class="nav-link {{actif chemin '/medecins'}}" href="/medecins">{{t 'APP.MENU_DOCTORS'}}</a></li>
<li class="nav-item"><a class="nav-link {{actif chemin '/clients'}}" href="/clients">{{t 'APP.MENU_PATIENTS'}}</a></li>
{{/if}}
</ul>
{{else}}
<div class="me-auto"></div>
{{/if}}
<div class="d-flex align-items-center gap-2">
{{!-- FR / EN : 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 {{#if (eq lang 'fr')}}btn-light{{else}}btn-outline-light{{/if}}" href="/langue/fr">FR</a>
<a class="btn {{#if (eq lang 'en')}}btn-light{{else}}btn-outline-light{{/if}}" href="/langue/en">EN</a>
</div>
{{#if utilisateur}}
<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>
{{/if}}
</div>
</nav>
|
- lignes 14-25 : 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 gardes des contrôleurs ;
- lignes 31-34 : les liens FR / EN ;
- lignes 36-43 : le rôle, le nom, et le bouton de déconnexion, dans un formulaire POST.
11.15.1.3. views/partials/flash.hbs
| {{!--
views/partials/flash.hbs - 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. src/web/contexte-vue.ts).
Un message flash d'ERREUR, lui, est affiché en bas de la page, par
views/partials/erreurs.hbs.
--}}
{{#if (eq flash.type 'success')}}
<div class="alert alert-success" role="status">
{{t flash.cle nom=flash.params.nom login=flash.params.login minutes=flash.params.minutes}}
</div>
{{/if}}
|
ligne 10 : le message est une clé ; ses paramètres possibles ([nom], [login], [minutes]) sont passés au helper [t].
11.15.1.4. views/partials/erreurs.hbs
| {{!--
==========================================================================
views/partials/erreurs.hbs - la zone d'erreur, en BAS de chaque page, sur
fond rose léger (classe .zone-erreur, cf. public/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.
==========================================================================
--}}
{{#if (ou erreur (eq flash.type 'danger'))}}
<div class="zone-erreur" role="alert">
{{#if erreur}}
<div>{{t erreur minutes=erreurParams.minutes}}</div>
{{/if}}
{{#if (eq flash.type 'danger')}}
<div>{{t flash.cle nom=flash.params.nom login=flash.params.login minutes=flash.params.minutes}}</div>
{{/if}}
</div>
{{/if}}
|
ligne 16 : la zone s'affiche s'il y a une erreur de page ou un message flash d'erreur : le helper [ou] combine les deux conditions, [eq] étant utilisé en sous-expression.
11.15.1.5. views/partials/confirmation.hbs
| {{!--
==========================================================================
views/partials/confirmation.hbs - 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 public/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 (partials/reservation.hbs).
==========================================================================
--}}
<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 et des attributs d'accessibilité : [role="alertdialog"], et [aria-labelledby] / [aria-describedby] qui désignent le titre et le message lus par un lecteur d'écran.
11.15.1.6. views/partials/captcha.hbs
| {{!--
==========================================================================
views/partials/captcha.hbs - le captcha de la page d'authentification
(cf. src/auth/captcha.service.ts). Il protège à la fois la connexion et
l'accès à la création de compte (bouton "Créer un compte client").
{{{captcha.svg}}} : triple accolades, l'image SVG est insérée TELLE QUELLE
dans la page. C'est sans danger ici parce que ce SVG est fabriqué par le
serveur lui-même (bibliothèque svg-captcha) 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).
==========================================================================
--}}
<div class="mb-3">
<label class="form-label" for="captcha">{{t 'CAPTCHA.LABEL'}}</label>
<div class="d-flex align-items-center gap-3 mb-2">
<div class="captcha-image" role="img" aria-label="{{t 'CAPTCHA.IMAGE_ALT'}}">{{{captcha.svg}}}</div>
<a class="small" href="{{chemin}}">{{t 'CAPTCHA.NEW_IMAGE'}}</a>
</div>
<input type="hidden" name="captchaId" value="{{captcha.id}}">
<input id="captcha" name="captcha" type="text" maxlength="10" autocomplete="off" autocapitalize="off" spellcheck="false" required
class="form-control {{#if champsInvalides.captcha}}is-invalid{{/if}}">
{{#if erreurs.captcha}}
<div class="invalid-feedback">{{t erreurs.captcha}}</div>
{{else}}
<div class="form-text">{{t 'CAPTCHA.HELP'}}</div>
{{/if}}
</div>
|
- ligne 20 : l'image SVG, insérée telle quelle (triples accolades) : elle est fabriquée par le serveur, sans rien qui vienne de l'utilisateur ;
- ligne 21 : « Autre image » recharge simplement la page ;
- ligne 23 : l'identifiant du captcha, dans un champ caché.
11.15.1.7. views/partials/champsPersonne.hbs
| {{!--
views/partials/champsPersonne.hbs - 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 utilise le modèle de la page qui l'insère : valeurs, titres,
erreurs (message sous le champ) et champsInvalides (encadré rouge).
--}}
<div class="mb-3">
<label class="form-label" for="titre">{{t 'PERSONNES.TITLE_LABEL'}}</label>
<select id="titre" name="titre" class="form-select {{#if champsInvalides.titre}}is-invalid{{/if}}">
{{#each titres}}
<option value="{{valeur}}" {{#if selectionne}}selected{{/if}}>{{valeur}}</option>
{{/each}}
</select>
{{#if erreurs.titre}}<div class="invalid-feedback">{{t erreurs.titre}}</div>{{/if}}
</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 {{#if champsInvalides.prenom}}is-invalid{{/if}}" value="{{valeurs.prenom}}">
{{#if erreurs.prenom}}<div class="invalid-feedback">{{t erreurs.prenom}}</div>{{/if}}
</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 {{#if champsInvalides.nom}}is-invalid{{/if}}" value="{{valeurs.nom}}">
{{#if erreurs.nom}}<div class="invalid-feedback">{{t erreurs.nom}}</div>{{/if}}
</div>
</div>
|
Les champs d'identité, communs à trois formulaires (cf. exemple 19). Un partiel inséré sans paramètre utilise le modèle de la page qui l'insère : [valeurs], [titres], [erreurs], [champsInvalides].
11.15.1.8. views/partials/reservation.hbs
| {{!--
==========================================================================
views/partials/reservation.hbs - 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.
==========================================================================
--}}
<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="{{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="{{idMedecin}}">
<input type="hidden" name="jour" value="{{jour}}">
<input type="hidden" name="idCreneau" value="{{reservation.idCreneau}}">
{{#if reservation.patient}}
{{!-- 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>
{{#each reservation.clients}}
<option value="{{id}}">{{libelle}}</option>
{{/each}}
</select>
{{/if}}
</div>
<div class="modal-footer">
<a class="btn btn-secondary" href="{{urlRetour}}">{{t 'BOOKING_DIALOG.CANCEL'}}</a>
<button type="submit" class="btn btn-primary">{{t 'BOOKING_DIALOG.CONFIRM'}}</button>
</div>
</form>
</div>
</div>
|
- lignes 13-14 : les classes [Bootstrap] d'une fenêtre modale, rendue visible « en dur » : c'est le serveur qui décide de l'afficher ;
- ligne 19 : fermer la fenêtre, c'est suivre un lien vers la même page, sans le paramètre [reserver] ;
- lignes 26-28 : les champs cachés du formulaire ;
- lignes 30-34 : un patient voit son nom ; un gestionnaire choisit le patient dans une liste.
11.15.2. Les pages
11.15.2.1. views/login.hbs
| {{!--
==========================================================================
views/login.hbs - le formulaire de connexion.
Équivalent de features/login/ du client Angular. 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 [erreur], affichée
en bas de page par le gabarit.
==========================================================================
--}}
<div class="row justify-content-center">
<div class="col-sm-8 col-md-6 col-lg-5">
<div class="card p-4">
<h5 class="card-title mb-3">{{t 'LOGIN.TITLE'}}</h5>
<form method="post" action="/login">
<div class="mb-3">
<label class="form-label" for="login">{{t 'LOGIN.LOGIN_LABEL'}}</label>
<input id="login" name="login" type="text" class="form-control" autocomplete="username"
value="{{login}}" required {{#unless login}}autofocus{{/unless}}>
</div>
<div class="mb-3">
<label class="form-label" for="password">{{t 'LOGIN.PASSWORD_LABEL'}}</label>
<input id="password" name="password" type="password" class="form-control"
autocomplete="current-password" required {{#if login}}autofocus{{/if}}>
</div>
{{> captcha}}
<button type="submit" class="btn btn-primary w-100">{{t 'LOGIN.SUBMIT'}}</button>
{{!-- 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 20 : le curseur est placé dans le champ login, ou dans le champ mot de passe si le login est déjà connu ;
- ligne 28 : le captcha ;
- lignes 41-42 : le second bouton envoie le même formulaire à [/inscription/acces] ([formaction]). [formnovalidate] : le navigateur n'exige pas le login et le mot de passe, inutiles pour créer un compte.
11.15.2.2. views/agenda.hbs
| {{!--
==========================================================================
views/agenda.hbs - 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/partials/erreurs.hbs) --}}
{{!-- 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>
{{#each medecins}}
<option value="{{id}}" {{#if selectionne}}selected{{/if}}>{{libelle}}</option>
{{/each}}
</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="{{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 info}}
<div class="alert alert-info">
{{t info}} <a href="/mon-espace" class="alert-link">{{t 'APP.MENU_MY_SPACE'}}</a>
</div>
{{/if}}
{{!-- 2. l'agenda --}}
{{#if agenda}}
<div class="card p-3">
<h5>{{t 'AGENDA.TITLE_PREFIX'}} {{agenda.jour}} - {{agenda.medecin}}</h5>
{{#if agenda.lignes.length}}
<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>
{{#each agenda.lignes}}
<tr {{#if reservable}}class="creneau-libre"{{/if}}>
<td>{{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 libre}}
<span class="badge text-bg-success">{{t 'AGENDA.FREE'}}</span>
{{else if patient}}
<span class="badge {{#if moi}}text-bg-primary{{else}}text-bg-secondary{{/if}}">{{patient}}</span>
{{else}}
<span class="badge text-bg-secondary">{{t 'AGENDA.BOOKED_BY_OTHER'}}</span>
{{/if}}
</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 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="{{urlReserver}}">{{t 'AGENDA.BOOK'}}</a>
{{/if}}
{{#if idRv}}
{{!-- "Annuler" MODIFIE des données : c'est donc un POST.
@root : le modèle de la PAGE (à l'intérieur d'un {{#each}},
les noms simples désignent les champs de la ligne courante) --}}
<form method="post" action="/agenda/annuler" class="m-0"
data-confirmation="{{t 'AGENDA.CANCEL_CONFIRM'}}">
<input type="hidden" name="idRv" value="{{idRv}}">
<input type="hidden" name="idMedecin" value="{{@root.idMedecin}}">
<input type="hidden" name="jour" value="{{@root.jour}}">
<button type="submit" class="btn btn-sm btn-outline-danger">{{t 'AGENDA.CANCEL_APPOINTMENT'}}</button>
</form>
{{/if}}
</td>
</tr>
{{/each}}
</tbody>
</table>
{{else}}
<p class="text-muted mb-0">{{t 'AGENDA.NO_SLOTS'}}</p>
{{/if}}
</div>
{{else}}
<p class="text-muted">{{t 'AGENDA.EMPTY_STATE'}}</p>
{{/if}}
{{!-- 3. la fenêtre de réservation, seulement si l'URL contient &reserver=... --}}
{{#if reservation}}
{{> reservation}}
{{/if}}
|
- lignes 16-35 : le choix du médecin et du jour : un formulaire GET (cf. exemple 17). Un champ [type="date"] envoie la date au format [aaaa-mm-jj] ;
- lignes 38-42 : le message d'un patient qui a déjà un rendez-vous ;
- lignes 59-96 : une ligne par créneau. [{{else if patient}}] enchaîne les conditions : libre, occupé avec un nom visible, occupé sans nom (« Réservé ») ;
- ligne 80 : Réserver est un lien ;
- lignes 86-92 : Annuler est un formulaire POST avec confirmation. Dans la boucle, [@root.idMedecin] désigne le champ du modèle de la page, et non celui de la ligne ;
- lignes 108-110 : la fenêtre de réservation, si le contrôleur l'a préparée.
11.15.2.3. views/personnes/liste.hbs
| {{!--
==========================================================================
views/personnes/liste.hbs - la liste des médecins OU des clients (ADMIN).
Une seule vue pour les deux : les libellés propres à chacun arrivent dans
[cles] (cf. MedecinsController / ClientsController).
==========================================================================
--}}
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">{{t cles.titre}}</h4>
<a class="btn btn-primary" href="{{urlAjouter}}">{{t cles.ajouter}}</a>
</div>
<div class="card p-3">
{{#if personnes.length}}
<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>
{{#each personnes}}
<tr>
<td>{{titre}}</td>
<td>{{prenom}}</td>
<td>{{nom}}</td>
<td><code>{{login}}</code></td>
<td class="text-end">
<div class="d-inline-flex gap-2">
<a class="btn btn-sm btn-outline-primary" href="{{urlModifier}}">{{t 'PERSONNES.EDIT'}}</a>
{{!-- data-confirmation : une fenêtre de confirmation s'ouvrira
avant l'envoi du formulaire (cf. public/js/app.js) --}}
<form method="post" action="{{urlSupprimer}}" class="m-0"
data-confirmation="{{t @root.cles.confirmerSuppression}}">
<button type="submit" class="btn btn-sm btn-outline-danger">{{t 'PERSONNES.DELETE'}}</button>
</form>
</div>
</td>
</tr>
{{/each}}
</tbody>
</table>
{{else}}
<p class="text-muted mb-0">{{t cles.vide}}</p>
{{/if}}
</div>
|
Une seule vue pour les médecins et les clients : les textes arrivent sous forme de clés ([cles.titre], [cles.ajouter]...). Ligne 38 : [@root.cles] est nécessaire dans la boucle.
| {{!--
==========================================================================
views/personnes/formulaire.hbs - création OU modification d'un médecin
ou d'un client (ADMIN). [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 (valeurs.xxx) ;
- les champs fautifs encadrés en rouge (champsInvalides.xxx, classe
Bootstrap "is-invalid") ;
- sous un champ refusé par la validation, son message (erreurs.xxx) ;
- le message d'erreur de la page (erreur), affiché EN BAS de la page
par le gabarit (cf. views/partials/erreurs.hbs).
==========================================================================
--}}
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card p-4">
<h5 class="card-title mb-3">{{t 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="{{action}}" novalidate>
{{#unless creation}}
<input type="hidden" name="version" value="{{valeurs.version}}">
{{/unless}}
{{!-- titre, prénom, nom : partiel commun à trois formulaires --}}
{{> champsPersonne}}
<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 {{#if champsInvalides.login}}is-invalid{{/if}}" value="{{valeurs.login}}">
{{#if erreurs.login}}
<div class="invalid-feedback">{{t erreurs.login}}</div>
{{else}}
<div class="form-text">{{#if creation}}{{t 'PERSONNES.LOGIN_HELP_CREATE'}}{{else}}{{t 'PERSONNES.LOGIN_HELP_EDIT'}}{{/if}}</div>
{{/if}}
</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 {{#if champsInvalides.password}}is-invalid{{/if}}">
{{#if erreurs.password}}
<div class="invalid-feedback">{{t erreurs.password}}</div>
{{else}}
<div class="form-text">{{#if creation}}{{t 'PERSONNES.PASSWORD_HELP_CREATE'}}{{else}}{{t 'PERSONNES.PASSWORD_HELP_EDIT'}}{{/if}}</div>
{{/if}}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">{{t 'PERSONNES.SAVE'}}</button>
<a class="btn btn-secondary" href="{{urlListe}}">{{t 'PERSONNES.BACK'}}</a>
</div>
</form>
</div>
</div>
</div>
|
- ligne 24 : l'adresse d'envoi ([action]) est fournie par le contrôleur. [novalidate] : c'est le serveur qui valide ;
- lignes 25-27 : la version, pour une modification ;
- ligne 30 : les champs d'identité ;
- lignes 35-42 : le login, avec un texte d'aide ou un message d'erreur ;
- lignes 46-54 : le mot de passe, jamais prérempli.
11.15.2.5. views/compte/inscription.hbs
| {{!--
==========================================================================
views/compte/inscription.hbs - "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").
"Retour à la connexion" ramène à la page d'authentification.
==========================================================================
--}}
<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>
{{> champsPersonne}}
<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 {{#if champsInvalides.login}}is-invalid{{/if}}" value="{{valeurs.login}}">
{{#if erreurs.login}}
<div class="invalid-feedback">{{t erreurs.login}}</div>
{{else}}
<div class="form-text">{{t 'INSCRIPTION.LOGIN_HELP'}}</div>
{{/if}}
</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 {{#if champsInvalides.password}}is-invalid{{/if}}">
{{#if erreurs.password}}
<div class="invalid-feedback">{{t erreurs.password min=motDePasseMin}}</div>
{{else}}
<div class="form-text">{{t 'INSCRIPTION.PASSWORD_HELP' min=motDePasseMin}}</div>
{{/if}}
</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 {{#if champsInvalides.confirmation}}is-invalid{{/if}}">
{{#if erreurs.confirmation}}<div class="invalid-feedback">{{t erreurs.confirmation}}</div>{{/if}}
</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. Ligne 42 : le texte d'aide a un paramètre, la longueur minimale du mot de passe, fournie par le contrôleur.
11.15.2.6. views/patient/espace.hbs
| {{!--
==========================================================================
views/patient/espace.hbs - "Mon espace" : la page d'accueil du patient
(rôle USER), affichée juste après sa connexion (cf. EspacePatientController).
- 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.
==========================================================================
--}}
<h4 class="mb-3">{{t 'ESPACE.HELLO' nom=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 rendezVous.length}}
{{!-- normalement un seul (règle "un rendez-vous à venir à la fois"),
mais la page sait en afficher plusieurs --}}
{{#each rendezVous}}
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 py-2 {{#unless @last}}border-bottom{{/unless}}">
<div>
<div class="fw-semibold">{{jour}}, {{horaire}}</div>
<div class="text-muted">{{medecin}} - <a href="{{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="{{id}}">
<button type="submit" class="btn btn-outline-danger">{{t 'ESPACE.CANCEL_APPOINTMENT'}}</button>
</form>
</div>
{{/each}}
{{else}}
<p class="text-muted mb-0">{{t 'ESPACE.NO_APPOINTMENT'}}</p>
{{/if}}
</div>
|
- ligne 9 : « Bonjour Mr Jean MARTIN » ;
- lignes 13-19 : les deux options, des liens ;
- lignes 31-43 : le rendez-vous à venir. [{{#unless @last}}] : une bordure sépare les rendez-vous, sauf après le dernier.
11.15.2.7. views/patient/compte.hbs
| {{!--
==========================================================================
views/patient/compte.hbs - "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.
==========================================================================
--}}
<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="{{valeurs.version}}">
{{> champsPersonne}}
<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 {{#if champsInvalides.login}}is-invalid{{/if}}" value="{{valeurs.login}}">
{{#if erreurs.login}}<div class="invalid-feedback">{{t erreurs.login}}</div>{{/if}}
</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 {{#if champsInvalides.password}}is-invalid{{/if}}">
{{#if erreurs.password}}
<div class="invalid-feedback">{{t erreurs.password min=motDePasseMin}}</div>
{{else}}
<div class="form-text">{{t 'PERSONNES.PASSWORD_HELP_EDIT'}}</div>
{{/if}}
</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 {{#if champsInvalides.confirmation}}is-invalid{{/if}}">
{{#if erreurs.confirmation}}<div class="invalid-feedback">{{t erreurs.confirmation}}</div>{{/if}}
</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 {{#if champsInvalides.motDePasseActuel}}is-invalid{{/if}}">
{{#if erreurs.motDePasseActuel}}
<div class="invalid-feedback">{{t erreurs.motDePasseActuel}}</div>
{{else}}
<div class="form-text">{{t 'MON_COMPTE.CURRENT_PASSWORD_HELP'}}</div>
{{/if}}
</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 17-65 : la modification du compte ;
- lignes 72-80 : la suppression, avec confirmation. Deux formulaires indépendants sur une même page : chacun n'envoie que ses propres champs.
11.15.2.8. views/erreur.hbs
| {{!--
views/erreur.hbs - la page d'erreur (403, 404, 400, 500), fabriquée par
HtmlExceptionFilter (src/web/html-exception.filter.ts).
--}}
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card p-4 text-center">
<h1 class="display-4 text-danger">{{code}}</h1>
<p class="lead">{{t message}}</p>
<div><a class="btn btn-primary" href="/">{{t 'ERROR_PAGE.BACK'}}</a></div>
</div>
</div>
</div>
|
La page d'erreur du filtre d'exceptions : le code HTTP, et le message traduit.
11.16. Les dictionnaires
11.16.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]) ;
- ligne 80 : un texte avec deux paramètres, [{nom}] et [{login}] ;
- lignes 98-137 : les messages d'erreur. Ce sont les clés qu'utilisent les [MetierException] de la couche métier, les décorateurs de validation des formulaires, et le filtre d'exceptions.
11.16.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.17. Les fichiers statiques
11.17.1. public/css/styles.css
| /* =========================================================================
public/css/styles.css - quelques réglages en plus de Bootstrap
(repris de src/styles.css du client Angular).
========================================================================= */
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/partials/erreurs.hbs).
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é des fenêtres modales ;
- lignes 36-43 : les deux options de « Mon espace » réagissent au survol ;
- lignes 46-50 : le cadre de l'image du captcha.
11.17.2. public/js/app.js
| // =========================================================================
// public/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/partials/confirmation.hbs).
// 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é :
- lignes 17-19 : une page sans fenêtre de confirmation n'a rien à faire ;
- ligne 24 : l'élément qui avait le focus clavier avant l'ouverture, auquel on le 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 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 ;
- lignes 71-86 : la touche Échap ferme la fenêtre ; la touche Tab fait tourner le focus dans la fenêtre, au lieu de le laisser partir sur la page grisée, derrière.