Avez-vous une question? (216) 97 656 803 hajjriadh@gmail.com
NestJS

Projet de synthèse : API complète d'une plateforme de gestion de formations

Projet de synthèse du cours NestJS : concevoir, développer, sécuriser, tester et déployer FormaPro, une API complète de gestion de formations (sessions, inscriptions avec liste d'attente, présences, a...

0 chapitres 0 QCM Accès gratuit
Dans cet article

À propos de ce cours

Projet de synthèse du cours NestJS : concevoir, développer, sécuriser, tester et déployer FormaPro, une API complète de gestion de formations (sessions, inscriptions avec liste d'attente, présences, attestations PDF), avec un front Angular.

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.

Objectifs du projet
  • 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

ApplicationFormaPro: API REST de gestion d'un centre de formation + interface Angular
Durée indicative70 à 90 heures (6 semaines à raison de 12 à 15 heures par semaine)
OrganisationEn binôme (ou seul). Chaque membre doit pouvoir expliquer tout le code.
PrérequisChapitres 1 à 20 du cours NestJS ; chapitres 7 à 15 du cours Angular pour la partie front
TechnologiesNestJS 12, TypeScript, TypeORM, MySQL 8, JWT, class-validator, Swagger, Vitest, Supertest, Docker, GitHub Actions, Angular
LivrablesDépôt GitHub, API déployée, documentation Swagger, dossier de conception, rapport de tests, démonstration et soutenance
Kit de départformapro-kit-depart.zip : Docker (MySQL, Adminer, Mailpit), variables d'environnement, migrations, données de démonstration, requêtes de test, Dockerfile, CI
Version de NestJS. Ce projet utilise NestJS 12. Un projet créé avec 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ôleCodeDescription
Visiteur—Personne non connectée. Consulte le catalogue des formations et les sessions ouvertes, crée un compte.
StagiairestagiaireS'inscrit aux sessions, suit ses inscriptions, consulte son assiduité, télécharge ses attestations.
FormateurformateurConsulte ses sessions, fait l'appel à chaque séance, saisit les notes de fin de formation.
GestionnairegestionnaireGère le catalogue, les salles, les sessions et les séances ; valide ou refuse les inscriptions ; consulte les statistiques.
AdministrateuradminTous 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.

IDEn tant que…Je veux…Priorité
US-01visiteurcréer un compte stagiaire avec mon nom, mon e-mail et un mot de passeM
US-02utilisateurme connecter, rester connecté (refresh token) et me déconnecterM
US-03visiteurparcourir le catalogue avec pagination, recherche par mot-clé, filtre par catégorie et triM
US-04visiteurvoir les sessions ouvertes d'une formation, avec les places restantesM
US-05gestionnairecréer, modifier et publier des formations, avec une imageM
US-06gestionnairegérer les salles (nom, capacité, équipement informatique)M
US-07gestionnaireprogrammer une session (formation, formateur, salle, dates, places) sans conflit de planningM
US-08stagiairem'inscrire à une session ouverte, ou être placé en liste d'attente si elle est complèteM
US-09gestionnairevalider ou refuser une inscription (avec un motif en cas de refus)M
US-10stagiaireannuler mon inscription ; la première personne en liste d'attente récupère la placeM
US-11gestionnairegénérer les séances d'une session (dates et horaires)S
US-12formateurfaire l'appel à chaque séance (présent, retard, absent, excusé) et le corrigerM
US-13formateursaisir la note finale (sur 20) et une appréciation pour chaque stagiaireM
US-14stagiaireconsulter mon taux d'assiduité et télécharger mon attestation en PDF si j'y ai droitM
US-15stagiairerecevoir un e-mail à chaque étape de mon inscription et un rappel deux jours avant le débutS
US-16gestionnaireconsulter un tableau de bord : remplissage, chiffre d'affaires, assiduité, formations populairesS
US-17administrateurcréer des comptes formateurs et gestionnaires, désactiver un compteM
US-18gestionnaireexporter la liste des inscrits d'une session en CSVC
US-19stagiairevoir en temps réel le nombre de places restantes d'une sessionC
US-20gestionnaireannuler une session ; tous les inscrits sont prévenus par e-mailS

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.

CodeRègleRéponse HTTP si violée
Comptes
RG-U1L'e-mail est unique (comparaison sans tenir compte des majuscules).409 Conflict
RG-U2Mot 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-U3L'inscription publique crée toujours un compte stagiaire. Seul l'administrateur crée les autres rôles.403 Forbidden
RG-U4Un compte désactivé ne peut plus se connecter.403 Forbidden
Catalogue et sessions
RG-F1Le titre d'une formation est unique ; son slug (adresse lisible) est calculé automatiquement.409 Conflict
RG-F2Une formation qui a des sessions ne peut pas être supprimée : on la dépublie.409 Conflict
RG-S1Le nombre de places d'une session ne dépasse pas la capacité de la salle.400 Bad Request
RG-S2Une 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-S3Une session commence dans le futur et sa date de fin suit sa date de début.400 Bad Request
RG-S4Le formateur d'une session a obligatoirement le rôle formateur. 400 Bad Request
Inscriptions
RG-I1On ne s'inscrit qu'à une session ouverte ou complete (pour la liste d'attente).400 Bad Request
RG-I2Un stagiaire ne s'inscrit qu'une fois à la même session (sauf après annulation).409 Conflict
RG-I3Tant 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-I4Quand la session est complète, l'inscription passe en liste_attente avec un rang (1, 2, 3…).—
RG-I5Un stagiaire peut annuler jusqu'à 3 jours avant le début. Le gestionnaire peut annuler à tout moment.400 / 403
RG-I6Quand 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-I7Un refus d'inscription exige un motif.400 Bad Request
RG-I8Deux inscriptions simultanées ne peuvent jamais dépasser le nombre de places (accès concurrents).—
Présences, évaluations, attestations
RG-P1Seul le formateur de la session fait l'appel de ses séances.403 Forbidden
RG-P2L'appel ne concerne que les stagiaires dont l'inscription est validee. 403 Forbidden
RG-P3Refaire l'appel d'une séance corrige les présences existantes (pas de doublon).—
RG-P4Taux d'assiduité = (présent + retard + excusé) / nombre de séances déjà passées, en pourcentage.—
RG-E1La 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-A1Attestation délivrée si : inscription validée, session terminée, assiduité ≥ 80 % et note ≥ 10/20.422 Unprocessable Entity
RG-A2Chaque 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
DeVersDéclencheur
brouillonouvertele gestionnaire ouvre les inscriptions
ouvertecompletedernière place prise
completeouverteune place se libère, sans liste d'attente
ouverte / completeen_coursdate de début atteinte (tâche planifiée)
en_courstermineedate de fin dépassée (tâche planifiée)
brouillon / ouverte / completeannuleele gestionnaire annule (US-20)
Inscription
DeVersDéclencheur
—en_attenteinscription, place disponible
—liste_attenteinscription, session complète
liste_attenteen_attenteune place se libère et c'est son tour
en_attentevalidee / refuseedécision du gestionnaire
en_attente / liste_attente / valideeannuleeannulation (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, version v1, JSON, codes de statut HTTP corrects, messages d'erreur en français, listes paginées.
  • Documentation: Swagger complet et à jour, fichier LISEZ-MOI.md permettant à 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 synchronize en production).

Modélisation des données

4.1 Les entités

EntitéTableAttributs principaux
Utilisateurutilisateursid, prenom, nom, email (unique), motDePasse (haché), role, refreshTokenHash, actif, creeLe
Categoriecategoriesid, nom (unique)
Formationformationsid, titre, slug (unique), description, prerequis, dureeHeures, prix, image, publiee, creeLe, modifieLe
Sallesallesid, nom (unique), capacite, equipeeInformatique
Sessionsessionsid, dateDebut, dateFin, placesMax, placesOccupees, statut, version
Inscriptioninscriptionsid, statut, rangListeAttente, motifRefus, creeLe
Seanceseancesid, date, heureDebut, heureFin
Presencepresencesid, statut (present, retard, absent, excuse)
Evaluationevaluationsid, note, appreciation, numeroAttestation (unique)

4.2 Les associations

AssociationCardinalitésTraduction TypeORM
Categorie — Formationune catégorie regroupe 0..n formations ; une formation appartient à 1 catégorie@OneToMany / @ManyToOne
Formation — Sessionune formation a 0..n sessions ; une session concerne 1 formation@OneToMany / @ManyToOne
Utilisateur (formateur) — Sessionun formateur anime 0..n sessions ; une session a 1 formateur@ManyToOne
Salle — Sessionune salle accueille 0..n sessions ; une session a lieu dans 1 salle@ManyToOne
Utilisateur (stagiaire) — Sessionplusieurs à plusieurs, porteuse de données (statut, rang, date…)entité d'association Inscription avec deux @ManyToOne et une contrainte d'unicité
Session — Seanceune session a 1..n séances@OneToMany / @ManyToOne
Seance — Utilisateur (stagiaire)plusieurs à plusieurs, porteuse du statut de présenceentité d'association Presence, unique (seance, stagiaire)
Inscription — Evaluationune inscription a 0..1 évaluation@OneToOne avec @JoinColumn côté Evaluation
Pourquoi pas @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

BesoinOutilChapitre du cours
FrameworkNestJS 12 (Express)1 à 7
Validation des entréesclass-validator, class-transformer, ValidationPipe8
Guards, filtres, intercepteursNestJS9
Configuration@nestjs/config, fichier .env10
Base de donnéesMySQL 8.4, TypeORM 1.x, migrations11
Authentification@nestjs/jwt, bcrypt13
Documentation@nestjs/swagger14
Fichiers, e-mails, tâches, événementsMulter, Nodemailer + Mailpit, @nestjs/schedule, @nestjs/event-emitter, PDFKit15
Temps réel (bonus)@nestjs/websockets, Socket.IO16
TestsVitest, Supertest17
Front-endAngular18
SécuritéHelmet, @nestjs/throttler, CORS19
DéploiementDocker, GitHub Actions, PM2 / Nginx20

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éthodeRouteAccèsRôle de la routeUS
Authentification et comptes
POST/auth/inscriptionpublicCréer un compte stagiaire, renvoie les jetons01
POST/auth/connexionpublicSe connecter : { accessToken, refreshToken }02
POST/auth/rafraichirpublicObtenir de nouveaux jetons avec le refresh token02
POST/auth/deconnexionconnectéRévoquer le refresh token02
GET/auth/moiconnectéProfil de l'utilisateur connecté02
GET/utilisateursadminLister les comptes (pagination, filtre par rôle)17
POST/utilisateursadminCréer un formateur ou un gestionnaire17
PATCH/utilisateurs/:id/activationadminActiver ou désactiver un compte17
Catalogue
GET/categoriespublicLister les catégories03
POST / PATCH / DELETE/categories[/:id]admin, gestionnaireGérer les catégories05
GET/formations?page&limite&recherche&categorieId&tri&ordrepublicCatalogue paginé (formations publiées)03
GET/formations/:idpublicDétail d'une formation03
POST/formationsadmin, gestionnaireCréer une formation05
PATCH/formations/:idadmin, gestionnaireModifier, publier, dépublier05
POST/formations/:id/imageadmin, gestionnaireTéléverser l'image (JPEG, PNG, WebP, 2 Mo max)05
DELETE/formations/:idadminSupprimer (si aucune session)05
Salles et sessions
GET / POST / PATCH/salles[/:id]admin, gestionnaireGérer les salles06
GET/sessions?formationIdpublicSessions ouvertes ou complètes, avec places restantes04
GET/sessions/:idpublicDétail d'une session04
GET/sessions/mes-sessionsformateurSessions du formateur connecté12
POST/sessionsadmin, gestionnaireProgrammer une session (RG-S1 à S4)07
PATCH/sessions/:id/ouvriradmin, gestionnaireOuvrir les inscriptions07
PATCH/sessions/:id/annuleradmin, gestionnaireAnnuler et prévenir les inscrits20
POST/sessions/:id/seancesadmin, gestionnaireGénérer les séances (jours ouvrés, horaires)11
GET/sessions/:id/inscritsgestion, formateur de la sessionListe des inscrits ; ?format=csv pour l'export18
Inscriptions
POST/sessions/:id/inscriptionsstagiaireS'inscrire (RG-I1 à I4, I8)08
GET/inscriptions/moistagiaireMes inscriptions08
PATCH/inscriptions/:id/decisionadmin, gestionnaire{ accepter, motif? } (RG-I7)09
PATCH/inscriptions/:id/annulationstagiaire concerné, gestionAnnuler (RG-I5, I6)10
Présences, évaluations, attestations
POST/seances/:id/appelformateur de la session{ lignes: [{ stagiaireId, statut }] } (RG-P1 à P3)12
GET/sessions/:id/mon-assiduitestagiaireMon taux d'assiduité (RG-P4)14
PUT/inscriptions/:id/evaluationformateur de la session{ note, appreciation? } (RG-E1)13
GET/inscriptions/:id/attestationstagiaire concernéTélécharger le PDF (RG-A1, A2)14
Statistiques
GET/statistiques/tableau-de-bord?anneeadmin, gestionnaireIndicateurs clés16

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.

Méthode de travail conseillée: une branche Git par étape (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.

  1. 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
  1. 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"
}
  1. Le fichier docker-compose.yml du 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:
  1. Configurez TypeORM de façon asynchrone, à partir de ConfigService, dans app.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 {}
  1. 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'],
});
  1. Configurez main.ts: sécurité, CORS, préfixe /api, version v1, 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();
Piège : 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 -d lance les trois services ; Adminer se connecte à la base formapro.
  • ☐ npm run start:dev démarre sans erreur ; http://localhost:3000/api/docs affiche Swagger.
  • ☐ Le fichier .env est dans .gitignore; .env.example est versionné.
  • ☐ Premier commit poussé sur GitHub, avec un LISEZ-MOI.md qui 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: false sur 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 ;
  • @Unique sur 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 migrations contient une ligne Init….
  • ☐ npm run seed crée 16 comptes, 4 catégories, 3 salles et 4 formations.
  • ☐ Les montants utilisent decimal (jamais float pour 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 };
  }
}
Sécurité : un seul message d'erreur. En cas d'échec de connexion, on répond « E-mail ou mot de passe incorrect », sans préciser lequel des deux est faux. Sinon, un attaquant pourrait découvrir quels e-mails ont un compte.

