diff --git a/eme-backend/src/controllers/emprunt.controller.ts b/eme-backend/src/controllers/emprunt.controller.ts index 8845029..c3f2d7f 100644 --- a/eme-backend/src/controllers/emprunt.controller.ts +++ b/eme-backend/src/controllers/emprunt.controller.ts @@ -1,7 +1,7 @@ import { Request, Response } from 'express'; import { AppError } from '../errors/app-error'; -import { listerMesEmpruntsEnCours } from '../services/emprunt.service'; -import { toEmpruntResponse } from '../dtos/emprunt.dto'; +import { creerEmprunt, listerMesEmpruntsEnCours } from '../services/emprunt.service'; +import { parseCreerEmpruntRequest, toEmpruntResponse } from '../dtos/emprunt.dto'; export async function getMesEmprunts(req: Request, res: Response): Promise { const user = req.user; @@ -12,3 +12,14 @@ export async function getMesEmprunts(req: Request, res: Response): Promise const emprunts = await listerMesEmpruntsEnCours(user.id); res.json({ data: emprunts.map(toEmpruntResponse) }); } + +export async function postEmprunt(req: Request, res: Response): Promise { + const user = req.user; + if (!user) { + throw new AppError(401, 'Authentification requise'); + } + + const request = parseCreerEmpruntRequest(req.body); + const emprunt = await creerEmprunt(user.id, user.campusId, request); + res.status(201).json({ data: toEmpruntResponse(emprunt) }); +} diff --git a/eme-backend/src/dtos/emprunt.dto.ts b/eme-backend/src/dtos/emprunt.dto.ts index f690d32..c01822f 100644 --- a/eme-backend/src/dtos/emprunt.dto.ts +++ b/eme-backend/src/dtos/emprunt.dto.ts @@ -1,5 +1,7 @@ import { EmpruntAvecMateriel } from '../repositories/emprunt.repository'; import { MaterielResponse, toMaterielResponse } from './materiel.dto'; +import { AppError } from '../errors/app-error'; +import { ETAT_CHECKLIST_ELEMENT, MODE_IDENTIFICATION } from '../models/enums'; export interface EmpruntResponse { id: number; @@ -20,3 +22,97 @@ export function toEmpruntResponse(emprunt: EmpruntAvecMateriel): EmpruntResponse materiel: toMaterielResponse(emprunt.materiel), }; } + +export interface ChecklistElementRequest { + accessoireId?: number; + nomElement: string; + etat: string; + quantiteConstatee: number; + commentaire?: string; +} + +export interface CreerEmpruntRequest { + materielId: number; + posteEmpruntId: number; + modeIdentification: string; + commentaireDepart?: string; + elements: ChecklistElementRequest[]; +} + +function asObject(value: unknown, message: string): Record { + if (typeof value !== 'object' || value === null) { + throw new AppError(400, message); + } + return value as Record; +} + +function asPositiveInt(value: unknown, champ: string): number { + if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) { + throw new AppError(400, `${champ} doit etre un entier positif`); + } + return value; +} + +function asIntOrZero(value: unknown, champ: string): number { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + throw new AppError(400, `${champ} doit etre un entier positif ou nul`); + } + return value; +} + +function asEnum(value: unknown, autorisees: readonly T[], champ: string): T { + if (typeof value !== 'string' || !(autorisees as readonly string[]).includes(value)) { + throw new AppError(400, `${champ} invalide`); + } + return value as T; +} + +function parseElement(value: unknown, index: number): ChecklistElementRequest { + const obj = asObject(value, `elements[${index}] invalide`); + + if (typeof obj.nomElement !== 'string' || obj.nomElement.trim() === '') { + throw new AppError(400, `elements[${index}].nomElement requis`); + } + + const element: ChecklistElementRequest = { + nomElement: obj.nomElement, + etat: asEnum(obj.etat, ETAT_CHECKLIST_ELEMENT, `elements[${index}].etat`), + quantiteConstatee: asIntOrZero(obj.quantiteConstatee, `elements[${index}].quantiteConstatee`), + }; + + if (obj.accessoireId !== undefined) { + element.accessoireId = asPositiveInt(obj.accessoireId, `elements[${index}].accessoireId`); + } + if (obj.commentaire !== undefined) { + if (typeof obj.commentaire !== 'string') { + throw new AppError(400, `elements[${index}].commentaire invalide`); + } + element.commentaire = obj.commentaire; + } + + return element; +} + +export function parseCreerEmpruntRequest(body: unknown): CreerEmpruntRequest { + const obj = asObject(body, 'Corps de requete invalide'); + + if (!Array.isArray(obj.elements) || obj.elements.length === 0) { + throw new AppError(400, 'La checklist de depart est obligatoire (RG11)'); + } + + const request: CreerEmpruntRequest = { + materielId: asPositiveInt(obj.materielId, 'materielId'), + posteEmpruntId: asPositiveInt(obj.posteEmpruntId, 'posteEmpruntId'), + modeIdentification: asEnum(obj.modeIdentification, MODE_IDENTIFICATION, 'modeIdentification'), + elements: obj.elements.map((element, index) => parseElement(element, index)), + }; + + if (obj.commentaireDepart !== undefined) { + if (typeof obj.commentaireDepart !== 'string') { + throw new AppError(400, 'commentaireDepart invalide'); + } + request.commentaireDepart = obj.commentaireDepart; + } + + return request; +} diff --git a/eme-backend/src/repositories/emprunt.repository.ts b/eme-backend/src/repositories/emprunt.repository.ts index 67f158f..241a606 100644 --- a/eme-backend/src/repositories/emprunt.repository.ts +++ b/eme-backend/src/repositories/emprunt.repository.ts @@ -1,6 +1,7 @@ import { Prisma } from '@prisma/client'; import { prisma } from '../db/prisma'; import { StatutEmprunt } from '../models/enums'; +import { AppError } from '../errors/app-error'; export type EmpruntAvecMateriel = Prisma.EmpruntGetPayload<{ include: { materiel: { include: { categorie: true } } }; @@ -18,3 +19,74 @@ export function findEnCoursParUtilisateur(utilisateurId: number): Promise EMPRUNTE), emprunt, + checklist de départ et ses éléments. Si une étape échoue, tout est annulé. */ +export function creerEmpruntComplet(data: CreerEmpruntData): Promise { + return prisma.$transaction(async (tx) => { + // Réservation atomique : empêche un double emprunt du même matériel (RG07). + const reservation = await tx.materiel.updateMany({ + where: { id: data.materielId, statut: 'DISPONIBLE' }, + data: { statut: 'EMPRUNTE' }, + }); + if (reservation.count === 0) { + throw new AppError(409, 'Materiel non disponible'); + } + + const emprunt = await tx.emprunt.create({ + data: { + utilisateurId: data.utilisateurId, + materielId: data.materielId, + campusId: data.campusId, + sallePretId: data.sallePretId, + posteEmpruntId: data.posteEmpruntId, + dateEmprunt: new Date(), + dateRetourPrevue: data.dateRetourPrevue, + statut: 'EN_COURS', + modeIdentificationEmprunt: data.modeIdentification, + commentaireDepart: data.commentaireDepart ?? null, + }, + }); + + await tx.checklist.create({ + data: { + empruntId: emprunt.id, + utilisateurId: data.utilisateurId, + type: 'DEPART', + dateVerification: new Date(), + elements: { + create: data.elements.map((element) => ({ + accessoireId: element.accessoireId ?? null, + nomElement: element.nomElement, + etat: element.etat, + quantiteConstatee: element.quantiteConstatee, + commentaire: element.commentaire ?? null, + })), + }, + }, + }); + + return tx.emprunt.findUniqueOrThrow({ + where: { id: emprunt.id }, + include: { materiel: { include: { categorie: true } } }, + }); + }); +} diff --git a/eme-backend/src/repositories/materiel.repository.ts b/eme-backend/src/repositories/materiel.repository.ts index 3f33807..dbd5328 100644 --- a/eme-backend/src/repositories/materiel.repository.ts +++ b/eme-backend/src/repositories/materiel.repository.ts @@ -1,4 +1,4 @@ -import { Prisma } from '@prisma/client'; +import { Materiel, Prisma } from '@prisma/client'; import { prisma } from '../db/prisma'; export type MaterielAvecCategorie = Prisma.MaterielGetPayload<{ include: { categorie: true } }>; @@ -35,3 +35,7 @@ export function findDisponiblesParCampus( orderBy: { nom: 'asc' }, }); } + +export function findById(id: number): Promise { + return prisma.materiel.findUnique({ where: { id } }); +} diff --git a/eme-backend/src/repositories/poste-emprunt.repository.ts b/eme-backend/src/repositories/poste-emprunt.repository.ts new file mode 100644 index 0000000..388b442 --- /dev/null +++ b/eme-backend/src/repositories/poste-emprunt.repository.ts @@ -0,0 +1,11 @@ +import { Prisma } from '@prisma/client'; +import { prisma } from '../db/prisma'; + +export type PosteAvecSalle = Prisma.PosteEmpruntGetPayload<{ include: { sallePret: true } }>; + +export function findById(id: number): Promise { + return prisma.posteEmprunt.findUnique({ + where: { id }, + include: { sallePret: true }, + }); +} diff --git a/eme-backend/src/routes/emprunt.routes.ts b/eme-backend/src/routes/emprunt.routes.ts index 7891b85..0c803a4 100644 --- a/eme-backend/src/routes/emprunt.routes.ts +++ b/eme-backend/src/routes/emprunt.routes.ts @@ -1,6 +1,8 @@ import { Router } from 'express'; -import { getMesEmprunts } from '../controllers/emprunt.controller'; +import { getMesEmprunts, postEmprunt } from '../controllers/emprunt.controller'; export const empruntRoutes: Router = Router(); +empruntRoutes.post('/', postEmprunt); -empruntRoutes.get('/', getMesEmprunts); +export const mesEmpruntsRoutes: Router = Router(); +mesEmpruntsRoutes.get('/', getMesEmprunts); diff --git a/eme-backend/src/routes/index.ts b/eme-backend/src/routes/index.ts index 72fba39..c956ac6 100644 --- a/eme-backend/src/routes/index.ts +++ b/eme-backend/src/routes/index.ts @@ -2,7 +2,7 @@ import { Router } from 'express'; import { currentUser } from '../middlewares/current-user'; import { authRoutes } from './auth.routes'; import { materielRoutes } from './materiel.routes'; -import { empruntRoutes } from './emprunt.routes'; +import { empruntRoutes, mesEmpruntsRoutes } from './emprunt.routes'; export const apiRouter: Router = Router(); @@ -11,4 +11,5 @@ apiRouter.use('/auth', authRoutes); // Le reste exige un utilisateur identifié. apiRouter.use('/materiels', currentUser, materielRoutes); -apiRouter.use('/mes-emprunts', currentUser, empruntRoutes); +apiRouter.use('/emprunts', currentUser, empruntRoutes); +apiRouter.use('/mes-emprunts', currentUser, mesEmpruntsRoutes); diff --git a/eme-backend/src/services/emprunt.service.ts b/eme-backend/src/services/emprunt.service.ts index 08751c2..25d16c1 100644 --- a/eme-backend/src/services/emprunt.service.ts +++ b/eme-backend/src/services/emprunt.service.ts @@ -1,7 +1,59 @@ -import { findEnCoursParUtilisateur, EmpruntAvecMateriel } from '../repositories/emprunt.repository'; +import { AppError } from '../errors/app-error'; +import { CreerEmpruntRequest } from '../dtos/emprunt.dto'; +import { + findEnCoursParUtilisateur, + creerEmpruntComplet, + EmpruntAvecMateriel, +} from '../repositories/emprunt.repository'; +import { findById as findMaterielById } from '../repositories/materiel.repository'; +import { findById as findPosteById } from '../repositories/poste-emprunt.repository'; -/* RG15 : un étudiant ne consulte que ses propres emprunts, et uniquement ceux - non encore restitués (en cours ou en retard). */ +const DUREE_EMPRUNT_JOURS = 14; + +/* RG15 : un étudiant ne consulte que ses propres emprunts non restitués. */ export function listerMesEmpruntsEnCours(utilisateurId: number): Promise { return findEnCoursParUtilisateur(utilisateurId); } + +/* RG07/RG10/RG11/RG12 : création d'un emprunt avec sa checklist de départ. + Les contrôles d'éligibilité (campus, disponibilité) sont faits ici ; l'écriture + atomique est déléguée au repository. */ +export async function creerEmprunt( + utilisateurId: number, + campusId: number, + request: CreerEmpruntRequest, +): Promise { + const materiel = await findMaterielById(request.materielId); + if (!materiel) { + throw new AppError(404, 'Materiel introuvable'); + } + if (materiel.campusId !== campusId) { + throw new AppError(403, 'Materiel rattache a un autre campus'); + } + if (materiel.statut !== 'DISPONIBLE') { + throw new AppError(409, 'Materiel non disponible'); + } + + const poste = await findPosteById(request.posteEmpruntId); + if (!poste) { + throw new AppError(400, 'Poste introuvable'); + } + if (poste.sallePret.campusId !== campusId) { + throw new AppError(403, 'Poste rattache a un autre campus'); + } + + const dateRetourPrevue = new Date(); + dateRetourPrevue.setDate(dateRetourPrevue.getDate() + DUREE_EMPRUNT_JOURS); + + return creerEmpruntComplet({ + utilisateurId, + materielId: request.materielId, + campusId, + sallePretId: poste.sallePretId, + posteEmpruntId: request.posteEmpruntId, + dateRetourPrevue, + modeIdentification: request.modeIdentification, + commentaireDepart: request.commentaireDepart, + elements: request.elements, + }); +} diff --git a/review.md b/review.md index 87541a6..62a996c 100644 --- a/review.md +++ b/review.md @@ -149,6 +149,13 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y - Préventif : clés/certificats (`*.pem`, `*.key`, `*.crt`, `*.cert`, `*.pfx`, `*.p12`) pour Azure AD/TLS à venir ; dumps de base (`*.bak`, `*.dump`) ; `**/coverage/` ; `*.tsbuildinfo`. - Vérifié : aucun secret n'était déjà suivi ; les vrais `.env` restent ignorés ; `.env.example` (placeholders) reste versionné. +### Étape 19 — Block 4 : création d'emprunt (POST /api/emprunts) +- Écriture transactionnelle : réservation atomique du matériel (`updateMany where statut=DISPONIBLE`), création de l'emprunt (`EN_COURS`), de la checklist de départ et de ses éléments. Rollback global si une étape échoue. +- Règles : RG07 (matériel disponible), RG10 (matériel du campus de l'étudiant), RG11 (checklist obligatoire non vide), RG12 (matériel -> `EMPRUNTE`, horodatage). +- Validation d'entrée manuelle (`CreerEmpruntRequest`, `parseCreerEmpruntRequest`) : aucune dépendance ajoutée. Durée d'emprunt par défaut : +14 jours. +- Contrôle du poste (existe + même campus, RG05). Routage : `POST /api/emprunts` et `GET /api/mes-emprunts` sur des routeurs séparés. +- Vérifié au runtime : 401 sans auth, 400 checklist vide, 404 matériel inconnu, 201 création (matériel -> `EMPRUNTE`, +14j), 409 doublon (réservation atomique). + --- ## Dette technique en attente @@ -164,4 +171,4 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y --- -*Dernière mise à jour : 2026-06-22 — Durcissement .gitignore (sécurité secrets).* +*Dernière mise à jour : 2026-07-06 — Création d'emprunt (POST /api/emprunts) en place (5/6 du Block 4).*