feat(backend): expose POST /api/emprunts with departure checklist
This commit is contained in:
@@ -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) });
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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).*
|
||||||
|
|||||||
Reference in New Issue
Block a user