Critères de validation

  • ☐ POST /auth/inscription cré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/moi ne renvoie jamais motDePasse ni refreshTokenHash.
  • ☐ 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);
  }
}
Type 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=5 fonctionne ; tri=motDePasse renvoie 400.
  • ☐ Le visiteur ne voit que les formations publiées.
  • ☐ Un PDF renommé en .png est 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/:id pour modifier une session en brouillon (en revérifiant les conflits, avec exclureId).
  • 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 :

TempsRequête ARequête B
t1lit placesOccupees = 1 → il reste une place
t2lit placesOccupees = 1 → il reste une place
t3enregistre l'inscription, écrit placesOccupees = 2
t4enregistre 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' },
    });
  }
}
Vérifié: avec ce code, quatre requêtes envoyées en parallèle sur une session de deux places donnent toujours exactement deux inscriptions 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/inscrits pour le gestionnaire et le formateur de la session, avec l'export CSV (US-18) : en-tête Content-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 placesOccupees est toujours égal au nombre d'inscriptions en_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_cours le jour de leur début et à terminee le 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);
  }
}
Pourquoi 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éfaillantebcrypt (coût 12), JWT courts, refresh token révocable, message d'erreur unique, limitation des tentatives
Exposition de propriétés sensiblesselect: false sur les secrets ; whitelist du ValidationPipe (empêche d'envoyer role: "admin" à l'inscription)
Consommation excessive de ressourcesThrottlerGuard global, pagination limitée à 100, fichiers limités à 2 Mo
Autorisation au niveau des fonctionsGuards globaux, @Roles() sur chaque route de gestion
Mauvaise configurationHelmet, CORS limité au front, secrets dans .env, synchronize désactivé
InjectionParamè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 : @ApiProperty sur tous les DTO, @ApiResponse pour 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" dans POST /auth/inscription renvoie 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 test et npm run test:e2e passent 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.

