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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user