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 { 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) });
}
+96
View File
@@ -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 },
});
}
+4 -2
View File
@@ -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);
+3 -2
View File
@@ -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);
+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
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,
});
}