PageRoute AngularAccès
Catalogue (recherche, filtres, pagination)/formationstous
Détail d'une formation et ses sessions (places restantes)/formations/:idtous
Connexion / création de compte/connexion, /inscriptionvisiteur
Mon espace : inscriptions, assiduité, attestations/mon-espacestagiaire
Mes sessions et feuille d'appel/formateur/sessions/:id/appelformateur
Gestion : formations, sessions, validation des inscriptions/gestion/…gestionnaire, admin
Tableau de bord/gestion/tableau-de-bordgestionnaire, 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-restantes dans le salon session-{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 payee et 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/health vérifie l'API et la base.

Organisation et planning

SemaineÉtapesRésultat attendu en fin de semaine
1Analyse, modélisation, étapes 1 et 2Dossier de conception validé par le formateur ; base créée par migration ; données de démonstration
2Étapes 3, 4 et 5Authentification complète, rôles, catalogue paginé avec images
3Étapes 6 et 7Sessions sans conflit ; inscriptions avec liste d'attente, résistantes aux accès simultanés
4Étapes 8 à 11Appel, assiduité, attestations PDF, e-mails, tâches planifiées, statistiques
5Étapes 12 et 13, début de l'étape 14API sécurisée, documentée et testée ; premières pages Angular
6Fin de l'étape 14, étape 15, bonusApplication 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

  1. Dépôt GitHub avec un historique propre (commits réguliers et explicites, branches, pull requests).
  2. 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.
  3. 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.
  4. Rapport de tests: tableau des règles de gestion avec le test qui couvre chacune, capture du rapport de couverture.
  5. Application déployée: API et front accessibles en ligne pendant la période d'évaluation.
  6. Soutenance: 15 minutes de démonstration et de présentation, puis 10 minutes de questions.

Déroulé conseillé de la démonstration

  1. Le gestionnaire crée une formation, une session de 2 places, puis l'ouvre.
  2. Trois stagiaires s'inscrivent : le troisième passe en liste d'attente.
  3. Le gestionnaire tente de créer une session dans la même salle aux mêmes dates : refus.
  4. Un stagiaire annule : le troisième récupère la place et reçoit un e-mail (montrez Mailpit).
  5. Le formateur fait l'appel ; le stagiaire consulte son assiduité.
  6. Session terminée, note saisie : le stagiaire télécharge son attestation PDF.
  7. Le tableau de bord affiche les chiffres à jour.
  8. Pour finir : la CI sur GitHub et le test des inscriptions simultanées.

Barème d'évaluation (sur 100 points)

CritèreCe qui est évaluéPoints
ConceptionModèle de données correct et justifié, contrat d'API cohérent, dossier de conception12
Architecture et qualité du codeModules par domaine, DTO, services sans logique dans les contrôleurs, lisibilité, lint10
Authentification et autorisationJWT + refresh révocable, rôles, propriété des ressources, sécurité par défaut12
Règles métierConflits de planning, inscriptions, liste d'attente, transactions et verrou, présences, attestations20
Base de donnéesMigrations, contraintes, requêtes paginées et agrégées, absence de synchronize en production8
Fonctionnalités annexesUpload sécurisé, PDF, e-mails, tâches planifiées, statistiques8
TestsUnitaires et e2e, une règle = un test, couverture10
Documentation et sécuritéSwagger complet, LISEZ-MOI, Helmet, CORS, limitation des requêtes, filtre d'erreurs6
Front AngularParcours complets par rôle, intercepteur et refresh, gestion des erreurs8
Déploiement et CIDocker, GitHub Actions, API en ligne en HTTPS6
BonusTemps réel, QR code, réinitialisation du mot de passe, audit…+ 10 max
Note éliminatoire. Un secret (mot de passe de base, clé JWT) présent dans l'historique Git, ou des mots de passe stockés en clair, entraînent une note de 0 au critère « Authentification et autorisation ». Si cela vous arrive, changez immédiatement le secret : le supprimer du dernier commit ne suffit pas, il reste dans l'historique.

Scénarios de recette

Avant la soutenance, déroulez ces scénarios. Chacun doit donner exactement le résultat attendu.

#ScénarioRésultat attendu
1Créer un compte avec un e-mail déjà utilisé, en majuscules409
2S'inscrire en envoyant "role": "admin"400, aucun compte créé
3Appeler GET /auth/moi avec un jeton modifié d'un caractère401
4Utiliser un refresh token après déconnexion401
5Un stagiaire crée une formation403
6Catalogue trié par une colonne interdite400
7Session du 10 au 15 dans une salle occupée du 12 au 20409, message qui cite la session en conflit
8Session du 1er au 5 puis du 6 au 10 dans la même salle201 pour les deux
9Session de 30 places dans une salle de 16400
10Inscription à une session en brouillon400
1110 inscriptions simultanées sur une session de 3 places3 en_attente, 7 liste_attente (rangs 1 à 7), compteur = 3
12Annulation d'un inscrit, avec une liste d'attenteLe rang 1 passe en_attente et reçoit un e-mail ; compteur inchangé
13Annulation par un stagiaire à 2 jours du début400
14Un stagiaire annule l'inscription d'un autre403
15Refus d'inscription sans motif400
16Appel fait par un formateur qui n'anime pas la session403
17Refaire l'appel d'une séancePrésences corrigées, aucun doublon
18Attestation avec 75 % d'assiduité et 15/20422 avec les deux valeurs
19Attestation téléchargée deux foisMême numéro les deux fois
20Téléverser un PDF renommé en .jpg comme image de formation422
21Supprimer une formation qui a des sessions409
22Arrêter Mailpit puis s'inscrireInscription réussie, erreur d'envoi journalisée
236 connexions ratées en une minute429 à la 6e
24Laisser le front ouvert 20 minutes puis naviguerAucune déconnexion (refresh automatique)

Les pièges les plus fréquents

SymptômeCause et solution
Cannot find module './x.service' au démarrageProjet ESM : l'import relatif doit se terminer par .js ('./x.service.js'), même dans un fichier .ts.
Cannot access 'Session' before initializationRelations 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 erreurIl 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 colonneVous 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 jourFuseau 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 introuvableAjoutez "multer" dans "types" du tsconfig.json (après npm i -D @types/multer).
jest is not defined dans un testNestJS 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.md suffisent 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 ».