feat(backend): expose POST /api/emprunts with departure checklist
This commit is contained in:
@@ -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<void> {
|
||||
const user = req.user;
|
||||
@@ -12,3 +12,14 @@ export async function getMesEmprunts(req: Request, res: Response): Promise<void>
|
||||
const emprunts = await listerMesEmpruntsEnCours(user.id);
|
||||
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) });
|
||||
}
|
||||
|
||||
@@ -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<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 '../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<Emprun
|
||||
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';
|
||||
|
||||
export type MaterielAvecCategorie = Prisma.MaterielGetPayload<{ include: { categorie: true } }>;
|
||||
@@ -35,3 +35,7 @@ export function findDisponiblesParCampus(
|
||||
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 },
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<EmpruntAvecMateriel[]> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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).*
|
||||
|
||||
Reference in New Issue
Block a user