À propos de ce cours
Ce projet de synthèse rassemble tout le cours NestJS dans une seule application réelle : FormaPro, une plateforme de gestion de formations pour un centre de formation professionnelle. Vous allez concevoir, développer, sécuriser, tester, documenter et déployer une API complète, puis la connecter à une interface Angular. C'est un projet long, conçu pour être réalisé en plusieurs semaines, comme un vrai projet d'entreprise.
- Analyser un cahier des charges et le traduire en modèle de données et en contrat d'API.
- Construire une API NestJS modulaire, connectée à MySQL avec TypeORM et des migrations.
- Mettre en œuvre une authentification JWT complète (access token, refresh token) et des rôles.
- Programmer des règles métier réelles: capacités, conflits de planning, liste d'attente, transactions et accès concurrents.
- Générer des documents PDF, envoyer des e-mails, planifier des tâches.
- Documenter l'API avec Swagger, la tester avec Vitest et Supertest, la déployer avec Docker et GitHub Actions.
Fiche du projet
| Application | FormaPro: API REST de gestion d'un centre de formation + interface Angular |
|---|---|
| Durée indicative | 70 à 90 heures (6 semaines à raison de 12 à 15 heures par semaine) |
| Organisation | En binôme (ou seul). Chaque membre doit pouvoir expliquer tout le code. |
| Prérequis | Chapitres 1 à 20 du cours NestJS ; chapitres 7 à 15 du cours Angular pour la partie front |
| Technologies | NestJS 12, TypeScript, TypeORM, MySQL 8, JWT, class-validator, Swagger, Vitest, Supertest, Docker, GitHub Actions, Angular |
| Livrables | Dépôt GitHub, API déployée, documentation Swagger, dossier de conception, rapport de tests, démonstration et soutenance |
| Kit de départ | formapro-kit-depart.zip : Docker (MySQL, Adminer, Mailpit), variables d'environnement, migrations, données de démonstration, requêtes de test, Dockerfile, CI |
nest new est en ESM: les imports relatifs se terminent par .js (import { X } from './x.service.js'), __dirname est remplacé par import.meta.dirname, et les tests utilisent Vitest (vi.fn() au lieu de jest.fn()). Tout le code de cet énoncé a été compilé et exécuté avec NestJS 12.5, TypeORM 1.1 et MySQL.
Le contexte
Le centre FormaPro organise des formations courtes pour des salariés et des demandeurs d'emploi : développement web, bureautique, réseaux… Aujourd'hui, tout est géré avec des tableurs et des e-mails. Les problèmes sont nombreux :
- deux sessions ont déjà été programmées dans la même salle, aux mêmes dates;
- une session a accepté plus de stagiaires que de places, parce que deux secrétaires ont inscrit des personnes en même temps ;
- les feuilles de présence sont sur papier : impossible de savoir rapidement qui a droit à son attestation;
- les stagiaires ne reçoivent aucun rappel et oublient parfois le premier jour ;
- la direction ne dispose d'aucun chiffre : taux de remplissage, chiffre d'affaires, formations les plus demandées.
La direction vous confie le développement de la plateforme. Votre client attend une API fiable, sécurisée et documentée, utilisable par une application web (Angular) et, plus tard, par une application mobile.
Cahier des charges
3.1 Les acteurs
| Rôle | Code | Description |
|---|---|---|
| Visiteur | — | Personne non connectée. Consulte le catalogue des formations et les sessions ouvertes, crée un compte. |
| Stagiaire | stagiaire | S'inscrit aux sessions, suit ses inscriptions, consulte son assiduité, télécharge ses attestations. |
| Formateur | formateur | Consulte ses sessions, fait l'appel à chaque séance, saisit les notes de fin de formation. |
| Gestionnaire | gestionnaire | Gère le catalogue, les salles, les sessions et les séances ; valide ou refuse les inscriptions ; consulte les statistiques. |
| Administrateur | admin | Tous les droits du gestionnaire, plus la gestion des comptes (création des formateurs et gestionnaires, désactivation) et les suppressions. |
3.2 Les besoins fonctionnels (user stories)
Les priorités suivent la méthode MoSCoW: M = indispensable (Must), S = important (Should), C = souhaitable (Could). Les fonctionnalités M conditionnent la réussite du projet.
| ID | En tant que… | Je veux… | Priorité |
|---|---|---|---|
| US-01 | visiteur | créer un compte stagiaire avec mon nom, mon e-mail et un mot de passe | M |
| US-02 | utilisateur | me connecter, rester connecté (refresh token) et me déconnecter | M |
| US-03 | visiteur | parcourir le catalogue avec pagination, recherche par mot-clé, filtre par catégorie et tri | M |
| US-04 | visiteur | voir les sessions ouvertes d'une formation, avec les places restantes | M |
| US-05 | gestionnaire | créer, modifier et publier des formations, avec une image | M |
| US-06 | gestionnaire | gérer les salles (nom, capacité, équipement informatique) | M |
| US-07 | gestionnaire | programmer une session (formation, formateur, salle, dates, places) sans conflit de planning | M |
| US-08 | stagiaire | m'inscrire à une session ouverte, ou être placé en liste d'attente si elle est complète | M |
| US-09 | gestionnaire | valider ou refuser une inscription (avec un motif en cas de refus) | M |
| US-10 | stagiaire | annuler mon inscription ; la première personne en liste d'attente récupère la place | M |
| US-11 | gestionnaire | générer les séances d'une session (dates et horaires) | S |
| US-12 | formateur | faire l'appel à chaque séance (présent, retard, absent, excusé) et le corriger | M |
| US-13 | formateur | saisir la note finale (sur 20) et une appréciation pour chaque stagiaire | M |
| US-14 | stagiaire | consulter mon taux d'assiduité et télécharger mon attestation en PDF si j'y ai droit | M |
| US-15 | stagiaire | recevoir un e-mail à chaque étape de mon inscription et un rappel deux jours avant le début | S |
| US-16 | gestionnaire | consulter un tableau de bord : remplissage, chiffre d'affaires, assiduité, formations populaires | S |
| US-17 | administrateur | créer des comptes formateurs et gestionnaires, désactiver un compte | M |
| US-18 | gestionnaire | exporter la liste des inscrits d'une session en CSV | C |
| US-19 | stagiaire | voir en temps réel le nombre de places restantes d'une session | C |
| US-20 | gestionnaire | annuler une session ; tous les inscrits sont prévenus par e-mail | S |
3.3 Les règles de gestion
Les règles de gestion (RG) sont le cœur du projet. Ce sont elles qui distinguent une vraie application d'un simple CRUD. Chaque règle doit être programmée côté serveur et couverte par au moins un test.
| Code | Règle | Réponse HTTP si violée |
|---|---|---|
| Comptes | ||
| RG-U1 | L'e-mail est unique (comparaison sans tenir compte des majuscules). | 409 Conflict |
| RG-U2 | Mot de passe : 8 caractères minimum, au moins une majuscule et un chiffre ; stocké haché avec bcrypt, jamais renvoyé par l'API. | 400 Bad Request |
| RG-U3 | L'inscription publique crée toujours un compte stagiaire. Seul l'administrateur crée les autres rôles. | 403 Forbidden |
| RG-U4 | Un compte désactivé ne peut plus se connecter. | 403 Forbidden |
| Catalogue et sessions | ||
| RG-F1 | Le titre d'une formation est unique ; son slug (adresse lisible) est calculé automatiquement. | 409 Conflict |
| RG-F2 | Une formation qui a des sessions ne peut pas être supprimée : on la dépublie. | 409 Conflict |
| RG-S1 | Le nombre de places d'une session ne dépasse pas la capacité de la salle. | 400 Bad Request |
| RG-S2 | Une salle, comme un formateur, ne peut pas être affecté(e) à deux sessions dont les périodes se chevauchent (sessions annulées et terminées exclues). | 409 Conflict |
| RG-S3 | Une session commence dans le futur et sa date de fin suit sa date de début. | 400 Bad Request |
| RG-S4 | Le formateur d'une session a obligatoirement le rôle formateur. | 400 Bad Request |
| Inscriptions | ||
| RG-I1 | On ne s'inscrit qu'à une session ouverte ou complete (pour la liste d'attente). | 400 Bad Request |
| RG-I2 | Un stagiaire ne s'inscrit qu'une fois à la même session (sauf après annulation). | 409 Conflict |
| RG-I3 | Tant qu'il reste des places, l'inscription prend une place (statut en_attente de validation). La session passe à complete quand toutes les places sont prises. | — |
| RG-I4 | Quand la session est complète, l'inscription passe en liste_attente avec un rang (1, 2, 3…). | — |
| RG-I5 | Un stagiaire peut annuler jusqu'à 3 jours avant le début. Le gestionnaire peut annuler à tout moment. | 400 / 403 |
| RG-I6 | Quand une place se libère (annulation ou refus), le premier de la liste d'attente la récupère automatiquement et il est prévenu par e-mail. | — |
| RG-I7 | Un refus d'inscription exige un motif. | 400 Bad Request |
| RG-I8 | Deux inscriptions simultanées ne peuvent jamais dépasser le nombre de places (accès concurrents). | — |
| Présences, évaluations, attestations | ||
| RG-P1 | Seul le formateur de la session fait l'appel de ses séances. | 403 Forbidden |
| RG-P2 | L'appel ne concerne que les stagiaires dont l'inscription est validee. | 403 Forbidden |
| RG-P3 | Refaire l'appel d'une séance corrige les présences existantes (pas de doublon). | — |
| RG-P4 | Taux d'assiduité = (présent + retard + excusé) / nombre de séances déjà passées, en pourcentage. | — |
| RG-E1 | La note finale est comprise entre 0 et 20 ; elle est saisie par le formateur de la session, une fois la session terminée. | 400 / 403 |
| RG-A1 | Attestation délivrée si : inscription validée, session terminée, assiduité ≥ 80 % et note ≥ 10/20. | 422 Unprocessable Entity |
| RG-A2 | Chaque attestation porte un numéro unique et définitif (ex. ATT-2027-000042). | — |
3.4 Les cycles de vie
Une session et une inscription changent d'état au fil du temps. Seules les transitions ci-dessous sont autorisées ; toute autre transition renvoie 409 Conflict.
| Session | ||
|---|---|---|
| De | Vers | Déclencheur |
| brouillon | ouverte | le gestionnaire ouvre les inscriptions |
| ouverte | complete | dernière place prise |
| complete | ouverte | une place se libère, sans liste d'attente |
| ouverte / complete | en_cours | date de début atteinte (tâche planifiée) |
| en_cours | terminee | date de fin dépassée (tâche planifiée) |
| brouillon / ouverte / complete | annulee | le gestionnaire annule (US-20) |
| Inscription | ||
|---|---|---|
| De | Vers | Déclencheur |
| — | en_attente | inscription, place disponible |
| — | liste_attente | inscription, session complète |
| liste_attente | en_attente | une place se libère et c'est son tour |
| en_attente | validee / refusee | décision du gestionnaire |
| en_attente / liste_attente / validee | annulee | annulation (stagiaire ou gestion) |
3.5 Les exigences non fonctionnelles
- Sécurité: toutes les routes sont protégées par défaut ; seules les routes marquées publiques sont accessibles sans jeton. En-têtes HTTP sécurisés (Helmet), CORS limité au front, limitation du nombre de requêtes, validation stricte de toutes les entrées, aucun secret dans le dépôt Git.
- Fiabilité: les opérations qui modifient plusieurs tables (inscription, annulation, appel) sont faites dans des transactions. Une erreur d'envoi d'e-mail ne fait jamais échouer une inscription.
- Qualité du code: un module par domaine, des DTO pour toutes les entrées, aucune logique métier dans les contrôleurs, code formaté (Prettier) et vérifié (oxlint).
- API: préfixe
/api, versionv1, JSON, codes de statut HTTP corrects, messages d'erreur en français, listes paginées. - Documentation: Swagger complet et à jour, fichier
LISEZ-MOI.mdpermettant à n'importe qui d'installer le projet en moins de 10 minutes. - Tests: au moins 70 % de couverture sur les services ; chaque règle de gestion testée.
- Base de données: schéma géré par des migrations versionnées (pas de
synchronizeen production).
Modélisation des données
4.1 Les entités
| Entité | Table | Attributs principaux |
|---|---|---|
| Utilisateur | utilisateurs | id, prenom, nom, email (unique), motDePasse (haché), role, refreshTokenHash, actif, creeLe |
| Categorie | categories | id, nom (unique) |
| Formation | formations | id, titre, slug (unique), description, prerequis, dureeHeures, prix, image, publiee, creeLe, modifieLe |
| Salle | salles | id, nom (unique), capacite, equipeeInformatique |
| Session | sessions | id, dateDebut, dateFin, placesMax, placesOccupees, statut, version |
| Inscription | inscriptions | id, statut, rangListeAttente, motifRefus, creeLe |
| Seance | seances | id, date, heureDebut, heureFin |
| Presence | presences | id, statut (present, retard, absent, excuse) |
| Evaluation | evaluations | id, note, appreciation, numeroAttestation (unique) |
4.2 Les associations
| Association | Cardinalités | Traduction TypeORM |
|---|---|---|
| Categorie — Formation | une catégorie regroupe 0..n formations ; une formation appartient à 1 catégorie | @OneToMany / @ManyToOne |
| Formation — Session | une formation a 0..n sessions ; une session concerne 1 formation | @OneToMany / @ManyToOne |
| Utilisateur (formateur) — Session | un formateur anime 0..n sessions ; une session a 1 formateur | @ManyToOne |
| Salle — Session | une salle accueille 0..n sessions ; une session a lieu dans 1 salle | @ManyToOne |
| Utilisateur (stagiaire) — Session | plusieurs à plusieurs, porteuse de données (statut, rang, date…) | entité d'association Inscription avec deux @ManyToOne et une contrainte d'unicité |
| Session — Seance | une session a 1..n séances | @OneToMany / @ManyToOne |
| Seance — Utilisateur (stagiaire) | plusieurs à plusieurs, porteuse du statut de présence | entité d'association Presence, unique (seance, stagiaire) |
| Inscription — Evaluation | une inscription a 0..1 évaluation | @OneToOne avec @JoinColumn côté Evaluation |
@ManyToMany? Une relation plusieurs à plusieurs « simple » ne stocke que les deux clés. Ici, l'inscription a son propre statut, un rang, un motif, une date et une évaluation : c'est une entité à part entière. Même raisonnement pour la présence. C'est un choix de modélisation à justifier lors de la soutenance.
4.3 Le schéma relationnel
utilisateurs (id PK, prenom, nom, email UNIQUE, motDePasse, role, refreshTokenHash, actif, creeLe)
categories (id PK, nom UNIQUE)
formations (id PK, titre, slug UNIQUE, description, prerequis, dureeHeures, prix, image,
publiee, creeLe, modifieLe, #categorieId → categories)
salles (id PK, nom UNIQUE, capacite, equipeeInformatique)
sessions (id PK, dateDebut, dateFin, placesMax, placesOccupees, statut, version,
#formationId → formations, #formateurId → utilisateurs, #salleId → salles)
inscriptions (id PK, statut, rangListeAttente, motifRefus, creeLe,
#stagiaireId → utilisateurs, #sessionId → sessions,
UNIQUE (stagiaireId, sessionId))
evaluations (id PK, note, appreciation, numeroAttestation UNIQUE,
#inscriptionId → inscriptions UNIQUE)
seances (id PK, date, heureDebut, heureFin, #sessionId → sessions)
presences (id PK, statut, #seanceId → seances, #stagiaireId → utilisateurs,
UNIQUE (seanceId, stagiaireId))
4.4 Pourquoi un compteur placesOccupees?
On pourrait compter les inscriptions à chaque fois (COUNT(*)). Le compteur stocké dans la session présente deux avantages : la liste des sessions affiche les places restantes sans requête supplémentaire, et surtout, c'est sur la ligne de la session qu'on pose un verrou pendant une inscription (étape 7). Le prix à payer : il faut mettre ce compteur à jour dans la même transaction que l'inscription, sinon il devient faux.
Architecture technique
5.1 La pile technique
| Besoin | Outil | Chapitre du cours |
|---|---|---|
| Framework | NestJS 12 (Express) | 1 à 7 |
| Validation des entrées | class-validator, class-transformer, ValidationPipe | 8 |
| Guards, filtres, intercepteurs | NestJS | 9 |
| Configuration | @nestjs/config, fichier .env | 10 |
| Base de données | MySQL 8.4, TypeORM 1.x, migrations | 11 |
| Authentification | @nestjs/jwt, bcrypt | 13 |
| Documentation | @nestjs/swagger | 14 |
| Fichiers, e-mails, tâches, événements | Multer, Nodemailer + Mailpit, @nestjs/schedule, @nestjs/event-emitter, PDFKit | 15 |
| Temps réel (bonus) | @nestjs/websockets, Socket.IO | 16 |
| Tests | Vitest, Supertest | 17 |
| Front-end | Angular | 18 |
| Sécurité | Helmet, @nestjs/throttler, CORS | 19 |
| Déploiement | Docker, GitHub Actions, PM2 / Nginx | 20 |
5.2 L'organisation des dossiers
Un module par domaine métier. Le dossier common contient ce qui est partagé : énumérations, décorateurs, guards, DTO de pagination.
formapro-api/
├── docker-compose.yml MySQL, Adminer, Mailpit (kit de départ)
├── .env / .env.example
├── api.http requêtes de test (kit de départ)
├── src/
│ ├── main.ts Helmet, CORS, préfixe, versions, ValidationPipe, Swagger
│ ├── app.module.ts Config, TypeORM, Throttler, Events, Schedule + modules
│ ├── common/
│ │ ├── enums/ role.enum.ts, statuts.enum.ts
│ │ ├── decorators/ @Public(), @Roles(), @UtilisateurCourant()
│ │ ├── guards/ jwt-auth.guard.ts, roles.guard.ts
│ │ ├── filters/ filtre d'exception TypeORM → HTTP
│ │ └── dto/ pagination.dto.ts
│ ├── database/
│ │ ├── data-source.ts configuration de la CLI TypeORM
│ │ ├── migrations/ générées par npm run migration:generate
│ │ └── seed.ts données de démonstration
│ ├── utilisateurs/ entité, service, contrôleur (admin), DTO
│ ├── auth/ inscription, connexion, refresh, guards globaux
│ ├── formations/ formations + catégories, upload d'image
│ ├── sessions/ salles, sessions, conflits de planning, séances
│ ├── inscriptions/ inscriptions, liste d'attente, évaluations
│ ├── presences/ appel, assiduité
│ ├── attestations/ génération PDF
│ ├── notifications/ e-mails, tâches planifiées
│ └── statistiques/ tableau de bord
├── test/ tests end-to-end (Vitest + Supertest)
└── uploads/formations/ images téléversées (hors Git)
5.3 Le contrat d'API
Toutes les routes commencent par /api/v1. La colonne « Accès » indique les rôles autorisés ; « public » signifie sans jeton.
| Méthode | Route | Accès | Rôle de la route | US |
|---|---|---|---|---|
| Authentification et comptes | ||||
| POST | /auth/inscription | public | Créer un compte stagiaire, renvoie les jetons | 01 |
| POST | /auth/connexion | public | Se connecter : { accessToken, refreshToken } | 02 |
| POST | /auth/rafraichir | public | Obtenir de nouveaux jetons avec le refresh token | 02 |
| POST | /auth/deconnexion | connecté | Révoquer le refresh token | 02 |
| GET | /auth/moi | connecté | Profil de l'utilisateur connecté | 02 |
| GET | /utilisateurs | admin | Lister les comptes (pagination, filtre par rôle) | 17 |
| POST | /utilisateurs | admin | Créer un formateur ou un gestionnaire | 17 |
| PATCH | /utilisateurs/:id/activation | admin | Activer ou désactiver un compte | 17 |
| Catalogue | ||||
| GET | /categories | public | Lister les catégories | 03 |
| POST / PATCH / DELETE | /categories[/:id] | admin, gestionnaire | Gérer les catégories | 05 |
| GET | /formations?page&limite&recherche&categorieId&tri&ordre | public | Catalogue paginé (formations publiées) | 03 |
| GET | /formations/:id | public | Détail d'une formation | 03 |
| POST | /formations | admin, gestionnaire | Créer une formation | 05 |
| PATCH | /formations/:id | admin, gestionnaire | Modifier, publier, dépublier | 05 |
| POST | /formations/:id/image | admin, gestionnaire | Téléverser l'image (JPEG, PNG, WebP, 2 Mo max) | 05 |
| DELETE | /formations/:id | admin | Supprimer (si aucune session) | 05 |
| Salles et sessions | ||||
| GET / POST / PATCH | /salles[/:id] | admin, gestionnaire | Gérer les salles | 06 |
| GET | /sessions?formationId | public | Sessions ouvertes ou complètes, avec places restantes | 04 |
| GET | /sessions/:id | public | Détail d'une session | 04 |
| GET | /sessions/mes-sessions | formateur | Sessions du formateur connecté | 12 |
| POST | /sessions | admin, gestionnaire | Programmer une session (RG-S1 à S4) | 07 |
| PATCH | /sessions/:id/ouvrir | admin, gestionnaire | Ouvrir les inscriptions | 07 |
| PATCH | /sessions/:id/annuler | admin, gestionnaire | Annuler et prévenir les inscrits | 20 |
| POST | /sessions/:id/seances | admin, gestionnaire | Générer les séances (jours ouvrés, horaires) | 11 |
| GET | /sessions/:id/inscrits | gestion, formateur de la session | Liste des inscrits ; ?format=csv pour l'export | 18 |
| Inscriptions | ||||
| POST | /sessions/:id/inscriptions | stagiaire | S'inscrire (RG-I1 à I4, I8) | 08 |
| GET | /inscriptions/moi | stagiaire | Mes inscriptions | 08 |
| PATCH | /inscriptions/:id/decision | admin, gestionnaire | { accepter, motif? } (RG-I7) | 09 |
| PATCH | /inscriptions/:id/annulation | stagiaire concerné, gestion | Annuler (RG-I5, I6) | 10 |
| Présences, évaluations, attestations | ||||
| POST | /seances/:id/appel | formateur de la session | { lignes: [{ stagiaireId, statut }] } (RG-P1 à P3) | 12 |
| GET | /sessions/:id/mon-assiduite | stagiaire | Mon taux d'assiduité (RG-P4) | 14 |
| PUT | /inscriptions/:id/evaluation | formateur de la session | { note, appreciation? } (RG-E1) | 13 |
| GET | /inscriptions/:id/attestation | stagiaire concerné | Télécharger le PDF (RG-A1, A2) | 14 |
| Statistiques | ||||
| GET | /statistiques/tableau-de-bord?annee | admin, gestionnaire | Indicateurs clés | 16 |
5.4 Le format des réponses
Les listes paginées ont toutes la même forme. Les erreurs suivent le format standard de NestJS.
// GET /api/v1/formations?page=2&limite=10
{
"donnees": [ { "id": 11, "titre": "…", "prix": "450.00", "categorie": { "id": 2, "nom": "…" } } ],
"total": 37,
"page": 2,
"limite": 10,
"pages": 4
}
// Erreur métier : POST /api/v1/sessions (salle déjà occupée)
{
"message": "La salle est déjà occupé(e) du 2027-02-01 au 2027-02-05 (session 1)",
"error": "Conflict",
"statusCode": 409
}
// Erreur de validation : POST /api/v1/formations avec un corps incomplet
{
"message": ["titre must be longer than or equal to 5 characters", "property champInconnu should not exist"],
"error": "Bad Request",
"statusCode": 400
}
Réalisation, étape par étape
Le projet est découpé en 15 étapes. Chaque étape se termine par une liste de critères de validation: ne passez pas à l'étape suivante tant qu'ils ne sont pas tous remplis, et faites un commit à la fin de chaque étape. Le code de référence montre la partie la plus délicate de chaque étape ; le reste est à écrire par vous, sur le même modèle.
etape-03-auth…), une pull request relue par votre binôme, puis fusion dans develop. Messages de commit au format Conventional Commits: feat(inscriptions): liste d'attente, fix(sessions): conflit de salle, test(auth): refresh token. Étape 1 — Initialiser le projet et l'environnement
Objectif: un projet NestJS qui démarre, connecté à MySQL, avec la configuration dans .env et les migrations prêtes. Durée : 3 à 4 h.
- Téléchargez et décompressez le kit de départ, puis créez le projet NestJS dans ce dossier et installez les dépendances :
cd formapro-kit-depart
npx @nestjs/cli new . --package-manager npm --strict
npm i @nestjs/typeorm typeorm mysql2 @nestjs/config dotenv @nestjs/jwt bcrypt \
class-validator class-transformer @nestjs/swagger @nestjs/throttler helmet \
@nestjs/event-emitter @nestjs/schedule nodemailer pdfkit
npm i -D @types/bcrypt @types/nodemailer @types/pdfkit @types/multer
# En cas d'erreur « Cannot read properties of null (reading 'edgesOut') » :
# relancer la même commande avec --legacy-peer-deps
cp .env.example .env # puis remplacer les deux secrets JWT
docker compose up -d # MySQL, Adminer, Mailpit
npm run start:dev
- Ajoutez les scripts de migration dans
package.json:
"scripts": {
"...": "scripts générés par NestJS (build, start, test…)",
"migration:generate": "nest build && typeorm migration:generate -d dist/database/data-source.js",
"migration:run": "nest build && typeorm migration:run -d dist/database/data-source.js",
"migration:revert": "typeorm migration:revert -d dist/database/data-source.js",
"seed": "nest build && node dist/database/seed.js"
}
- Le fichier
docker-compose.ymldu kit lance trois services : MySQL, Adminer pour consulter la base (http://localhost:8080) et Mailpit, un faux serveur d'e-mails qui affiche tous les messages envoyés par l'API (http://localhost:8025).
# Environnement de développement FormaPro : docker compose up -d
services:
mysql:
image: mysql:8.4
container_name: formapro-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: formapro
MYSQL_USER: formapro
MYSQL_PASSWORD: formapro
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
ports:
- "3306:3306"
volumes:
- mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-proot"]
interval: 5s
retries: 10
adminer:
image: adminer
container_name: formapro-adminer
ports:
- "8080:8080"
depends_on:
- mysql
mailpit:
image: axllent/mailpit
container_name: formapro-mailpit
ports:
- "1025:1025" # SMTP (utilisé par l'API)
- "8025:8025" # Interface web : http://localhost:8025
volumes:
mysql-data:
- Configurez TypeORM de façon asynchrone, à partir de
ConfigService, dansapp.module.ts:
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AttestationsModule } from './attestations/attestations.module.js';
import { AuthModule } from './auth/auth.module.js';
import { FormationsModule } from './formations/formations.module.js';
import { InscriptionsModule } from './inscriptions/inscriptions.module.js';
import { NotificationsModule } from './notifications/notifications.module.js';
import { PresencesModule } from './presences/presences.module.js';
import { SessionsModule } from './sessions/sessions.module.js';
import { StatistiquesModule } from './statistiques/statistiques.module.js';
import { UtilisateursModule } from './utilisateurs/utilisateurs.module.js';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'mysql',
host: config.get('DB_HOST', 'localhost'),
port: config.get<number>('DB_PORT', 3306),
username: config.getOrThrow('DB_USER'),
password: config.getOrThrow('DB_PASSWORD'),
database: config.getOrThrow('DB_NAME'),
charset: 'utf8mb4',
autoLoadEntities: true,
synchronize: config.get('DB_SYNC') === 'true', // jamais en production : utiliser les migrations
}),
}),
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),
EventEmitterModule.forRoot(),
ScheduleModule.forRoot(),
UtilisateursModule,
AuthModule,
FormationsModule,
SessionsModule,
InscriptionsModule,
PresencesModule,
AttestationsModule,
NotificationsModule,
StatistiquesModule,
],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class AppModule {}
- La CLI TypeORM a besoin de sa propre configuration. Elle travaille sur le code compilé (
dist/), ce qui évite tout problème d'exécution de TypeScript en ESM :
import 'dotenv/config';
import { DataSource } from 'typeorm';
// Utilisé uniquement par la CLI TypeORM (migrations), sur le code compilé dans dist/
export default new DataSource({
type: 'mysql',
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT ?? 3306),
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
charset: 'utf8mb4',
entities: [import.meta.dirname + '/../**/*.entity.js'],
migrations: [import.meta.dirname + '/migrations/*.js'],
});
- Configurez
main.ts: sécurité, CORS, préfixe/api, versionv1, validation globale et Swagger.
import { ClassSerializerInterceptor, ValidationPipe, VersioningType } from '@nestjs/common';
import { NestFactory, Reflector } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import helmet from 'helmet';
import { AppModule } from './app.module.js';
import { TypeOrmExceptionFilter } from './common/filters/typeorm-exception.filter.js';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(helmet());
app.enableCors({ origin: process.env.FRONT_URL ?? 'http://localhost:4200' });
app.setGlobalPrefix('api');
app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' });
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
);
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
app.useGlobalFilters(new TypeOrmExceptionFilter());
const config = new DocumentBuilder()
.setTitle('FormaPro API')
.setDescription('Plateforme de gestion de formations')
.setVersion('1.0')
.addBearerAuth()
.build();
SwaggerModule.setup('api/docs', app, () => SwaggerModule.createDocument(app, config));
app.enableShutdownHooks();
await app.listen(process.env.PORT ?? 3000);
}
await bootstrap();
synchronize. Avec DB_SYNC=true, TypeORM modifie lui-même les tables pour coller aux entités, et il peut supprimer des colonnes et leurs données. Utilisez-le seulement pendant les premières heures, puis passez aux migrations pour de bon (DB_SYNC=false).
Critères de validation
- ☐
docker compose up -dlance les trois services ; Adminer se connecte à la baseformapro. - ☐
npm run start:devdémarre sans erreur ;http://localhost:3000/api/docsaffiche Swagger. - ☐ Le fichier
.envest dans.gitignore;.env.exampleest versionné. - ☐ Premier commit poussé sur GitHub, avec un
LISEZ-MOI.mdqui explique l'installation.
Étape 2 — Les entités et la première migration
Objectif: traduire le modèle de données en entités TypeORM et créer les tables par une migration. Durée : 5 à 6 h.
Commencez par les énumérations partagées :
export enum Role {
Admin = 'admin',
Gestionnaire = 'gestionnaire',
Formateur = 'formateur',
Stagiaire = 'stagiaire',
}
export enum StatutSession {
Brouillon = 'brouillon',
Ouverte = 'ouverte',
Complete = 'complete',
EnCours = 'en_cours',
Terminee = 'terminee',
Annulee = 'annulee',
}
export enum StatutInscription {
EnAttente = 'en_attente',
Validee = 'validee',
ListeAttente = 'liste_attente',
Refusee = 'refusee',
Annulee = 'annulee',
}
export enum StatutPresence {
Present = 'present',
Retard = 'retard',
Absent = 'absent',
Excuse = 'excuse',
}
Voici deux entités de référence. Observez trois détails importants :
select: falsesur le mot de passe et le refresh token : ces colonnes ne sont jamais chargées, sauf demande explicite ;- le type
Relation<…>autour des relations : indispensable en ESM. Sans lui, deux entités qui se référencent mutuellement (Session ↔ Inscription) provoquent l'erreur Cannot access 'Session' before initialization au démarrage ; @Uniquesur le couple (stagiaire, session) : la base elle-même refuse une double inscription, en dernier rempart derrière le code (RG-I2).
import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn, type Relation } from 'typeorm';
import { Role } from '../common/enums/role.enum.js';
import { Inscription } from '../inscriptions/inscription.entity.js';
@Entity('utilisateurs')
export class Utilisateur {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 80 })
prenom: string;
@Column({ length: 80 })
nom: string;
@Column({ length: 180, unique: true })
email: string;
@Column({ select: false })
motDePasse: string;
@Column({ type: 'enum', enum: Role, default: Role.Stagiaire })
role: Role;
@Column({ type: 'varchar', length: 255, nullable: true, select: false })
refreshTokenHash: string | null;
@Column({ default: true })
actif: boolean;
@CreateDateColumn()
creeLe: Date;
@OneToMany(() => Inscription, (i) => i.stagiaire)
inscriptions: Relation<Inscription[]>;
}
import { Column, Entity, Index, ManyToOne, OneToMany, PrimaryGeneratedColumn, type Relation, VersionColumn } from 'typeorm';
import { StatutSession } from '../common/enums/statuts.enum.js';
import { Formation } from '../formations/formation.entity.js';
import { Inscription } from '../inscriptions/inscription.entity.js';
import { Utilisateur } from '../utilisateurs/utilisateur.entity.js';
import { Salle } from './salle.entity.js';
import { Seance } from '../presences/seance.entity.js';
@Entity('sessions')
@Index(['dateDebut', 'dateFin'])
export class Session {
@PrimaryGeneratedColumn()
id: number;
@ManyToOne(() => Formation, (f) => f.sessions, { nullable: false })
formation: Relation<Formation>;
@ManyToOne(() => Utilisateur, { nullable: false })
formateur: Relation<Utilisateur>;
@ManyToOne(() => Salle, { nullable: false })
salle: Relation<Salle>;
@Column({ type: 'date' })
dateDebut: string;
@Column({ type: 'date' })
dateFin: string;
@Column({ type: 'smallint' })
placesMax: number;
@Column({ type: 'smallint', default: 0 })
placesOccupees: number;
@Column({ type: 'enum', enum: StatutSession, default: StatutSession.Brouillon })
statut: StatutSession;
@VersionColumn()
version: number;
@OneToMany(() => Inscription, (i) => i.session)
inscriptions: Relation<Inscription[]>;
@OneToMany(() => Seance, (s) => s.session)
seances: Relation<Seance[]>;
}
import { Column, CreateDateColumn, Entity, ManyToOne, OneToOne, PrimaryGeneratedColumn, type Relation, Unique } from 'typeorm';
import { StatutInscription } from '../common/enums/statuts.enum.js';
import { Session } from '../sessions/session.entity.js';
import { Utilisateur } from '../utilisateurs/utilisateur.entity.js';
import { Evaluation } from './evaluation.entity.js';
@Entity('inscriptions')
@Unique('uq_inscription_stagiaire_session', ['stagiaire', 'session'])
export class Inscription {
@PrimaryGeneratedColumn()
id: number;
@ManyToOne(() => Utilisateur, (u) => u.inscriptions, { nullable: false })
stagiaire: Relation<Utilisateur>;
@ManyToOne(() => Session, (s) => s.inscriptions, { nullable: false, onDelete: 'CASCADE' })
session: Relation<Session>;
@Column({ type: 'enum', enum: StatutInscription, default: StatutInscription.EnAttente })
statut: StatutInscription;
@Column({ type: 'smallint', nullable: true })
rangListeAttente: number | null;
@Column({ type: 'varchar', length: 255, nullable: true })
motifRefus: string | null;
@CreateDateColumn()
creeLe: Date;
@OneToOne(() => Evaluation, (e) => e.inscription)
evaluation: Relation<Evaluation> | null;
}
Écrivez sur ce modèle les entités Categorie, Formation, Salle, Seance, Presence et Evaluation (voir le schéma relationnel). Puis générez et appliquez la migration :
npm run migration:generate src/database/migrations/Init
npm run migration:run
npm run seed # comptes, catégories, salles et formations de démonstration
Critères de validation
- ☐ Les 9 tables existent, avec leurs clés étrangères et contraintes d'unicité (vérifiez dans Adminer).
- ☐ La table
migrationscontient une ligneInit…. - ☐
npm run seedcrée 16 comptes, 4 catégories, 3 salles et 4 formations. - ☐ Les montants utilisent
decimal(jamaisfloatpour de l'argent).
Étape 3 — Authentification JWT avec refresh token
Objectif: inscription, connexion, rafraîchissement et déconnexion. Durée : 6 à 8 h.
Le principe : l'access token vit 15 minutes et accompagne chaque requête ; le refresh token vit 7 jours et sert uniquement à obtenir de nouveaux jetons. On stocke en base un haché du refresh token : à la déconnexion on l'efface, ce qui le révoque immédiatement.
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsString, Matches, MaxLength, MinLength } from 'class-validator';
export class InscriptionCompteDto {
@ApiProperty({ example: 'Sara' })
@IsString() @IsNotEmpty() @MaxLength(80)
prenom: string;
@ApiProperty({ example: 'Ben Ali' })
@IsString() @IsNotEmpty() @MaxLength(80)
nom: string;
@ApiProperty({ example: 'sara@exemple.tn' })
@IsEmail()
email: string;
@ApiProperty({ example: 'Motdepasse1!' })
@IsString() @MinLength(8)
@Matches(/(?=.*[A-Z])(?=.*\d)/, { message: 'Le mot de passe doit contenir une majuscule et un chiffre' })
motDePasse: string;
}
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import * as bcrypt from 'bcrypt';
import { Repository } from 'typeorm';
import { Role } from '../common/enums/role.enum.js';
import { Utilisateur } from './utilisateur.entity.js';
@Injectable()
export class UtilisateursService {
constructor(@InjectRepository(Utilisateur) private readonly repo: Repository<Utilisateur>) {}
async creer(donnees: { prenom: string; nom: string; email: string; motDePasse: string; role?: Role }): Promise<Utilisateur> {
const email = donnees.email.toLowerCase().trim();
if (await this.repo.existsBy({ email })) {
throw new ConflictException('Un compte existe déjà avec cet e-mail');
}
const utilisateur = this.repo.create({
...donnees,
email,
motDePasse: await bcrypt.hash(donnees.motDePasse, 12),
});
const enregistre = await this.repo.save(utilisateur);
return this.trouverParId(enregistre.id);
}
async trouverParId(id: number): Promise<Utilisateur> {
const utilisateur = await this.repo.findOneBy({ id });
if (!utilisateur) throw new NotFoundException(`Utilisateur ${id} introuvable`);
return utilisateur;
}
/** Charge aussi le hash du mot de passe (exclu par défaut avec select: false). */
trouverAvecSecrets(email: string): Promise<Utilisateur | null> {
return this.repo
.createQueryBuilder('u')
.addSelect(['u.motDePasse', 'u.refreshTokenHash'])
.where('u.email = :email', { email: email.toLowerCase().trim() })
.getOne();
}
trouverParIdAvecSecrets(id: number): Promise<Utilisateur | null> {
return this.repo
.createQueryBuilder('u')
.addSelect('u.refreshTokenHash')
.where('u.id = :id', { id })
.getOne();
}
async enregistrerRefreshToken(id: number, jeton: string | null): Promise<void> {
const refreshTokenHash = jeton ? await bcrypt.hash(jeton, 10) : null;
await this.repo.update(id, { refreshTokenHash });
}
}
import { ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { Utilisateur } from '../utilisateurs/utilisateur.entity.js';
import { UtilisateursService } from '../utilisateurs/utilisateurs.service.js';
import { ConnexionDto } from './dto/connexion.dto.js';
import { InscriptionCompteDto } from './dto/inscription-compte.dto.js';
export interface Jetons {
accessToken: string;
refreshToken: string;
}
@Injectable()
export class AuthService {
constructor(
private readonly utilisateurs: UtilisateursService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
) {}
async inscrire(dto: InscriptionCompteDto): Promise<Jetons> {
const utilisateur = await this.utilisateurs.creer(dto); // rôle stagiaire par défaut
return this.emettreJetons(utilisateur);
}
async connecter(dto: ConnexionDto): Promise<Jetons> {
const utilisateur = await this.utilisateurs.trouverAvecSecrets(dto.email);
const valide = utilisateur && (await bcrypt.compare(dto.motDePasse, utilisateur.motDePasse));
if (!utilisateur || !valide) {
throw new UnauthorizedException('E-mail ou mot de passe incorrect');
}
if (!utilisateur.actif) throw new ForbiddenException('Compte désactivé');
return this.emettreJetons(utilisateur);
}
async rafraichir(refreshToken: string): Promise<Jetons> {
let charge: { sub: number };
try {
charge = await this.jwt.verifyAsync(refreshToken, {
secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'),
});
} catch {
throw new UnauthorizedException('Refresh token invalide');
}
const utilisateur = await this.utilisateurs.trouverParIdAvecSecrets(charge.sub);
if (!utilisateur?.refreshTokenHash || !(await bcrypt.compare(refreshToken, utilisateur.refreshTokenHash))) {
throw new UnauthorizedException('Refresh token révoqué');
}
return this.emettreJetons(utilisateur);
}
deconnecter(id: number): Promise<void> {
return this.utilisateurs.enregistrerRefreshToken(id, null);
}
private async emettreJetons(u: Utilisateur): Promise<Jetons> {
const charge = { sub: u.id, email: u.email, role: u.role };
const [accessToken, refreshToken] = await Promise.all([
this.jwt.signAsync(charge), // secret et durée de JwtModule (15 min)
this.jwt.signAsync({ sub: u.id }, {
secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'),
expiresIn: '7d',
}),
]);
await this.utilisateurs.enregistrerRefreshToken(u.id, refreshToken);
return { accessToken, refreshToken };
}
}
Critères de validation
- ☐
POST /auth/inscriptioncrée un stagiaire et renvoie deux jetons ; un e-mail déjà utilisé renvoie 409. - ☐ Un mot de passe trop faible renvoie 400 avec un message clair.
- ☐
GET /auth/moine renvoie jamaismotDePassenirefreshTokenHash. - ☐ Après
POST /auth/deconnexion, l'ancien refresh token est refusé (401). - ☐ Les deux secrets JWT sont différents et viennent du
.env.
Étape 4 — Autorisation : routes protégées par défaut et rôles
Objectif: protéger toute l'API d'un coup, puis ouvrir explicitement les routes publiques. Durée : 3 à 4 h.
On enregistre deux guards globaux avec APP_GUARD: le premier vérifie le jeton, le second les rôles. Une route oubliée est donc protégée, et non ouverte : c'est le principe de sécurité par défaut.
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
import { SetMetadata } from '@nestjs/common';
import { Role } from '../enums/role.enum.js';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Role } from '../enums/role.enum.js';
export interface UtilisateurJwt {
id: number;
email: string;
role: Role;
}
export const UtilisateurCourant = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): UtilisateurJwt =>
ctx.switchToHttp().getRequest().user,
);
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator.js';
import { UtilisateurJwt } from '../decorators/utilisateur-courant.decorator.js';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private readonly jwt: JwtService,
private readonly reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const estPublique = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (estPublique) return true;
const requete = context.switchToHttp().getRequest<Request & { user?: UtilisateurJwt }>();
const [type, jeton] = requete.headers.authorization?.split(' ') ?? [];
if (type !== 'Bearer' || !jeton) {
throw new UnauthorizedException('Jeton manquant');
}
try {
const charge = await this.jwt.verifyAsync<{ sub: number; email: string; role: UtilisateurJwt['role'] }>(jeton);
requete.user = { id: charge.sub, email: charge.email, role: charge.role };
return true;
} catch {
throw new UnauthorizedException('Jeton invalide ou expiré');
}
}
}
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator.js';
import { Role } from '../enums/role.enum.js';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const rolesRequis = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!rolesRequis || rolesRequis.length === 0) return true;
const { user } = context.switchToHttp().getRequest();
if (!user || !rolesRequis.includes(user.role)) {
throw new ForbiddenException('Accès réservé : ' + rolesRequis.join(', '));
}
return true;
}
}
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard.js';
import { RolesGuard } from '../common/guards/roles.guard.js';
import { UtilisateursModule } from '../utilisateurs/utilisateurs.module.js';
import { AuthController } from './auth.controller.js';
import { AuthService } from './auth.service.js';
@Module({
imports: [
UtilisateursModule,
JwtModule.registerAsync({
global: true,
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>('JWT_SECRET'),
signOptions: { expiresIn: '15m' },
}),
}),
],
controllers: [AuthController],
providers: [
AuthService,
// Ordre important : d'abord l'authentification, ensuite les rôles
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
],
})
export class AuthModule {}
Le contrôleur d'authentification montre l'utilisation des trois décorateurs :
import { Body, Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Public } from '../common/decorators/public.decorator.js';
import { UtilisateurCourant, type UtilisateurJwt } from '../common/decorators/utilisateur-courant.decorator.js';
import { UtilisateursService } from '../utilisateurs/utilisateurs.service.js';
import { AuthService } from './auth.service.js';
import { ConnexionDto } from './dto/connexion.dto.js';
import { InscriptionCompteDto } from './dto/inscription-compte.dto.js';
import { IsJWT } from 'class-validator';
class RafraichirDto {
@IsJWT()
refreshToken: string;
}
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(
private readonly auth: AuthService,
private readonly utilisateurs: UtilisateursService,
) {}
@Public()
@Post('inscription')
inscrire(@Body() dto: InscriptionCompteDto) {
return this.auth.inscrire(dto);
}
@Public()
@Post('connexion')
@HttpCode(HttpStatus.OK)
connecter(@Body() dto: ConnexionDto) {
return this.auth.connecter(dto);
}
@Public()
@Post('rafraichir')
@HttpCode(HttpStatus.OK)
rafraichir(@Body() dto: RafraichirDto) {
return this.auth.rafraichir(dto.refreshToken);
}
@ApiBearerAuth()
@Post('deconnexion')
@HttpCode(HttpStatus.NO_CONTENT)
deconnecter(@UtilisateurCourant() u: UtilisateurJwt) {
return this.auth.deconnecter(u.id);
}
@ApiBearerAuth()
@Get('moi')
moi(@UtilisateurCourant() u: UtilisateurJwt) {
return this.utilisateurs.trouverParId(u.id);
}
}
À faire: le module utilisateurs côté administrateur (US-17) : lister les comptes avec pagination et filtre par rôle, créer un formateur ou un gestionnaire, activer ou désactiver un compte. Un administrateur ne peut pas se désactiver lui-même.
Critères de validation
- ☐ Sans jeton, toute route non publique renvoie 401.
- ☐ Un stagiaire qui appelle une route de gestion reçoit 403 avec un message explicite.
- ☐ Un compte désactivé ne peut plus se connecter (RG-U4).
Étape 5 — Le catalogue : formations, catégories, pagination, image
Objectif: un catalogue complet, filtrable, avec téléversement d'image. Durée : 6 à 8 h.
Un DTO de pagination réutilisable, puis un DTO de filtre qui l'étend. Notez la liste blanche des colonnes de tri : on n'insère jamais directement dans une requête un nom de colonne venu du client.
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class PaginationDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
page = 1;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100)
limite = 10;
@IsOptional() @IsString()
recherche?: string;
}
export interface PageResultat<T> {
donnees: T[];
total: number;
page: number;
limite: number;
pages: number;
}
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional } from 'class-validator';
import { PaginationDto } from '../../common/dto/pagination.dto.js';
export class FiltreFormationsDto extends PaginationDto {
@IsOptional() @Type(() => Number) @IsInt()
categorieId?: number;
@IsOptional() @IsIn(['titre', 'prix', 'dureeHeures', 'creeLe'])
tri: 'titre' | 'prix' | 'dureeHeures' | 'creeLe' = 'creeLe';
@IsOptional() @IsIn(['ASC', 'DESC'])
ordre: 'ASC' | 'DESC' = 'DESC';
}
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsBoolean, IsInt, IsNotEmpty, IsNumber, IsOptional, IsString, MaxLength, Min, MinLength } from 'class-validator';
export class CreerFormationDto {
@ApiProperty({ example: 'Développement d’API avec NestJS' })
@IsString() @MinLength(5) @MaxLength(150)
titre: string;
@ApiProperty()
@IsString() @IsNotEmpty()
description: string;
@ApiProperty({ required: false })
@IsOptional() @IsString()
prerequis?: string;
@ApiProperty({ example: 35 })
@Type(() => Number) @IsInt() @Min(1)
dureeHeures: number;
@ApiProperty({ example: 450 })
@Type(() => Number) @IsNumber({ maxDecimalPlaces: 2 }) @Min(0)
prix: number;
@ApiProperty({ example: 1 })
@Type(() => Number) @IsInt()
categorieId: number;
@ApiProperty({ required: false, default: false })
@IsOptional() @IsBoolean()
publiee?: boolean;
}
import { PartialType } from '@nestjs/swagger';
import { CreerFormationDto } from './creer-formation.dto.js';
export class ModifierFormationDto extends PartialType(CreerFormationDto) {}
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'node:crypto';
import { mkdir, unlink, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { Repository } from 'typeorm';
import { PageResultat } from '../common/dto/pagination.dto.js';
import { Categorie } from './categorie.entity.js';
import { CreerFormationDto } from './dto/creer-formation.dto.js';
import { FiltreFormationsDto } from './dto/filtre-formations.dto.js';
import { ModifierFormationDto } from './dto/modifier-formation.dto.js';
import { Formation } from './formation.entity.js';
export function slugifier(texte: string): string {
return texte
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
@Injectable()
export class FormationsService {
constructor(
@InjectRepository(Formation) private readonly formations: Repository<Formation>,
@InjectRepository(Categorie) private readonly categories: Repository<Categorie>,
) {}
async lister(f: FiltreFormationsDto, inclureBrouillons = false): Promise<PageResultat<Formation>> {
const qb = this.formations
.createQueryBuilder('f')
.leftJoinAndSelect('f.categorie', 'c')
.orderBy(`f.${f.tri}`, f.ordre)
.skip((f.page - 1) * f.limite)
.take(f.limite);
if (!inclureBrouillons) qb.andWhere('f.publiee = :pub', { pub: true });
if (f.categorieId) qb.andWhere('c.id = :cat', { cat: f.categorieId });
if (f.recherche) {
qb.andWhere('(f.titre LIKE :q OR f.description LIKE :q)', { q: `%${f.recherche}%` });
}
const [donnees, total] = await qb.getManyAndCount();
return { donnees, total, page: f.page, limite: f.limite, pages: Math.ceil(total / f.limite) };
}
async trouver(id: number): Promise<Formation> {
const formation = await this.formations.findOne({ where: { id }, relations: { categorie: true } });
if (!formation) throw new NotFoundException(`Formation ${id} introuvable`);
return formation;
}
async creer(dto: CreerFormationDto): Promise<Formation> {
const categorie = await this.categories.findOneBy({ id: dto.categorieId });
if (!categorie) throw new NotFoundException(`Catégorie ${dto.categorieId} introuvable`);
const slug = slugifier(dto.titre);
if (await this.formations.existsBy({ slug })) {
throw new ConflictException('Une formation porte déjà ce titre');
}
const { categorieId, prix, ...reste } = dto;
return this.formations.save(this.formations.create({ ...reste, prix: prix.toFixed(2), slug, categorie }));
}
async modifier(id: number, dto: ModifierFormationDto): Promise<Formation> {
const formation = await this.trouver(id);
const { categorieId, prix, ...reste } = dto;
if (categorieId) {
const categorie = await this.categories.findOneBy({ id: categorieId });
if (!categorie) throw new NotFoundException(`Catégorie ${categorieId} introuvable`);
formation.categorie = categorie;
}
if (prix !== undefined) formation.prix = prix.toFixed(2);
if (reste.titre) formation.slug = slugifier(reste.titre);
Object.assign(formation, reste);
return this.formations.save(formation);
}
async supprimer(id: number): Promise<void> {
const formation = await this.formations.findOne({ where: { id }, relations: { sessions: true } });
if (!formation) throw new NotFoundException(`Formation ${id} introuvable`);
if (formation.sessions.length > 0) {
throw new ConflictException('Impossible de supprimer une formation qui a des sessions : dépubliez-la');
}
await this.formations.remove(formation);
}
async enregistrerImage(id: number, fichier: Express.Multer.File): Promise<Formation> {
const formation = await this.trouver(id);
const extensions: Record<string, string> = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp' };
const nom = randomUUID() + (extensions[fichier.mimetype] ?? '');
const dossier = join(process.cwd(), 'uploads', 'formations');
await mkdir(dossier, { recursive: true });
await writeFile(join(dossier, nom), fichier.buffer);
if (formation.image) {
// Supprime l'ancienne image ; on ignore l'erreur si elle n'existe plus
await unlink(join(process.cwd(), formation.image)).catch(() => undefined);
}
formation.image = `/uploads/formations/${nom}`;
return this.formations.save(formation);
}
}
Pour l'image, le fichier est reçu en mémoire: le validateur de type de NestJS lit alors ses premiers octets (sa « signature ») et reconnaît un PDF renommé en .png. Avec un stockage direct sur disque, cette vérification est impossible. Le service écrit ensuite le fichier sous un nom aléatoire : on ne fait jamais confiance au nom fourni par l'utilisateur.
import { Body, Controller, Delete, Get, HttpCode, Param, ParseIntPipe, Patch, Post, Query, UploadedFile, UseInterceptors, ParseFilePipeBuilder, HttpStatus } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Public } from '../common/decorators/public.decorator.js';
import { Roles } from '../common/decorators/roles.decorator.js';
import { Role } from '../common/enums/role.enum.js';
import { CreerFormationDto } from './dto/creer-formation.dto.js';
import { FiltreFormationsDto } from './dto/filtre-formations.dto.js';
import { ModifierFormationDto } from './dto/modifier-formation.dto.js';
import { FormationsService } from './formations.service.js';
@ApiTags('formations')
@Controller('formations')
export class FormationsController {
constructor(private readonly service: FormationsService) {}
@Public()
@Get()
lister(@Query() filtre: FiltreFormationsDto) {
return this.service.lister(filtre);
}
@Public()
@Get(':id')
trouver(@Param('id', ParseIntPipe) id: number) {
return this.service.trouver(id);
}
@ApiBearerAuth()
@Roles(Role.Admin, Role.Gestionnaire)
@Post()
creer(@Body() dto: CreerFormationDto) {
return this.service.creer(dto);
}
@ApiBearerAuth()
@Roles(Role.Admin, Role.Gestionnaire)
@Patch(':id')
modifier(@Param('id', ParseIntPipe) id: number, @Body() dto: ModifierFormationDto) {
return this.service.modifier(id, dto);
}
@ApiBearerAuth()
@Roles(Role.Admin)
@Delete(':id')
@HttpCode(204)
supprimer(@Param('id', ParseIntPipe) id: number) {
return this.service.supprimer(id);
}
@ApiBearerAuth()
@Roles(Role.Admin, Role.Gestionnaire)
@Post(':id/image')
// Stockage en mémoire : le validateur lit les premiers octets du fichier (signature réelle)
@UseInterceptors(FileInterceptor('image', { limits: { fileSize: 2 * 1024 * 1024 } }))
televerserImage(
@Param('id', ParseIntPipe) id: number,
@UploadedFile(
new ParseFilePipeBuilder()
.addFileTypeValidator({ fileType: /^image\/(jpeg|png|webp)$/ })
.addMaxSizeValidator({ maxSize: 2 * 1024 * 1024 })
.build({ errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY }),
)
fichier: Express.Multer.File,
) {
return this.service.enregistrerImage(id, fichier);
}
}
Express.Multer.File introuvable ? Le tsconfig.json généré par NestJS 12 limite les types globaux ("types": ["vitest/globals", "node"]). Ajoutez "multer" à cette liste, après avoir installé @types/multer. À faire: le CRUD des catégories ; servir le dossier uploads/ en statique pour que les images s'affichent (chapitre 15).
Critères de validation
- ☐
GET /formations?recherche=nest&tri=prix&ordre=ASC&page=1&limite=5fonctionne ;tri=motDePasserenvoie 400. - ☐ Le visiteur ne voit que les formations publiées.
- ☐ Un PDF renommé en
.pngest refusé (422) ; une image de plus de 2 Mo est refusée (413). - ☐ Téléverser une nouvelle image supprime l'ancienne du disque.
- ☐ Supprimer une formation qui a des sessions renvoie 409 (RG-F2).
Étape 6 — Salles et sessions : la détection des conflits
Objectif: programmer des sessions sans jamais réserver deux fois une salle ou un formateur. Durée : 6 à 8 h.
Deux périodes [a1, a2] et [b1, b2] se chevauchent si et seulement si a1 ≤ b2 ET b1 ≤ a2. Cette condition unique couvre tous les cas : chevauchement partiel, inclusion, dates identiques. C'est une question classique d'entretien d'embauche : dessinez les cas sur papier pour vous en convaincre.
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsDateString, IsInt, Max, Min } from 'class-validator';
export class CreerSessionDto {
@ApiProperty() @Type(() => Number) @IsInt()
formationId: number;
@ApiProperty() @Type(() => Number) @IsInt()
formateurId: number;
@ApiProperty() @Type(() => Number) @IsInt()
salleId: number;
@ApiProperty({ example: '2027-02-01' }) @IsDateString({ strict: true })
dateDebut: string;
@ApiProperty({ example: '2027-02-12' }) @IsDateString({ strict: true })
dateFin: string;
@ApiProperty({ example: 16 }) @Type(() => Number) @IsInt() @Min(1) @Max(60)
placesMax: number;
}
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Not, In, Repository } from 'typeorm';
import { Role } from '../common/enums/role.enum.js';
import { StatutSession } from '../common/enums/statuts.enum.js';
import { Formation } from '../formations/formation.entity.js';
import { Utilisateur } from '../utilisateurs/utilisateur.entity.js';
import { CreerSessionDto } from './dto/creer-session.dto.js';
import { Salle } from './salle.entity.js';
import { Session } from './session.entity.js';
const STATUTS_ACTIFS_EXCLUS = [StatutSession.Annulee, StatutSession.Terminee];
@Injectable()
export class SessionsService {
constructor(
@InjectRepository(Session) private readonly sessions: Repository<Session>,
@InjectRepository(Salle) private readonly salles: Repository<Salle>,
@InjectRepository(Formation) private readonly formations: Repository<Formation>,
@InjectRepository(Utilisateur) private readonly utilisateurs: Repository<Utilisateur>,
) {}
async creer(dto: CreerSessionDto): Promise<Session> {
if (dto.dateFin < dto.dateDebut) {
throw new BadRequestException('La date de fin doit suivre la date de début');
}
const aujourdHui = new Date().toISOString().slice(0, 10);
if (dto.dateDebut <= aujourdHui) {
throw new BadRequestException('Une session doit commencer dans le futur');
}
const [formation, formateur, salle] = await Promise.all([
this.formations.findOneBy({ id: dto.formationId }),
this.utilisateurs.findOneBy({ id: dto.formateurId }),
this.salles.findOneBy({ id: dto.salleId }),
]);
if (!formation) throw new NotFoundException('Formation introuvable');
if (!formateur || formateur.role !== Role.Formateur) {
throw new BadRequestException("L'utilisateur choisi n'est pas un formateur");
}
if (!salle) throw new NotFoundException('Salle introuvable');
if (dto.placesMax > salle.capacite) {
throw new BadRequestException(`La salle ${salle.nom} n'a que ${salle.capacite} places`);
}
await this.verifierDisponibilites(dto.salleId, dto.formateurId, dto.dateDebut, dto.dateFin);
return this.sessions.save(
this.sessions.create({
formation, formateur, salle,
dateDebut: dto.dateDebut, dateFin: dto.dateFin, placesMax: dto.placesMax,
}),
);
}
/**
* Règle RG-S2 : une salle et un formateur ne peuvent pas être réservés
* sur deux sessions dont les périodes se chevauchent.
* Deux périodes [a1, a2] et [b1, b2] se chevauchent si a1 <= b2 ET b1 <= a2.
*/
async verifierDisponibilites(salleId: number, formateurId: number, debut: string, fin: string, exclureId?: number) {
const qb = this.sessions
.createQueryBuilder('s')
.leftJoin('s.salle', 'salle')
.leftJoin('s.formateur', 'formateur')
.where('s.dateDebut <= :fin AND :debut <= s.dateFin', { debut, fin })
.andWhere('s.statut NOT IN (:...exclus)', { exclus: STATUTS_ACTIFS_EXCLUS })
.andWhere('(salle.id = :salleId OR formateur.id = :formateurId)', { salleId, formateurId })
.select(['s.id', 's.dateDebut', 's.dateFin', 'salle.id', 'formateur.id']);
if (exclureId) qb.andWhere('s.id != :exclureId', { exclureId });
const conflit = await qb.getOne();
if (conflit) {
const qui = conflit.salle.id === salleId ? 'La salle' : 'Le formateur';
throw new ConflictException(
`${qui} est déjà occupé(e) du ${conflit.dateDebut} au ${conflit.dateFin} (session ${conflit.id})`,
);
}
}
async ouvrir(id: number): Promise<Session> {
const session = await this.trouver(id);
if (session.statut !== StatutSession.Brouillon) {
throw new ConflictException(`Seule une session en brouillon peut être ouverte (statut : ${session.statut})`);
}
session.statut = StatutSession.Ouverte;
return this.sessions.save(session);
}
async trouver(id: number): Promise<Session> {
const session = await this.sessions.findOne({
where: { id },
relations: { formation: true, formateur: true, salle: true },
});
if (!session) throw new NotFoundException(`Session ${id} introuvable`);
return session;
}
listerOuvertes(formationId?: number): Promise<Session[]> {
return this.sessions.find({
where: {
statut: In([StatutSession.Ouverte, StatutSession.Complete]),
...(formationId ? { formation: { id: formationId } } : {}),
},
relations: { formation: true, salle: true },
order: { dateDebut: 'ASC' },
});
}
listerDuFormateur(formateurId: number): Promise<Session[]> {
return this.sessions.find({
where: { formateur: { id: formateurId }, statut: Not(StatutSession.Annulee) },
relations: { formation: true, salle: true },
order: { dateDebut: 'ASC' },
});
}
}
À faire
- Le CRUD des salles ; on ne réduit pas la capacité d'une salle en dessous des places d'une session future qui l'utilise.
PATCH /sessions/:idpour modifier une session en brouillon (en revérifiant les conflits, avecexclureId).POST /sessions/:id/seances(US-11) : à partir de{ heureDebut, heureFin, joursSemaine: [1,2,3,4,5] }, créer une séance pour chaque jour ouvré entre les dates de la session.PATCH /sessions/:id/annuler(US-20) : passer la session et toutes ses inscriptions actives àannulee, dans une transaction, puis émettre un événement pour prévenir les inscrits.
Critères de validation
- ☐ Les cinq cas de chevauchement (début, fin, inclusion, englobement, dates identiques) renvoient 409 ; deux sessions qui se suivent sans se toucher sont acceptées.
- ☐ Une session plus grande que sa salle renvoie 400 (RG-S1).
- ☐ Choisir un stagiaire comme formateur renvoie 400 (RG-S4).
- ☐ Une session annulée libère la salle et le formateur.
Étape 7 — Les inscriptions : transactions, verrou et liste d'attente
Objectif: la partie la plus importante du projet. Une inscription modifie deux tables (l'inscription et le compteur de la session) et doit résister aux accès simultanés. Durée : 8 à 10 h.
Le problème des accès concurrents
Imaginez une session de 2 places, dont 1 est déjà prise. Deux stagiaires cliquent sur « S'inscrire » à la même milliseconde :
| Temps | Requête A | Requête B |
|---|---|---|
| t1 | lit placesOccupees = 1 → il reste une place | |
| t2 | lit placesOccupees = 1 → il reste une place | |
| t3 | enregistre l'inscription, écrit placesOccupees = 2 | |
| t4 | enregistre l'inscription, écrit placesOccupees = 2 | |
| Résultat : 3 inscrits pour 2 places, et un compteur faux. C'est exactement le problème vécu par le centre. | ||
La solution : une transaction qui commence par poser un verrou d'écriture sur la ligne de la session (SELECT … FOR UPDATE, que TypeORM écrit lock: { mode: 'pessimistic_write' }). La requête B attend que A ait terminé, puis lit la valeur à jour. Le verrou ne bloque que cette session : les inscriptions aux autres sessions continuent normalement.
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, Repository } from 'typeorm';
import { UtilisateurJwt } from '../common/decorators/utilisateur-courant.decorator.js';
import { Role } from '../common/enums/role.enum.js';
import { StatutInscription, StatutSession } from '../common/enums/statuts.enum.js';
import { Session } from '../sessions/session.entity.js';
import { Utilisateur } from '../utilisateurs/utilisateur.entity.js';
import { Inscription } from './inscription.entity.js';
export class InscriptionCreeeEvent {
constructor(public readonly inscriptionId: number, public readonly statut: StatutInscription) {}
}
export class PlaceLibereeEvent {
constructor(public readonly sessionId: number, public readonly promueId: number | null) {}
}
const STATUTS_OCCUPANT_UNE_PLACE = [StatutInscription.EnAttente, StatutInscription.Validee];
@Injectable()
export class InscriptionsService {
constructor(
private readonly dataSource: DataSource,
@InjectRepository(Inscription) private readonly inscriptions: Repository<Inscription>,
private readonly evenements: EventEmitter2,
) {}
/**
* RG-I1 à RG-I4 : un stagiaire s'inscrit à une session ouverte, une seule fois,
* dans la limite des places ; au-delà, il passe en liste d'attente.
* Tout se fait dans UNE transaction avec un verrou sur la ligne de la session :
* deux inscriptions simultanées ne peuvent pas prendre la dernière place.
*/
async inscrire(sessionId: number, stagiaireId: number): Promise<Inscription> {
const inscription = await this.dataSource.transaction(async (em) => {
const session = await em.findOne(Session, {
where: { id: sessionId },
lock: { mode: 'pessimistic_write' },
});
if (!session) throw new NotFoundException(`Session ${sessionId} introuvable`);
if (![StatutSession.Ouverte, StatutSession.Complete].includes(session.statut)) {
throw new BadRequestException("Cette session n'accepte pas d'inscriptions");
}
const dejaInscrit = await em.findOne(Inscription, {
where: { session: { id: sessionId }, stagiaire: { id: stagiaireId } },
});
if (dejaInscrit && dejaInscrit.statut !== StatutInscription.Annulee) {
throw new ConflictException('Vous êtes déjà inscrit(e) à cette session');
}
const stagiaire = await em.findOneByOrFail(Utilisateur, { id: stagiaireId });
const inscription = dejaInscrit ?? em.create(Inscription, { session, stagiaire });
if (session.placesOccupees < session.placesMax) {
inscription.statut = StatutInscription.EnAttente; // en attente de validation
inscription.rangListeAttente = null;
session.placesOccupees += 1;
if (session.placesOccupees === session.placesMax) session.statut = StatutSession.Complete;
} else {
inscription.statut = StatutInscription.ListeAttente;
inscription.rangListeAttente = (await this.dernierRang(em, sessionId)) + 1;
}
await em.save(session);
return em.save(inscription);
});
this.evenements.emit('inscription.creee', new InscriptionCreeeEvent(inscription.id, inscription.statut));
return inscription;
}
async valider(id: number, accepter: boolean, motif?: string): Promise<Inscription> {
return this.dataSource.transaction(async (em) => {
const inscription = await em.findOne(Inscription, { where: { id }, relations: { session: true } });
if (!inscription) throw new NotFoundException(`Inscription ${id} introuvable`);
if (inscription.statut !== StatutInscription.EnAttente) {
throw new ConflictException(`Inscription déjà traitée (statut : ${inscription.statut})`);
}
if (accepter) {
inscription.statut = StatutInscription.Validee;
return em.save(inscription);
}
if (!motif) throw new BadRequestException('Un motif est obligatoire pour refuser');
inscription.statut = StatutInscription.Refusee;
inscription.motifRefus = motif;
await em.save(inscription);
await this.libererPlace(em, inscription.session.id);
return inscription;
});
}
/** RG-I5 : annulation par le stagiaire jusqu'à J-3 ; la première personne en liste d'attente est promue. */
async annuler(id: number, demandeur: UtilisateurJwt): Promise<Inscription> {
return this.dataSource.transaction(async (em) => {
const inscription = await em.findOne(Inscription, {
where: { id },
relations: { session: true, stagiaire: true },
});
if (!inscription) throw new NotFoundException(`Inscription ${id} introuvable`);
const estGestion = [Role.Admin, Role.Gestionnaire].includes(demandeur.role);
if (!estGestion && inscription.stagiaire.id !== demandeur.id) {
throw new ForbiddenException("Vous ne pouvez annuler que vos propres inscriptions");
}
const limite = new Date(inscription.session.dateDebut);
limite.setDate(limite.getDate() - 3);
if (!estGestion && new Date() > limite) {
throw new BadRequestException("L'annulation n'est plus possible à moins de 3 jours du début");
}
const occupaitUnePlace = STATUTS_OCCUPANT_UNE_PLACE.includes(inscription.statut);
inscription.statut = StatutInscription.Annulee;
inscription.rangListeAttente = null;
await em.save(inscription);
if (occupaitUnePlace) await this.libererPlace(em, inscription.session.id);
return inscription;
});
}
private async libererPlace(em: EntityManager, sessionId: number): Promise<void> {
const session = await em.findOneOrFail(Session, {
where: { id: sessionId },
lock: { mode: 'pessimistic_write' },
});
const suivant = await em.findOne(Inscription, {
where: { session: { id: sessionId }, statut: StatutInscription.ListeAttente },
order: { rangListeAttente: 'ASC' },
});
if (suivant) {
// La place passe directement au premier de la liste : placesOccupees ne change pas
suivant.statut = StatutInscription.EnAttente;
suivant.rangListeAttente = null;
await em.save(suivant);
} else {
session.placesOccupees -= 1;
if (session.statut === StatutSession.Complete) session.statut = StatutSession.Ouverte;
await em.save(session);
}
this.evenements.emit('place.liberee', new PlaceLibereeEvent(sessionId, suivant?.id ?? null));
}
private async dernierRang(em: EntityManager, sessionId: number): Promise<number> {
const { max } = (await em
.createQueryBuilder(Inscription, 'i')
.select('MAX(i.rangListeAttente)', 'max')
.where('i.sessionId = :sessionId', { sessionId })
.getRawOne<{ max: number | null }>()) ?? { max: null };
return Number(max ?? 0);
}
mesInscriptions(stagiaireId: number): Promise<Inscription[]> {
return this.inscriptions.find({
where: { stagiaire: { id: stagiaireId } },
relations: { session: { formation: true, salle: true }, evaluation: true },
order: { creeLe: 'DESC' },
});
}
}
en_attente et deux en liste_attente (rangs 1 et 2), et un compteur juste. Le test de bout en bout de l'étape 13 rejoue ce scénario à chaque exécution.
Trois remarques sur ce code :
- Les événements (
inscription.creee) sont émis après la transaction. Si on envoyait l'e-mail à l'intérieur et que la transaction échouait ensuite, le stagiaire recevrait un e-mail pour une inscription qui n'existe pas. - Quand une place se libère et qu'il y a une liste d'attente, le compteur ne change pas: la place passe directement au suivant.
- Le service vérifie qui annule : un stagiaire ne peut annuler que ses propres inscriptions. Vérifier le rôle ne suffit pas ; il faut aussi vérifier la propriété de la ressource.
import { Body, Controller, Get, Param, ParseIntPipe, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator';
import { Roles } from '../common/decorators/roles.decorator.js';
import { UtilisateurCourant, type UtilisateurJwt } from '../common/decorators/utilisateur-courant.decorator.js';
import { Role } from '../common/enums/role.enum.js';
import { InscriptionsService } from './inscriptions.service.js';
class DecisionDto {
@IsBoolean()
accepter: boolean;
@IsOptional() @IsString() @MaxLength(255)
motif?: string;
}
@ApiTags('inscriptions')
@ApiBearerAuth()
@Controller()
export class InscriptionsController {
constructor(private readonly service: InscriptionsService) {}
@Roles(Role.Stagiaire)
@Post('sessions/:id/inscriptions')
inscrire(@Param('id', ParseIntPipe) sessionId: number, @UtilisateurCourant() u: UtilisateurJwt) {
return this.service.inscrire(sessionId, u.id);
}
@Roles(Role.Stagiaire)
@Get('inscriptions/moi')
mesInscriptions(@UtilisateurCourant() u: UtilisateurJwt) {
return this.service.mesInscriptions(u.id);
}
@Roles(Role.Admin, Role.Gestionnaire)
@Patch('inscriptions/:id/decision')
decider(@Param('id', ParseIntPipe) id: number, @Body() dto: DecisionDto) {
return this.service.valider(id, dto.accepter, dto.motif);
}
@Patch('inscriptions/:id/annulation')
annuler(@Param('id', ParseIntPipe) id: number, @UtilisateurCourant() u: UtilisateurJwt) {
return this.service.annuler(id, u);
}
}
À faire
GET /sessions/:id/inscritspour le gestionnaire et le formateur de la session, avec l'export CSV (US-18) : en-têteContent-Type: text/csv; charset=utf-8, séparateur point-virgule, ce qui permet de l'ouvrir directement dans Excel.- Faites calculer par l'API les places restantes (
placesMax - placesOccupees) dans la liste des sessions.
Critères de validation
- ☐ Les règles RG-I1 à RG-I8 sont respectées et chacune est démontrable avec
api.http. - ☐ Le compteur
placesOccupeesest toujours égal au nombre d'inscriptionsen_attente+validee(écrivez la requête SQL de contrôle). - ☐ Arrêter MySQL au milieu d'une inscription ne laisse aucune donnée incohérente (la transaction est annulée).
Étape 8 — Séances, appel et assiduité
Objectif: permettre au formateur de faire l'appel et calculer l'assiduité. Durée : 5 à 6 h.
L'appel utilise un upsert (« insérer ou mettre à jour ») : grâce à la contrainte d'unicité (séance, stagiaire), refaire l'appel corrige les présences au lieu de créer des doublons (RG-P3).
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { StatutInscription, StatutPresence } from '../common/enums/statuts.enum.js';
import { Inscription } from '../inscriptions/inscription.entity.js';
import { Utilisateur } from '../utilisateurs/utilisateur.entity.js';
import { Presence } from './presence.entity.js';
import { Seance } from './seance.entity.js';
export interface LignePresence {
stagiaireId: number;
statut: StatutPresence;
}
@Injectable()
export class PresencesService {
constructor(
private readonly dataSource: DataSource,
@InjectRepository(Seance) private readonly seances: Repository<Seance>,
) {}
/** RG-P1 : seul le formateur de la session saisit l'appel ; RG-P2 : seulement pour les inscrits validés. */
async saisirAppel(seanceId: number, formateurId: number, lignes: LignePresence[]): Promise<Presence[]> {
return this.dataSource.transaction(async (em) => {
const seance = await em.findOne(Seance, {
where: { id: seanceId },
relations: { session: { formateur: true } },
});
if (!seance) throw new NotFoundException(`Séance ${seanceId} introuvable`);
if (seance.session.formateur.id !== formateurId) {
throw new ForbiddenException("Vous n'êtes pas le formateur de cette session");
}
const valides = await em.find(Inscription, {
where: { session: { id: seance.session.id }, statut: StatutInscription.Validee },
relations: { stagiaire: true },
});
const idsValides = new Set(valides.map((i) => i.stagiaire.id));
const intrus = lignes.filter((l) => !idsValides.has(l.stagiaireId));
if (intrus.length) {
throw new ForbiddenException(`Stagiaires non inscrits : ${intrus.map((l) => l.stagiaireId).join(', ')}`);
}
// upsert : une deuxième saisie corrige la première au lieu de créer un doublon
await em.upsert(
Presence,
lignes.map((l) => ({
seance: { id: seanceId },
stagiaire: { id: l.stagiaireId } as Utilisateur,
statut: l.statut,
})),
{ conflictPaths: ['seance', 'stagiaire'] },
);
return em.find(Presence, { where: { seance: { id: seanceId } }, relations: { stagiaire: true } });
});
}
/** Taux d'assiduité = (présent + retard + excusé) / séances déjà passées. */
async tauxAssiduite(sessionId: number, stagiaireId: number): Promise<number> {
const aujourdHui = new Date().toISOString().slice(0, 10);
const total = await this.seances
.createQueryBuilder('s')
.where('s.sessionId = :sessionId AND s.date <= :aujourdHui', { sessionId, aujourdHui })
.getCount();
if (total === 0) return 0;
const presentes = await this.dataSource
.getRepository(Presence)
.createQueryBuilder('p')
.innerJoin('p.seance', 's')
.where('s.sessionId = :sessionId AND p.stagiaireId = :stagiaireId', { sessionId, stagiaireId })
.andWhere('p.statut IN (:...ok)', { ok: [StatutPresence.Present, StatutPresence.Retard, StatutPresence.Excuse] })
.getCount();
return Math.round((presentes / total) * 1000) / 10;
}
}
Le contrôleur valide un tableau d'objets: @ValidateNested({ each: true }) avec @Type() pour que chaque ligne soit contrôlée.
import { Body, Controller, Get, Param, ParseIntPipe, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsEnum, IsInt, ValidateNested } from 'class-validator';
import { Roles } from '../common/decorators/roles.decorator.js';
import { UtilisateurCourant, type UtilisateurJwt } from '../common/decorators/utilisateur-courant.decorator.js';
import { Role } from '../common/enums/role.enum.js';
import { StatutPresence } from '../common/enums/statuts.enum.js';
import { PresencesService } from './presences.service.js';
class LignePresenceDto {
@IsInt()
stagiaireId: number;
@IsEnum(StatutPresence)
statut: StatutPresence;
}
class AppelDto {
@ArrayNotEmpty()
@ValidateNested({ each: true })
@Type(() => LignePresenceDto)
lignes: LignePresenceDto[];
}
@ApiTags('presences')
@ApiBearerAuth()
@Controller()
export class PresencesController {
constructor(private readonly service: PresencesService) {}
@Roles(Role.Formateur)
@Post('seances/:id/appel')
appel(@Param('id', ParseIntPipe) id: number, @Body() dto: AppelDto, @UtilisateurCourant() u: UtilisateurJwt) {
return this.service.saisirAppel(id, u.id, dto.lignes);
}
@Roles(Role.Stagiaire)
@Get('sessions/:id/mon-assiduite')
async monAssiduite(@Param('id', ParseIntPipe) id: number, @UtilisateurCourant() u: UtilisateurJwt) {
return { sessionId: id, taux: await this.service.tauxAssiduite(id, u.id) };
}
}
Critères de validation
- ☐ Un autre formateur reçoit 403 ; un stagiaire non inscrit dans la liste renvoie 403 avec son identifiant.
- ☐ Refaire l'appel ne crée aucune ligne en double.
- ☐ Avec 5 séances passées et 2 absences, le taux affiché est 60 %.
Étape 9 — Évaluations et attestation PDF
Objectif: saisir les notes et délivrer une attestation PDF numérotée aux stagiaires qui y ont droit. Durée : 5 à 6 h.
À faire d'abord: PUT /inscriptions/:id/evaluation pour le formateur de la session (RG-E1). Utilisez @Min(0), @Max(20) et @IsNumber({ maxDecimalPlaces: 2 }). PUT crée l'évaluation si elle n'existe pas et la remplace sinon.
Le PDF est construit en mémoire avec PDFKit, puis renvoyé avec StreamableFile. Le numéro d'attestation n'est attribué qu'une fois : un second téléchargement redonne le même numéro (RG-A2).
import { ForbiddenException, Injectable, NotFoundException, UnprocessableEntityException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import PDFDocument from 'pdfkit';
import { Repository } from 'typeorm';
import { StatutInscription, StatutSession } from '../common/enums/statuts.enum.js';
import { Inscription } from '../inscriptions/inscription.entity.js';
import { PresencesService } from '../presences/presences.service.js';
export const SEUIL_ASSIDUITE = 80;
export const NOTE_MINIMALE = 10;
@Injectable()
export class AttestationsService {
constructor(
@InjectRepository(Inscription) private readonly inscriptions: Repository<Inscription>,
private readonly presences: PresencesService,
) {}
/** RG-A1 : attestation si session terminée, assiduité ≥ 80 % et note ≥ 10/20. */
async generer(inscriptionId: number, demandeurId: number): Promise<{ nomFichier: string; pdf: Buffer }> {
const ins = await this.inscriptions.findOne({
where: { id: inscriptionId },
relations: { stagiaire: true, evaluation: true, session: { formation: true, formateur: true } },
});
if (!ins) throw new NotFoundException(`Inscription ${inscriptionId} introuvable`);
if (ins.stagiaire.id !== demandeurId) throw new ForbiddenException();
if (ins.statut !== StatutInscription.Validee || ins.session.statut !== StatutSession.Terminee) {
throw new UnprocessableEntityException("La session n'est pas terminée");
}
const taux = await this.presences.tauxAssiduite(ins.session.id, ins.stagiaire.id);
const note = Number(ins.evaluation?.note ?? 0);
if (taux < SEUIL_ASSIDUITE || note < NOTE_MINIMALE) {
throw new UnprocessableEntityException(
`Conditions non remplies : assiduité ${taux} % (min. ${SEUIL_ASSIDUITE} %), note ${note}/20 (min. ${NOTE_MINIMALE})`,
);
}
const numero = ins.evaluation!.numeroAttestation ?? `ATT-${new Date().getFullYear()}-${String(ins.id).padStart(6, '0')}`;
if (!ins.evaluation!.numeroAttestation) {
ins.evaluation!.numeroAttestation = numero;
await this.inscriptions.manager.save(ins.evaluation!);
}
const pdf = await this.dessiner(ins, numero, taux, note);
return { nomFichier: `${numero}.pdf`, pdf };
}
private dessiner(ins: Inscription, numero: string, taux: number, note: number): Promise<Buffer> {
return new Promise((resolve) => {
const doc = new PDFDocument({ size: 'A4', layout: 'landscape', margin: 60 });
const morceaux: Buffer[] = [];
doc.on('data', (m: Buffer) => morceaux.push(m));
doc.on('end', () => resolve(Buffer.concat(morceaux)));
doc.fontSize(30).fillColor('#E0234E').text('ATTESTATION DE FORMATION', { align: 'center' });
doc.moveDown(1.5).fontSize(16).fillColor('#111')
.text(`Nous attestons que ${ins.stagiaire.prenom} ${ins.stagiaire.nom}`, { align: 'center' })
.moveDown(0.5)
.text(`a suivi avec succès la formation « ${ins.session.formation.titre} »`, { align: 'center' })
.moveDown(0.5)
.text(`du ${ins.session.dateDebut} au ${ins.session.dateFin} (${ins.session.formation.dureeHeures} heures)`, { align: 'center' })
.moveDown(0.5)
.text(`Assiduité : ${taux} % — Note finale : ${note}/20`, { align: 'center' });
doc.moveDown(3).fontSize(11).fillColor('#555')
.text(`Formateur : ${ins.session.formateur.prenom} ${ins.session.formateur.nom}`, { align: 'left' })
.text(`N° ${numero}`, { align: 'right' });
doc.end();
});
}
}
import { Controller, Get, Param, ParseIntPipe, Res, StreamableFile } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { Roles } from '../common/decorators/roles.decorator.js';
import { UtilisateurCourant, type UtilisateurJwt } from '../common/decorators/utilisateur-courant.decorator.js';
import { Role } from '../common/enums/role.enum.js';
import { AttestationsService } from './attestations.service.js';
@ApiTags('attestations')
@ApiBearerAuth()
@Controller('inscriptions/:id/attestation')
export class AttestationsController {
constructor(private readonly service: AttestationsService) {}
@Roles(Role.Stagiaire)
@Get()
async telecharger(
@Param('id', ParseIntPipe) id: number,
@UtilisateurCourant() u: UtilisateurJwt,
@Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> {
const { nomFichier, pdf } = await this.service.generer(id, u.id);
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${nomFichier}"`,
});
return new StreamableFile(pdf);
}
}
Pour aller plus loin: ajoutez le logo du centre et un QR code qui pointe vers une page publique de vérification GET /attestations/verifier/:numero. Un employeur pourra ainsi vérifier qu'une attestation est authentique.
Critères de validation
- ☐ Assiduité 60 % ou note 8/20 : 422 avec un message qui donne les deux valeurs.
- ☐ Un stagiaire ne peut pas télécharger l'attestation d'un autre (403).
- ☐ Le PDF s'ouvre correctement et les accents s'affichent.
Étape 10 — Notifications par e-mail et tâches planifiées
Objectif: prévenir les stagiaires automatiquement et faire évoluer les statuts des sessions sans intervention humaine. Durée : 5 à 6 h.
Le service d'inscription émet des événements ; le service de notifications les écoute. Les deux modules ne se connaissent pas : on peut ajouter demain une notification par SMS sans toucher au code des inscriptions. Pendant le développement, tous les e-mails arrivent dans Mailpit (http://localhost:8025).
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OnEvent } from '@nestjs/event-emitter';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import nodemailer, { type Transporter } from 'nodemailer';
import { Repository } from 'typeorm';
import { StatutInscription } from '../common/enums/statuts.enum.js';
import { Inscription } from '../inscriptions/inscription.entity.js';
import { InscriptionCreeeEvent } from '../inscriptions/inscriptions.service.js';
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
private readonly transport: Transporter;
constructor(
config: ConfigService,
@InjectRepository(Inscription) private readonly inscriptions: Repository<Inscription>,
) {
this.transport = nodemailer.createTransport({
host: config.get('SMTP_HOST', 'localhost'),
port: config.get<number>('SMTP_PORT', 1025),
});
}
@OnEvent('inscription.creee', { async: true })
async surInscription(evt: InscriptionCreeeEvent) {
const ins = await this.inscriptions.findOne({
where: { id: evt.inscriptionId },
relations: { stagiaire: true, session: { formation: true } },
});
if (!ins) return;
const sujet = evt.statut === StatutInscription.ListeAttente
? `Liste d'attente : ${ins.session.formation.titre}`
: `Demande d'inscription reçue : ${ins.session.formation.titre}`;
await this.envoyer(ins.stagiaire.email, sujet,
`Bonjour ${ins.stagiaire.prenom},\n\nVotre demande pour la session du ${ins.session.dateDebut} est enregistrée (statut : ${ins.statut}).`);
}
/** Tous les jours à 8 h : rappel aux stagiaires validés dont la session commence dans 2 jours. */
@Cron(CronExpression.EVERY_DAY_AT_8AM, { name: 'rappel-j2', timeZone: 'Africa/Tunis' })
async rappelJ2() {
const cible = new Date();
cible.setDate(cible.getDate() + 2);
const jour = cible.toISOString().slice(0, 10);
const liste = await this.inscriptions.find({
where: { statut: StatutInscription.Validee, session: { dateDebut: jour } },
relations: { stagiaire: true, session: { formation: true, salle: true } },
});
for (const ins of liste) {
await this.envoyer(ins.stagiaire.email, `Rappel : votre formation commence le ${jour}`,
`Rendez-vous en salle ${ins.session.salle.nom} pour « ${ins.session.formation.titre} ».`);
}
this.logger.log(`Rappel J-2 : ${liste.length} e-mail(s) envoyé(s)`);
}
private async envoyer(to: string, subject: string, text: string) {
try {
await this.transport.sendMail({ from: 'FormaPro <no-reply@formapro.tn>', to, subject, text });
} catch (e) {
// Un e-mail en échec ne doit jamais faire échouer l'inscription
this.logger.error(`E-mail non envoyé à ${to} : ${(e as Error).message}`);
}
}
}
À faire
- Écouter
place.liberee: prévenir le stagiaire promu depuis la liste d'attente (RG-I6). - Écouter la validation et le refus d'une inscription (avec le motif).
- Prévenir tous les inscrits quand une session est annulée (US-20).
- Ajouter une tâche quotidienne, à 0 h 05, qui passe les sessions à
en_coursle jour de leur début et àtermineele lendemain de leur fin. - Remplacer le texte brut par des e-mails HTML simples, à partir de petits gabarits.
Critères de validation
- ☐ Chaque étape d'une inscription produit un e-mail visible dans Mailpit.
- ☐ Arrêter Mailpit n'empêche pas les inscriptions : l'erreur est seulement journalisée.
- ☐ Les tâches planifiées peuvent être déclenchées à la main pour les tester (méthode de service appelée par une route réservée à l'admin, ou test unitaire).
Étape 11 — Le tableau de bord statistique
Objectif: fournir à la direction les chiffres qui lui manquent, avec des requêtes d'agrégation. Durée : 4 à 5 h.
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { StatutInscription, StatutSession } from '../common/enums/statuts.enum.js';
import { Inscription } from '../inscriptions/inscription.entity.js';
import { Session } from '../sessions/session.entity.js';
@Injectable()
export class StatistiquesService {
constructor(private readonly dataSource: DataSource) {}
async tableauDeBord(annee: number) {
const debut = `${annee}-01-01`;
const fin = `${annee}-12-31`;
// 1. Remplissage moyen des sessions (hors annulées)
const remplissage = await this.dataSource
.getRepository(Session)
.createQueryBuilder('s')
.select('COUNT(*)', 'sessions')
.addSelect('ROUND(AVG(s.placesOccupees / s.placesMax) * 100, 1)', 'tauxRemplissage')
.where('s.dateDebut BETWEEN :debut AND :fin', { debut, fin })
.andWhere('s.statut != :annulee', { annulee: StatutSession.Annulee })
.getRawOne<{ sessions: string; tauxRemplissage: string | null }>();
// 2. Chiffre d'affaires : inscriptions validées × prix de la formation
const ca = await this.dataSource
.getRepository(Inscription)
.createQueryBuilder('i')
.innerJoin('i.session', 's')
.innerJoin('s.formation', 'f')
.select('COALESCE(SUM(f.prix), 0)', 'chiffreAffaires')
.where('i.statut = :validee', { validee: StatutInscription.Validee })
.andWhere('s.dateDebut BETWEEN :debut AND :fin', { debut, fin })
.getRawOne<{ chiffreAffaires: string }>();
// 3. Top 5 des formations les plus demandées
const top = await this.dataSource
.getRepository(Inscription)
.createQueryBuilder('i')
.innerJoin('i.session', 's')
.innerJoin('s.formation', 'f')
.select(['f.id AS id', 'f.titre AS titre'])
.addSelect('COUNT(i.id)', 'demandes')
.where('s.dateDebut BETWEEN :debut AND :fin', { debut, fin })
.andWhere('i.statut != :annulee', { annulee: StatutInscription.Annulee })
.groupBy('f.id')
.addGroupBy('f.titre')
.orderBy('demandes', 'DESC')
.limit(5)
.getRawMany<{ id: number; titre: string; demandes: string }>();
// 4. Inscriptions par mois (pour un graphique)
const parMois = await this.dataSource
.getRepository(Inscription)
.createQueryBuilder('i')
.select('MONTH(i.creeLe)', 'mois')
.addSelect('COUNT(*)', 'inscriptions')
.where('YEAR(i.creeLe) = :annee', { annee })
.groupBy('mois')
.orderBy('mois')
.getRawMany<{ mois: number; inscriptions: string }>();
return {
annee,
sessions: Number(remplissage?.sessions ?? 0),
tauxRemplissage: Number(remplissage?.tauxRemplissage ?? 0),
chiffreAffaires: Number(ca?.chiffreAffaires ?? 0),
topFormations: top.map((t) => ({ ...t, demandes: Number(t.demandes) })),
inscriptionsParMois: parMois.map((m) => ({ mois: Number(m.mois), inscriptions: Number(m.inscriptions) })),
};
}
}
import { Controller, DefaultValuePipe, Get, ParseIntPipe, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Roles } from '../common/decorators/roles.decorator.js';
import { Role } from '../common/enums/role.enum.js';
import { StatistiquesService } from './statistiques.service.js';
@ApiTags('statistiques')
@ApiBearerAuth()
@Roles(Role.Admin, Role.Gestionnaire)
@Controller('statistiques')
export class StatistiquesController {
constructor(private readonly service: StatistiquesService) {}
@Get('tableau-de-bord')
tableauDeBord(@Query('annee', new DefaultValuePipe(new Date().getFullYear()), ParseIntPipe) annee: number) {
return this.service.tableauDeBord(annee);
}
}
Number(…) partout ? Avec MySQL, les résultats d'agrégats (COUNT, SUM, AVG) et les colonnes decimal arrivent sous forme de chaînes, pour ne pas perdre de précision. Il faut les convertir avant de les renvoyer.
À faire: ajouter le taux d'assiduité moyen par formation, et mettre le tableau de bord en cache 10 minutes (@nestjs/cache-manager, chapitre 15).
Étape 12 — Sécurité, gestion des erreurs et documentation
Objectif: rendre l'API prête pour la production. Durée : 4 à 5 h.
Un filtre d'exception global transforme les erreurs de base de données en réponses HTTP propres. Sans lui, une contrainte d'unicité violée produit une erreur 500 qui peut révéler la structure de la base.
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus, Logger } from '@nestjs/common';
import type { Response } from 'express';
import { EntityNotFoundError, QueryFailedError } from 'typeorm';
/**
* Transforme les erreurs de base de données en réponses HTTP propres,
* au lieu d'une erreur 500 qui exposerait la requête SQL.
*/
@Catch(QueryFailedError, EntityNotFoundError)
export class TypeOrmExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(TypeOrmExceptionFilter.name);
catch(exception: QueryFailedError | EntityNotFoundError, host: ArgumentsHost) {
const res = host.switchToHttp().getResponse<Response>();
if (exception instanceof EntityNotFoundError) {
return res.status(HttpStatus.NOT_FOUND).json({
statusCode: 404, error: 'Not Found', message: 'Ressource introuvable',
});
}
const code = (exception.driverError as { code?: string } | undefined)?.code;
const correspondances: Record<string, [number, string, string]> = {
ER_DUP_ENTRY: [409, 'Conflict', 'Cette valeur existe déjà'],
ER_NO_REFERENCED_ROW_2: [400, 'Bad Request', 'Référence vers un élément inexistant'],
ER_ROW_IS_REFERENCED_2: [409, 'Conflict', 'Élément encore utilisé ailleurs : suppression impossible'],
};
const [statusCode, error, message] = correspondances[code ?? ''] ?? [500, 'Internal Server Error', 'Erreur interne'];
if (statusCode === 500) this.logger.error(exception.message, exception.stack);
return res.status(statusCode).json({ statusCode, error, message });
}
}
Liste de contrôle sécurité (à vérifier une par une) :
| Risque (OWASP API Top 10) | Protection dans FormaPro |
|---|---|
| Accès aux objets d'un autre utilisateur (BOLA) | Vérification de la propriété : annulation, attestation, appel, notes |
| Authentification défaillante | bcrypt (coût 12), JWT courts, refresh token révocable, message d'erreur unique, limitation des tentatives |
| Exposition de propriétés sensibles | select: false sur les secrets ; whitelist du ValidationPipe (empêche d'envoyer role: "admin" à l'inscription) |
| Consommation excessive de ressources | ThrottlerGuard global, pagination limitée à 100, fichiers limités à 2 Mo |
| Autorisation au niveau des fonctions | Guards globaux, @Roles() sur chaque route de gestion |
| Mauvaise configuration | Helmet, CORS limité au front, secrets dans .env, synchronize désactivé |
| Injection | Paramètres liés dans le QueryBuilder (:param), liste blanche des colonnes de tri |
À faire
- Limiter la route de connexion à 5 tentatives par minute avec
@Throttle({ default: { limit: 5, ttl: 60_000 } }). - Compléter Swagger :
@ApiPropertysur tous les DTO,@ApiResponsepour les codes d'erreur importants, exemples de corps de requête. - Journaliser chaque requête (méthode, route, code, durée) avec un intercepteur ou un middleware (chapitre 9).
Critères de validation
- ☐ Envoyer
"role": "admin"dansPOST /auth/inscriptionrenvoie 400. - ☐ La 6e tentative de connexion en une minute renvoie 429.
- ☐ Toutes les routes apparaissent dans Swagger, avec le bouton « Authorize » fonctionnel.
Étape 13 — Les tests
Objectif: prouver que les règles de gestion fonctionnent, et le garantir à chaque modification. Durée : 8 à 10 h.
Tests unitaires (Vitest) : une classe testée seule, ses dépendances simulées. Exemple avec le guard des rôles :
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '../enums/role.enum.js';
import { RolesGuard } from './roles.guard.js';
function contexte(role?: Role): ExecutionContext {
return {
getHandler: () => ({}),
getClass: () => ({}),
switchToHttp: () => ({ getRequest: () => ({ user: role ? { id: 1, email: 'a@b.c', role } : undefined }) }),
} as unknown as ExecutionContext;
}
describe('RolesGuard', () => {
const reflector = new Reflector();
const guard = new RolesGuard(reflector);
it('laisse passer une route sans @Roles', () => {
vi.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined);
expect(guard.canActivate(contexte(Role.Stagiaire))).toBe(true);
});
it('autorise un rôle listé', () => {
vi.spyOn(reflector, 'getAllAndOverride').mockReturnValue([Role.Admin, Role.Gestionnaire]);
expect(guard.canActivate(contexte(Role.Gestionnaire))).toBe(true);
});
it('refuse un rôle non listé avec une 403', () => {
vi.spyOn(reflector, 'getAllAndOverride').mockReturnValue([Role.Admin]);
expect(() => guard.canActivate(contexte(Role.Stagiaire))).toThrow(ForbiddenException);
});
});
Tests de bout en bout (Vitest + Supertest) : toute l'application, avec une vraie base de test. Ce test rejoue le scénario des inscriptions simultanées :
import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { App } from 'supertest/types.js';
import { DataSource } from 'typeorm';
import { AppModule } from '../src/app.module.js';
/**
* Test de bout en bout du parcours d'inscription.
* Base dédiée aux tests (DB_NAME=formapro_test), vidée avant chaque exécution.
*/
describe('Inscriptions (e2e)', () => {
let app: INestApplication<App>;
let admin: string;
const stagiaires: string[] = [];
let formateurId: number;
const inscrireCompte = async (prenom: string, email: string) =>
(await request(app.getHttpServer())
.post('/api/v1/auth/inscription')
.send({ prenom, nom: 'Test', email, motDePasse: 'Motdepasse1' })
.expect(201)).body.accessToken as string;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleRef.createNestApplication();
// Même configuration que main.ts
app.setGlobalPrefix('api');
app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' });
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }));
await app.init();
const ds = app.get(DataSource);
await ds.synchronize(true); // tables recréées à vide
await inscrireCompte('Admin', 'admin@test.tn');
await inscrireCompte('Formateur', 'formateur@test.tn');
for (let i = 1; i <= 3; i++) stagiaires.push(await inscrireCompte(`S${i}`, `s${i}@test.tn`));
await ds.query("UPDATE utilisateurs SET role = 'admin' WHERE email = 'admin@test.tn'");
await ds.query("UPDATE utilisateurs SET role = 'formateur' WHERE email = 'formateur@test.tn'");
formateurId = (await ds.query("SELECT id FROM utilisateurs WHERE email = 'formateur@test.tn'"))[0].id;
await ds.query("INSERT INTO categories (nom) VALUES ('Test')");
await ds.query("INSERT INTO salles (nom, capacite, equipeeInformatique) VALUES ('Salle test', 2, 1)");
admin = (await request(app.getHttpServer())
.post('/api/v1/auth/connexion')
.send({ email: 'admin@test.tn', motDePasse: 'Motdepasse1' })).body.accessToken;
});
afterAll(async () => {
await app.close();
});
it('crée une formation puis une session ouverte de 2 places', async () => {
await request(app.getHttpServer())
.post('/api/v1/formations')
.auth(admin, { type: 'bearer' })
.send({ titre: 'Formation de test', description: 'Test', dureeHeures: 7, prix: 100, categorieId: 1, publiee: true })
.expect(201);
const session = await request(app.getHttpServer())
.post('/api/v1/sessions')
.auth(admin, { type: 'bearer' })
.send({ formationId: 1, formateurId, salleId: 1, dateDebut: '2030-01-07', dateFin: '2030-01-11', placesMax: 2 })
.expect(201);
expect(session.body.statut).toBe('brouillon');
await request(app.getHttpServer()).patch('/api/v1/sessions/1/ouvrir').auth(admin, { type: 'bearer' }).expect(200);
});
it('RG-I8 : trois inscriptions simultanées pour deux places → une seule en liste d’attente', async () => {
const reponses = await Promise.all(
stagiaires.map((jeton) =>
request(app.getHttpServer()).post('/api/v1/sessions/1/inscriptions').auth(jeton, { type: 'bearer' }),
),
);
const statuts = reponses.map((r) => r.body.statut).sort();
expect(statuts).toEqual(['en_attente', 'en_attente', 'liste_attente']);
const session = await request(app.getHttpServer()).get('/api/v1/sessions/1').expect(200);
expect(session.body.placesOccupees).toBe(2);
expect(session.body.statut).toBe('complete');
});
it('RG-I2 : refuse une double inscription (409)', async () => {
await request(app.getHttpServer())
.post('/api/v1/sessions/1/inscriptions')
.auth(stagiaires[0], { type: 'bearer' })
.expect(409);
});
it('RG-S2 : refuse une session qui chevauche la première dans la même salle (409)', async () => {
await request(app.getHttpServer())
.post('/api/v1/sessions')
.auth(admin, { type: 'bearer' })
.send({ formationId: 1, formateurId, salleId: 1, dateDebut: '2030-01-10', dateFin: '2030-01-15', placesMax: 2 })
.expect(409);
});
it('refuse l’accès à la gestion pour un stagiaire (403) et sans jeton (401)', async () => {
await request(app.getHttpServer()).get('/api/v1/statistiques/tableau-de-bord').auth(stagiaires[0], { type: 'bearer' }).expect(403);
await request(app.getHttpServer()).get('/api/v1/statistiques/tableau-de-bord').expect(401);
});
});
# base dédiée aux tests, jamais la base de développement
DB_NAME=formapro_test npm run test:e2e
npm run test:cov # rapport de couverture dans coverage/
À faire: un test par règle de gestion du tableau 3.3. Pour les services, simulez les repositories avec vi.fn(); pour InscriptionsService, préférez les tests de bout en bout, plus fiables pour les transactions.
Critères de validation
- ☐
npm testetnpm run test:e2epassent sans erreur. - ☐ Couverture ≥ 70 % sur les fichiers
*.service.ts. - ☐ Le tableau des règles de gestion indique, pour chaque règle, le nom du test qui la couvre.
Étape 14 — L'interface Angular
Objectif: une interface simple mais complète, qui consomme l'API. Durée : 12 à 15 h.
| Page | Route Angular | Accès |
|---|---|---|
| Catalogue (recherche, filtres, pagination) | /formations | tous |
| Détail d'une formation et ses sessions (places restantes) | /formations/:id | tous |
| Connexion / création de compte | /connexion, /inscription | visiteur |
| Mon espace : inscriptions, assiduité, attestations | /mon-espace | stagiaire |
| Mes sessions et feuille d'appel | /formateur/sessions/:id/appel | formateur |
| Gestion : formations, sessions, validation des inscriptions | /gestion/… | gestionnaire, admin |
| Tableau de bord | /gestion/tableau-de-bord | gestionnaire, admin |
Deux pièces maîtresses côté Angular : un intercepteur qui ajoute le jeton à chaque requête et rafraîchit les jetons quand l'API répond 401, et un guard qui protège les pages selon le rôle.
// src/app/core/auth.interceptor.ts — enregistré avec provideHttpClient(withInterceptors([authInterceptor]))
import { HttpErrorResponse, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, switchMap, throwError } from 'rxjs';
import { AuthService } from './auth.service';
const avecJeton = (req: HttpRequest<unknown>, jeton: string | null) =>
jeton ? req.clone({ setHeaders: { Authorization: `Bearer ${jeton}` } }) : req;
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
// Les routes d'authentification n'ont pas besoin de jeton
if (req.url.includes('/auth/connexion') || req.url.includes('/auth/rafraichir')) {
return next(req);
}
return next(avecJeton(req, auth.accessToken())).pipe(
catchError((erreur: HttpErrorResponse) => {
if (erreur.status !== 401 || !auth.refreshToken()) {
return throwError(() => erreur);
}
// Jeton expiré : on demande de nouveaux jetons, puis on rejoue la requête une fois
return auth.rafraichir().pipe(
switchMap((jetons) => next(avecJeton(req, jetons.accessToken))),
catchError((e) => {
auth.deconnecterLocalement(); // refresh token invalide : retour à la connexion
return throwError(() => e);
}),
);
}),
);
};
// src/app/core/role.guard.ts — utilisation : { path: 'gestion', canActivate: [roleGuard('admin', 'gestionnaire')], … }
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService, Role } from './auth.service';
export const roleGuard = (...roles: Role[]): CanActivateFn => () => {
const auth = inject(AuthService);
const router = inject(Router);
const utilisateur = auth.utilisateur(); // signal alimenté par GET /auth/moi
if (!utilisateur) {
return router.createUrlTree(['/connexion']);
}
return roles.length === 0 || roles.includes(utilisateur.role)
? true
: router.createUrlTree(['/acces-refuse']);
};
Critères de validation
- ☐ Un stagiaire peut faire tout son parcours sans Swagger : compte, inscription, suivi, attestation.
- ☐ Après 15 minutes, l'utilisateur reste connecté (rafraîchissement automatique et transparent).
- ☐ Les erreurs de l'API (409, 422…) sont affichées clairement à l'utilisateur.
- ☐ Le menu n'affiche que les pages autorisées pour le rôle connecté. Rappel : ce n'est pas une sécurité, la vraie protection est dans l'API.
Étape 15 — Intégration continue et déploiement
Objectif: chaque push est testé automatiquement, et l'application tourne sur un serveur. Durée : 5 à 6 h.
Le Dockerfile du kit construit l'application en deux étapes : une image de compilation, puis une image d'exécution légère qui ne contient que dist/ et les dépendances de production, lancée avec un utilisateur non administrateur.
# --- Étape 1 : compilation ---
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
# --- Étape 2 : image d'exécution, légère ---
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
RUN mkdir -p uploads/formations && chown -R node:node uploads
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]
Le workflow GitHub Actions du kit lance une base MySQL de test, applique les migrations, puis exécute le lint, les tests unitaires et les tests de bout en bout :
name: CI
on:
push:
branches: [main, develop]
pull_request:
jobs:
tests:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.4
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: formapro_test
MYSQL_USER: formapro
MYSQL_PASSWORD: formapro
ports: ["3306:3306"]
options: >-
--health-cmd="mysqladmin ping -h localhost -proot"
--health-interval=5s --health-retries=10
env:
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_USER: formapro
DB_PASSWORD: formapro
DB_NAME: formapro_test
JWT_SECRET: ci-secret-acces
JWT_REFRESH_SECRET: ci-secret-refresh
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run migration:run
- run: npm test
- run: npm run test:e2e
À faire: déployer sur un VPS (chapitre 20) avec Docker Compose (API + MySQL) ou PM2, derrière Nginx en reverse proxy, en HTTPS avec Let's Encrypt. Appliquer les migrations au déploiement (npm run migration:run), jamais synchronize.
Critères de validation
- ☐ Le badge GitHub Actions est vert dans le
LISEZ-MOI.md. - ☐ Une pull request qui casse un test ne peut pas être fusionnée (règle de protection de branche).
- ☐ L'API répond en HTTPS sur une adresse publique ; Swagger y est accessible.
Bonus (points supplémentaires)
- Temps réel (US-19) : une passerelle Socket.IO diffuse
places-restantesdans le salonsession-{id}à chaque inscription ou annulation. La page de détail d'une session se met à jour sans rechargement. - Vérification d'attestation par QR code (étape 9).
- Paiement en ligne simulé : statut
payeeet reçu PDF. - Réinitialisation du mot de passe par e-mail, avec un jeton à usage unique valable 30 minutes.
- Journal d'audit: qui a validé, refusé ou annulé quoi, et quand.
- Health check (
@nestjs/terminus) :GET /api/healthvérifie l'API et la base.
Organisation et planning
| Semaine | Étapes | Résultat attendu en fin de semaine |
|---|---|---|
| 1 | Analyse, modélisation, étapes 1 et 2 | Dossier de conception validé par le formateur ; base créée par migration ; données de démonstration |
| 2 | Étapes 3, 4 et 5 | Authentification complète, rôles, catalogue paginé avec images |
| 3 | Étapes 6 et 7 | Sessions sans conflit ; inscriptions avec liste d'attente, résistantes aux accès simultanés |
| 4 | Étapes 8 à 11 | Appel, assiduité, attestations PDF, e-mails, tâches planifiées, statistiques |
| 5 | Étapes 12 et 13, début de l'étape 14 | API sécurisée, documentée et testée ; premières pages Angular |
| 6 | Fin de l'étape 14, étape 15, bonus | Application complète en ligne, CI verte, soutenance préparée |
Organisez le travail avec un tableau de suivi (GitHub Projects) : une carte par user story et par règle de gestion, avec les colonnes « À faire », « En cours », « En revue », « Terminé ». Chaque membre du binôme doit avoir développé une partie de l'étape 7 : c'est la plus formatrice.
Livrables
- Dépôt GitHub avec un historique propre (commits réguliers et explicites, branches, pull requests).
- Fichier
LISEZ-MOI.md: présentation, installation en moins de 10 minutes, comptes de démonstration, lien vers l'API en ligne et vers Swagger, badge de la CI. - Dossier de conception (PDF, 10 à 15 pages) : contexte, acteurs, user stories, règles de gestion, modèle de données justifié, contrat d'API, choix techniques, difficultés rencontrées et solutions.
- Rapport de tests: tableau des règles de gestion avec le test qui couvre chacune, capture du rapport de couverture.
- Application déployée: API et front accessibles en ligne pendant la période d'évaluation.
- Soutenance: 15 minutes de démonstration et de présentation, puis 10 minutes de questions.
Déroulé conseillé de la démonstration
- Le gestionnaire crée une formation, une session de 2 places, puis l'ouvre.
- Trois stagiaires s'inscrivent : le troisième passe en liste d'attente.
- Le gestionnaire tente de créer une session dans la même salle aux mêmes dates : refus.
- Un stagiaire annule : le troisième récupère la place et reçoit un e-mail (montrez Mailpit).
- Le formateur fait l'appel ; le stagiaire consulte son assiduité.
- Session terminée, note saisie : le stagiaire télécharge son attestation PDF.
- Le tableau de bord affiche les chiffres à jour.
- Pour finir : la CI sur GitHub et le test des inscriptions simultanées.
Barème d'évaluation (sur 100 points)
| Critère | Ce qui est évalué | Points |
|---|---|---|
| Conception | Modèle de données correct et justifié, contrat d'API cohérent, dossier de conception | 12 |
| Architecture et qualité du code | Modules par domaine, DTO, services sans logique dans les contrôleurs, lisibilité, lint | 10 |
| Authentification et autorisation | JWT + refresh révocable, rôles, propriété des ressources, sécurité par défaut | 12 |
| Règles métier | Conflits de planning, inscriptions, liste d'attente, transactions et verrou, présences, attestations | 20 |
| Base de données | Migrations, contraintes, requêtes paginées et agrégées, absence de synchronize en production | 8 |
| Fonctionnalités annexes | Upload sécurisé, PDF, e-mails, tâches planifiées, statistiques | 8 |
| Tests | Unitaires et e2e, une règle = un test, couverture | 10 |
| Documentation et sécurité | Swagger complet, LISEZ-MOI, Helmet, CORS, limitation des requêtes, filtre d'erreurs | 6 |
| Front Angular | Parcours complets par rôle, intercepteur et refresh, gestion des erreurs | 8 |
| Déploiement et CI | Docker, GitHub Actions, API en ligne en HTTPS | 6 |
| Bonus | Temps réel, QR code, réinitialisation du mot de passe, audit… | + 10 max |
Scénarios de recette
Avant la soutenance, déroulez ces scénarios. Chacun doit donner exactement le résultat attendu.
| # | Scénario | Résultat attendu |
|---|---|---|
| 1 | Créer un compte avec un e-mail déjà utilisé, en majuscules | 409 |
| 2 | S'inscrire en envoyant "role": "admin" | 400, aucun compte créé |
| 3 | Appeler GET /auth/moi avec un jeton modifié d'un caractère | 401 |
| 4 | Utiliser un refresh token après déconnexion | 401 |
| 5 | Un stagiaire crée une formation | 403 |
| 6 | Catalogue trié par une colonne interdite | 400 |
| 7 | Session du 10 au 15 dans une salle occupée du 12 au 20 | 409, message qui cite la session en conflit |
| 8 | Session du 1er au 5 puis du 6 au 10 dans la même salle | 201 pour les deux |
| 9 | Session de 30 places dans une salle de 16 | 400 |
| 10 | Inscription à une session en brouillon | 400 |
| 11 | 10 inscriptions simultanées sur une session de 3 places | 3 en_attente, 7 liste_attente (rangs 1 à 7), compteur = 3 |
| 12 | Annulation d'un inscrit, avec une liste d'attente | Le rang 1 passe en_attente et reçoit un e-mail ; compteur inchangé |
| 13 | Annulation par un stagiaire à 2 jours du début | 400 |
| 14 | Un stagiaire annule l'inscription d'un autre | 403 |
| 15 | Refus d'inscription sans motif | 400 |
| 16 | Appel fait par un formateur qui n'anime pas la session | 403 |
| 17 | Refaire l'appel d'une séance | Présences corrigées, aucun doublon |
| 18 | Attestation avec 75 % d'assiduité et 15/20 | 422 avec les deux valeurs |
| 19 | Attestation téléchargée deux fois | Même numéro les deux fois |
| 20 | Téléverser un PDF renommé en .jpg comme image de formation | 422 |
| 21 | Supprimer une formation qui a des sessions | 409 |
| 22 | Arrêter Mailpit puis s'inscrire | Inscription réussie, erreur d'envoi journalisée |
| 23 | 6 connexions ratées en une minute | 429 à la 6e |
| 24 | Laisser le front ouvert 20 minutes puis naviguer | Aucune déconnexion (refresh automatique) |
Les pièges les plus fréquents
| Symptôme | Cause et solution |
|---|---|
Cannot find module './x.service' au démarrage | Projet ESM : l'import relatif doit se terminer par .js ('./x.service.js'), même dans un fichier .ts. |
Cannot access 'Session' before initialization | Relations circulaires entre entités en ESM : entourez le type de la relation avec Relation<…> (import type Relation depuis typeorm). |
Nest can't resolve dependencies of XService (?) | Le repository n'est pas déclaré : ajoutez l'entité dans TypeOrmModule.forFeature([...]) du module, ou importez le module qui exporte le service. |
| Un champ envoyé est ignoré sans erreur | Il manque un décorateur de validation dans le DTO : avec whitelist, une propriété sans décorateur est retirée (et refusée avec forbidNonWhitelisted). |
page=2 arrive comme la chaîne "2" | Les paramètres d'URL sont des chaînes : @Type(() => Number) dans le DTO et transform: true dans le ValidationPipe. |
Le prix ou un total arrive en chaîne "450.00" | Comportement normal de MySQL pour decimal et les agrégats : convertissez avec Number() avant de calculer. |
| La migration générée supprime et recrée une colonne | Vous avez renommé une propriété : modifiez la migration à la main pour faire un RENAME COLUMN et ne pas perdre les données. |
| Les dates se décalent d'un jour | Fuseau horaire : stockez les dates de session en type date (chaîne AAAA-MM-JJ), sans heure. |
Une route GET /sessions/mes-sessions renvoie « Validation failed (numeric string is expected) » | La route :id est déclarée avant : déclarez les routes fixes avant les routes paramétrées dans le contrôleur. |
Express.Multer.File introuvable | Ajoutez "multer" dans "types" du tsconfig.json (après npm i -D @types/multer). |
jest is not defined dans un test | NestJS 12 utilise Vitest : vi.fn(), vi.spyOn(). |
Liste de contrôle finale
- ☐ Les 18 user stories de priorité M et S sont réalisées.
- ☐ Chacune des règles de gestion est programmée côté serveur et couverte par un test.
- ☐ Les 24 scénarios de recette donnent le résultat attendu.
- ☐
git clone+LISEZ-MOI.mdsuffisent pour lancer le projet sur une machine neuve. - ☐ Aucun secret dans le dépôt ;
.env.exampleà jour. - ☐ Swagger documente toutes les routes ; la CI est verte ; l'application est en ligne.
- ☐ Chaque membre du binôme sait expliquer la transaction des inscriptions et le calcul des conflits de planning.
À retenir
- Une application réelle se distingue d'un CRUD par ses règles de gestion: elles se programment côté serveur et se prouvent par des tests.
- Dès qu'une opération modifie plusieurs tables ou dépend d'un compteur, il faut une transaction, et souvent un verrou.
- La sécurité se pense par défaut: tout est protégé, on ouvre explicitement ; on vérifie le rôle et la propriété des ressources.
- Les modules communiquent par événements pour rester indépendants ; les effets secondaires (e-mails) ne doivent jamais faire échouer l'action principale.
- Migrations, tests, documentation, CI et déploiement font partie du produit, pas du « bonus ».