feat(backend): expose POST /api/emprunts with departure checklist

This commit is contained in:
SaidSoighiri94
2026-07-06 09:46:03 +02:00
parent d8f044924e
commit 3215a3c11f
9 changed files with 267 additions and 11 deletions
@@ -1,7 +1,7 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { AppError } from '../errors/app-error'; import { AppError } from '../errors/app-error';
import { listerMesEmpruntsEnCours } from '../services/emprunt.service'; import { creerEmprunt, listerMesEmpruntsEnCours } from '../services/emprunt.service';
import { toEmpruntResponse } from '../dtos/emprunt.dto'; import { parseCreerEmpruntRequest, toEmpruntResponse } from '../dtos/emprunt.dto';
export async function getMesEmprunts(req: Request, res: Response): Promise<void> { export async function getMesEmprunts(req: Request, res: Response): Promise<void> {
const user = req.user; const user = req.user;
@@ -12,3 +12,14 @@ export async function getMesEmprunts(req: Request, res: Response): Promise<void>
const emprunts = await listerMesEmpruntsEnCours(user.id); const emprunts = await listerMesEmpruntsEnCours(user.id);
res.json({ data: emprunts.map(toEmpruntResponse) }); res.json({ data: emprunts.map(toEmpruntResponse) });
} }
export async function postEmprunt(req: Request, res: Response): Promise<void> {
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) });
}
+96
View File
@@ -1,5 +1,7 @@
import { EmpruntAvecMateriel } from '../repositories/emprunt.repository'; import { EmpruntAvecMateriel } from '../repositories/emprunt.repository';
import { MaterielResponse, toMaterielResponse } from './materiel.dto'; import { MaterielResponse, toMaterielResponse } from './materiel.dto';
import { AppError } from '../errors/app-error';
import { ETAT_CHECKLIST_ELEMENT, MODE_IDENTIFICATION } from '../models/enums';
export interface EmpruntResponse { export interface EmpruntResponse {
id: number; id: number;
@@ -20,3 +22,97 @@ export function toEmpruntResponse(emprunt: EmpruntAvecMateriel): EmpruntResponse
materiel: toMaterielResponse(emprunt.materiel), 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<string, unknown> {
if (typeof value !== 'object' || value === null) {
throw new AppError(400, message);
}
return value as Record<string, unknown>;
}
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<T extends string>(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;
}
@@ -1,6 +1,7 @@
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { prisma } from '../db/prisma'; import { prisma } from '../db/prisma';
import { StatutEmprunt } from '../models/enums'; import { StatutEmprunt } from '../models/enums';
import { AppError } from '../errors/app-error';
export type EmpruntAvecMateriel = Prisma.EmpruntGetPayload<{ export type EmpruntAvecMateriel = Prisma.EmpruntGetPayload<{
include: { materiel: { include: { categorie: true } } }; include: { materiel: { include: { categorie: true } } };
@@ -18,3 +19,74 @@ export function findEnCoursParUtilisateur(utilisateurId: number): Promise<Emprun
orderBy: { dateRetourPrevue: 'asc' }, orderBy: { dateRetourPrevue: 'asc' },
}); });
} }
export interface CreerEmpruntData {
utilisateurId: number;
materielId: number;
campusId: number;
sallePretId: number;
posteEmpruntId: number;
dateRetourPrevue: Date;
modeIdentification: string;
commentaireDepart?: string;
elements: {
accessoireId?: number;
nomElement: string;
etat: string;
quantiteConstatee: number;
commentaire?: string;
}[];
}
/* Création atomique : réservation du matériel (DISPONIBLE -> EMPRUNTE), emprunt,
checklist de départ et ses éléments. Si une étape échoue, tout est annulé. */
export function creerEmpruntComplet(data: CreerEmpruntData): Promise<EmpruntAvecMateriel> {
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 } } },
});
});
}
@@ -1,4 +1,4 @@
import { Prisma } from '@prisma/client'; import { Materiel, Prisma } from '@prisma/client';
import { prisma } from '../db/prisma'; import { prisma } from '../db/prisma';
export type MaterielAvecCategorie = Prisma.MaterielGetPayload<{ include: { categorie: true } }>; export type MaterielAvecCategorie = Prisma.MaterielGetPayload<{ include: { categorie: true } }>;
@@ -35,3 +35,7 @@ export function findDisponiblesParCampus(
orderBy: { nom: 'asc' }, orderBy: { nom: 'asc' },
}); });
} }
export function findById(id: number): Promise<Materiel | null> {
return prisma.materiel.findUnique({ where: { id } });
}
@@ -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<PosteAvecSalle | null> {
return prisma.posteEmprunt.findUnique({
where: { id },
include: { sallePret: true },
});
}
+4 -2
View File
@@ -1,6 +1,8 @@
import { Router } from 'express'; import { Router } from 'express';
import { getMesEmprunts } from '../controllers/emprunt.controller'; import { getMesEmprunts, postEmprunt } from '../controllers/emprunt.controller';
export const empruntRoutes: Router = Router(); export const empruntRoutes: Router = Router();
empruntRoutes.post('/', postEmprunt);
empruntRoutes.get('/', getMesEmprunts); export const mesEmpruntsRoutes: Router = Router();
mesEmpruntsRoutes.get('/', getMesEmprunts);
+3 -2
View File
@@ -2,7 +2,7 @@ import { Router } from 'express';
import { currentUser } from '../middlewares/current-user'; import { currentUser } from '../middlewares/current-user';
import { authRoutes } from './auth.routes'; import { authRoutes } from './auth.routes';
import { materielRoutes } from './materiel.routes'; import { materielRoutes } from './materiel.routes';
import { empruntRoutes } from './emprunt.routes'; import { empruntRoutes, mesEmpruntsRoutes } from './emprunt.routes';
export const apiRouter: Router = Router(); export const apiRouter: Router = Router();
@@ -11,4 +11,5 @@ apiRouter.use('/auth', authRoutes);
// Le reste exige un utilisateur identifié. // Le reste exige un utilisateur identifié.
apiRouter.use('/materiels', currentUser, materielRoutes); apiRouter.use('/materiels', currentUser, materielRoutes);
apiRouter.use('/mes-emprunts', currentUser, empruntRoutes); apiRouter.use('/emprunts', currentUser, empruntRoutes);
apiRouter.use('/mes-emprunts', currentUser, mesEmpruntsRoutes);
+55 -3
View File
@@ -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 const DUREE_EMPRUNT_JOURS = 14;
non encore restitués (en cours ou en retard). */
/* RG15 : un étudiant ne consulte que ses propres emprunts non restitués. */
export function listerMesEmpruntsEnCours(utilisateurId: number): Promise<EmpruntAvecMateriel[]> { export function listerMesEmpruntsEnCours(utilisateurId: number): Promise<EmpruntAvecMateriel[]> {
return findEnCoursParUtilisateur(utilisateurId); 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<EmpruntAvecMateriel> {
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,
});
}
+8 -1
View File
@@ -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`. - 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é. - 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 ## 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).*