10 Commits

Author SHA1 Message Date
SaidSoighiri94 d8f044924e chore(security): harden .gitignore against secret leaks 2026-06-23 16:08:39 +02:00
SaidSoighiri94 ab1bf29b7f refactor(backend): rename output DTOs to Response naming convention 2026-06-22 11:01:05 +02:00
SaidSoighiri94 101e52236f refactor(backend): add output DTOs for existing endpoints 2026-06-22 10:46:50 +02:00
SaidSoighiri94 b5a7577e8d feat(auth): add profile and QR card identification endpoints 2026-06-22 10:13:45 +02:00
SaidSoighiri94 20490a05c5 feat(backend): add GET /api/mes-emprunts endpoint 2026-06-19 11:38:40 +02:00
SaidSoighiri94 bee21b9fd8 feat(backend): add GET /api/materiels catalogue endpoint 2026-06-19 10:39:27 +02:00
SaidSoighiri94 bc83308d05 feat(auth): add simulated current-user middleware 2026-06-19 10:39:26 +02:00
SaidSoighiri94 63c56ce775 chore(backend): type-check prisma scripts via split tsconfig 2026-06-16 10:15:35 +02:00
SaidSoighiri94 57266b5414 style(backend): apply Prettier formatting 2026-06-16 10:01:20 +02:00
SaidSoighiri94 d67cf8d65e chore(backend): add ESLint and Prettier configuration 2026-06-16 10:01:19 +02:00
33 changed files with 1659 additions and 38 deletions
+21 -1
View File
@@ -1,8 +1,28 @@
# Node.js (backend) # Node.js (backend)
**/node_modules/ **/node_modules/
**/dist/ **/dist/
**/.env
**/*.log **/*.log
*.tsbuildinfo
# Variables d'environnement (secrets) — seul l'exemple est versionné
**/.env
**/.env.*
!**/.env.example
# Clés et certificats (Azure AD, TLS)
*.pem
*.key
*.crt
*.cert
*.pfx
*.p12
# Dumps / sauvegardes de base
*.bak
*.dump
# Tests
**/coverage/
# Local project context # Local project context
CLAUDE.md CLAUDE.md
+3
View File
@@ -0,0 +1,3 @@
dist
node_modules
prisma/migrations
+7
View File
@@ -0,0 +1,7 @@
{
"singleQuote": true,
"semi": true,
"printWidth": 100,
"tabWidth": 2,
"trailingComma": "all"
}
+26
View File
@@ -0,0 +1,26 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
import globals from 'globals';
export default tseslint.config(
{
ignores: ['dist/**', 'node_modules/**', 'prisma/migrations/**'],
},
js.configs.recommended,
...tseslint.configs.recommended,
prettier,
{
languageOptions: {
globals: { ...globals.node },
},
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/explicit-module-boundary-types': 'error',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'prefer-const': 'error',
'no-console': 'off',
},
},
);
+1076 -1
View File
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -4,28 +4,38 @@
"description": "EME — Emprunt Matériel ENSUP — API REST", "description": "EME — Emprunt Matériel ENSUP — API REST",
"main": "dist/app.js", "main": "dist/app.js",
"scripts": { "scripts": {
"build": "tsc", "build": "tsc -p tsconfig.build.json",
"start": "node dist/app.js", "start": "node dist/app.js",
"dev": "nodemon --exec ts-node src/app.ts", "dev": "nodemon --exec ts-node src/app.ts",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio", "prisma:studio": "prisma studio",
"seed": "ts-node prisma/seed.ts", "seed": "ts-node prisma/seed.ts",
"fixtures": "ts-node prisma/fixtures.ts" "fixtures": "ts-node prisma/fixtures.ts",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write \"src/**/*.ts\" \"prisma/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"prisma/**/*.ts\""
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"type": "commonjs", "type": "commonjs",
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.9.1", "@types/node": "^25.9.1",
"eslint": "^10.5.0",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.6.0",
"nodemon": "^3.1.14", "nodemon": "^3.1.14",
"prettier": "^3.8.4",
"prisma": "^7.8.0", "prisma": "^7.8.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^6.0.3" "typescript": "^6.0.3",
"typescript-eslint": "^8.61.1"
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-mssql": "^7.8.0", "@prisma/adapter-mssql": "^7.8.0",
+49 -12
View File
@@ -62,23 +62,23 @@ async function main(): Promise<void> {
// Références (doivent exister via le seed). // Références (doivent exister via le seed).
const roleEtudiant = required( const roleEtudiant = required(
await prisma.role.findUnique({ where: { code: 'ETUDIANT' } }), await prisma.role.findUnique({ where: { code: 'ETUDIANT' } }),
'Role ETUDIANT manquant — lance d\'abord `npm run seed`', "Role ETUDIANT manquant — lance d'abord `npm run seed`",
); );
const roleResponsable = required( const roleResponsable = required(
await prisma.role.findUnique({ where: { code: 'RESPONSABLE' } }), await prisma.role.findUnique({ where: { code: 'RESPONSABLE' } }),
'Role RESPONSABLE manquant — lance d\'abord `npm run seed`', "Role RESPONSABLE manquant — lance d'abord `npm run seed`",
); );
const campus = required( const campus = required(
await prisma.campus.findFirst({ where: { nom: 'Campus Saint-Christophe' } }), await prisma.campus.findFirst({ where: { nom: 'Campus Saint-Christophe' } }),
'Campus manquant — lance d\'abord `npm run seed`', "Campus manquant — lance d'abord `npm run seed`",
); );
const salle = required( const salle = required(
await prisma.sallePret.findFirst({ where: { campusId: campus.id } }), await prisma.sallePret.findFirst({ where: { campusId: campus.id } }),
'Salle de pret manquante — lance d\'abord `npm run seed`', "Salle de pret manquante — lance d'abord `npm run seed`",
); );
const poste = required( const poste = required(
await prisma.posteEmprunt.findFirst({ where: { sallePretId: salle.id } }), await prisma.posteEmprunt.findFirst({ where: { sallePretId: salle.id } }),
'Poste manquant — lance d\'abord `npm run seed`', "Poste manquant — lance d'abord `npm run seed`",
); );
const catOrdi = required( const catOrdi = required(
await prisma.categorieMateriel.findFirst({ where: { nom: 'Ordinateur portable' } }), await prisma.categorieMateriel.findFirst({ where: { nom: 'Ordinateur portable' } }),
@@ -305,8 +305,20 @@ async function main(): Promise<void> {
}); });
await prisma.checklistElement.createMany({ await prisma.checklistElement.createMany({
data: [ data: [
{ checklistId: clDepart1.id, accessoireId: chargeur.id, nomElement: 'Chargeur', etat: 'PRESENT', quantiteConstatee: 1 }, {
{ checklistId: clDepart1.id, accessoireId: souris.id, nomElement: 'Souris', etat: 'PRESENT', quantiteConstatee: 1 }, checklistId: clDepart1.id,
accessoireId: chargeur.id,
nomElement: 'Chargeur',
etat: 'PRESENT',
quantiteConstatee: 1,
},
{
checklistId: clDepart1.id,
accessoireId: souris.id,
nomElement: 'Souris',
etat: 'PRESENT',
quantiteConstatee: 1,
},
], ],
}); });
@@ -393,8 +405,20 @@ async function main(): Promise<void> {
}); });
await prisma.checklistElement.createMany({ await prisma.checklistElement.createMany({
data: [ data: [
{ checklistId: clDepart4.id, accessoireId: housse.id, nomElement: 'Housse', etat: 'PRESENT', quantiteConstatee: 1 }, {
{ checklistId: clDepart4.id, accessoireId: chargeur.id, nomElement: 'Chargeur', etat: 'PRESENT', quantiteConstatee: 1 }, checklistId: clDepart4.id,
accessoireId: housse.id,
nomElement: 'Housse',
etat: 'PRESENT',
quantiteConstatee: 1,
},
{
checklistId: clDepart4.id,
accessoireId: chargeur.id,
nomElement: 'Chargeur',
etat: 'PRESENT',
quantiteConstatee: 1,
},
], ],
}); });
const clRetour4 = await prisma.checklist.create({ const clRetour4 = await prisma.checklist.create({
@@ -407,8 +431,21 @@ async function main(): Promise<void> {
}); });
await prisma.checklistElement.createMany({ await prisma.checklistElement.createMany({
data: [ data: [
{ checklistId: clRetour4.id, accessoireId: housse.id, nomElement: 'Housse', etat: 'ABSENT', quantiteConstatee: 0, commentaire: 'Manquante au retour' }, {
{ checklistId: clRetour4.id, accessoireId: chargeur.id, nomElement: 'Chargeur', etat: 'PRESENT', quantiteConstatee: 1 }, checklistId: clRetour4.id,
accessoireId: housse.id,
nomElement: 'Housse',
etat: 'ABSENT',
quantiteConstatee: 0,
commentaire: 'Manquante au retour',
},
{
checklistId: clRetour4.id,
accessoireId: chargeur.id,
nomElement: 'Chargeur',
etat: 'PRESENT',
quantiteConstatee: 1,
},
], ],
}); });
console.log('[fixtures] 4 emprunts + checklists crees'); console.log('[fixtures] 4 emprunts + checklists crees');
@@ -464,7 +501,7 @@ async function main(): Promise<void> {
}, },
], ],
}); });
console.log('[fixtures] 2 entrees d\'historique creees'); console.log("[fixtures] 2 entrees d'historique creees");
} }
main() main()
+3
View File
@@ -6,6 +6,7 @@ import { logger } from './utils/logger';
import { requestLogger } from './middlewares/request-logger'; import { requestLogger } from './middlewares/request-logger';
import { notFoundHandler } from './middlewares/not-found'; import { notFoundHandler } from './middlewares/not-found';
import { errorHandler } from './middlewares/error-handler'; import { errorHandler } from './middlewares/error-handler';
import { apiRouter } from './routes';
const app = express(); const app = express();
@@ -17,6 +18,8 @@ app.get('/health', (_req, res) => {
res.json({ status: 'ok', app: 'EME API' }); res.json({ status: 'ok', app: 'EME API' });
}); });
app.use('/api', apiRouter);
/* notFoundHandler et errorHandler doivent rester après toutes les routes : /* notFoundHandler et errorHandler doivent rester après toutes les routes :
le premier capture les URL non gérées, le second clôt la chaîne Express. */ le premier capture les URL non gérées, le second clôt la chaîne Express. */
app.use(notFoundHandler); app.use(notFoundHandler);
@@ -0,0 +1,24 @@
import { Request, Response } from 'express';
import { AppError } from '../errors/app-error';
import { getProfil, identifierParCarte } from '../services/utilisateur.service';
import { toUtilisateurResponse } from '../dtos/utilisateur.dto';
export async function getMe(req: Request, res: Response): Promise<void> {
const user = req.user;
if (!user) {
throw new AppError(401, 'Authentification requise');
}
const profil = await getProfil(user.id);
res.json({ data: toUtilisateurResponse(profil) });
}
export async function identifier(req: Request, res: Response): Promise<void> {
const qr = req.params.qr;
if (typeof qr !== 'string') {
throw new AppError(400, 'QR code invalide');
}
const profil = await identifierParCarte(qr);
res.json({ data: toUtilisateurResponse(profil) });
}
@@ -0,0 +1,14 @@
import { Request, Response } from 'express';
import { AppError } from '../errors/app-error';
import { listerMesEmpruntsEnCours } from '../services/emprunt.service';
import { toEmpruntResponse } from '../dtos/emprunt.dto';
export async function getMesEmprunts(req: Request, res: Response): Promise<void> {
const user = req.user;
if (!user) {
throw new AppError(401, 'Authentification requise');
}
const emprunts = await listerMesEmpruntsEnCours(user.id);
res.json({ data: emprunts.map(toEmpruntResponse) });
}
@@ -0,0 +1,30 @@
import { Request, Response } from 'express';
import { AppError } from '../errors/app-error';
import { listerCatalogue } from '../services/materiel.service';
import { toMaterielResponse } from '../dtos/materiel.dto';
function parseCategorieId(value: unknown): number | undefined {
if (value === undefined) {
return undefined;
}
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new AppError(400, 'categorieId invalide');
}
return parsed;
}
export async function getCatalogue(req: Request, res: Response): Promise<void> {
const user = req.user;
if (!user) {
throw new AppError(401, 'Authentification requise');
}
const recherche = typeof req.query.q === 'string' ? req.query.q : undefined;
const categorieId = parseCategorieId(req.query.categorieId);
const materiels = await listerCatalogue(user.campusId, { categorieId, recherche });
res.json({ data: materiels.map(toMaterielResponse) });
}
+22
View File
@@ -0,0 +1,22 @@
import { EmpruntAvecMateriel } from '../repositories/emprunt.repository';
import { MaterielResponse, toMaterielResponse } from './materiel.dto';
export interface EmpruntResponse {
id: number;
dateEmprunt: Date;
dateRetourPrevue: Date;
dateRetourReelle: Date | null;
statut: string;
materiel: MaterielResponse;
}
export function toEmpruntResponse(emprunt: EmpruntAvecMateriel): EmpruntResponse {
return {
id: emprunt.id,
dateEmprunt: emprunt.dateEmprunt,
dateRetourPrevue: emprunt.dateRetourPrevue,
dateRetourReelle: emprunt.dateRetourReelle,
statut: emprunt.statut,
materiel: toMaterielResponse(emprunt.materiel),
};
}
+33
View File
@@ -0,0 +1,33 @@
import { MaterielAvecCategorie } from '../repositories/materiel.repository';
export interface CategorieResponse {
id: number;
nom: string;
}
export interface MaterielResponse {
id: number;
nom: string;
marque: string;
modele: string;
reference: string;
statut: string;
etatGeneral: string;
categorie: CategorieResponse;
}
export function toMaterielResponse(materiel: MaterielAvecCategorie): MaterielResponse {
return {
id: materiel.id,
nom: materiel.nom,
marque: materiel.marque,
modele: materiel.modele,
reference: materiel.reference,
statut: materiel.statut,
etatGeneral: materiel.etatGeneral,
categorie: {
id: materiel.categorie.id,
nom: materiel.categorie.nom,
},
};
}
+41
View File
@@ -0,0 +1,41 @@
import { UtilisateurProfil } from '../repositories/utilisateur.repository';
export interface RoleResponse {
code: string;
libelle: string;
}
export interface CampusResponse {
id: number;
nom: string;
ville: string;
}
export interface UtilisateurResponse {
id: number;
nom: string;
prenom: string;
email: string;
classe: string | null;
role: RoleResponse;
campus: CampusResponse;
}
export function toUtilisateurResponse(utilisateur: UtilisateurProfil): UtilisateurResponse {
return {
id: utilisateur.id,
nom: utilisateur.nom,
prenom: utilisateur.prenom,
email: utilisateur.email,
classe: utilisateur.classe,
role: {
code: utilisateur.role.code,
libelle: utilisateur.role.libelle,
},
campus: {
id: utilisateur.campus.id,
nom: utilisateur.campus.nom,
ville: utilisateur.campus.ville,
},
};
}
@@ -0,0 +1,28 @@
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../errors/app-error';
import { findByEmail } from '../repositories/utilisateur.repository';
import { AuthenticatedUser } from '../types/authenticated-user';
/* Auth simulée (temporaire) : en attendant Azure AD, l'utilisateur courant est
résolu depuis l'en-tête `x-user-email`. À remplacer par la validation du JWT. */
export async function currentUser(req: Request, _res: Response, next: NextFunction): Promise<void> {
const email = req.header('x-user-email');
if (!email) {
throw new AppError(401, 'En-tete x-user-email requis (auth simulee)');
}
const utilisateur = await findByEmail(email);
if (!utilisateur || !utilisateur.actif) {
throw new AppError(401, 'Utilisateur non reconnu');
}
const authenticated: AuthenticatedUser = {
id: utilisateur.id,
email: utilisateur.email,
roleCode: utilisateur.role.code,
campusId: utilisateur.campusId,
};
req.user = authenticated;
next();
}
+6 -1
View File
@@ -5,7 +5,12 @@ import { logger } from '../utils/logger';
/* Le 4e argument est requis par Express pour identifier un gestionnaire d'erreurs, /* Le 4e argument est requis par Express pour identifier un gestionnaire d'erreurs,
même s'il n'est pas utilisé. */ même s'il n'est pas utilisé. */
export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction): void { export function errorHandler(
err: unknown,
_req: Request,
res: Response,
_next: NextFunction,
): void {
const isAppError = err instanceof AppError; const isAppError = err instanceof AppError;
const statusCode = isAppError ? err.statusCode : 500; const statusCode = isAppError ? err.statusCode : 500;
const message = isAppError ? err.message : 'Erreur interne du serveur'; const message = isAppError ? err.message : 'Erreur interne du serveur';
+1 -6
View File
@@ -27,12 +27,7 @@ export const ETAT_CHECKLIST_ELEMENT = ['PRESENT', 'ABSENT', 'DETERIORE'] as cons
export type EtatChecklistElement = (typeof ETAT_CHECKLIST_ELEMENT)[number]; export type EtatChecklistElement = (typeof ETAT_CHECKLIST_ELEMENT)[number];
export const STATUT_ANOMALIE = [ export const STATUT_ANOMALIE = ['DETECTEE', 'EN_COURS_TRAITEMENT', 'RESOLUE', 'CLOTUREE'] as const;
'DETECTEE',
'EN_COURS_TRAITEMENT',
'RESOLUE',
'CLOTUREE',
] as const;
export type StatutAnomalie = (typeof STATUT_ANOMALIE)[number]; export type StatutAnomalie = (typeof STATUT_ANOMALIE)[number];
@@ -0,0 +1,13 @@
import { Prisma } from '@prisma/client';
import { prisma } from '../db/prisma';
export type CarteAvecUtilisateur = Prisma.CarteEtudianteGetPayload<{
include: { utilisateur: { include: { role: true; campus: true } } };
}>;
export function findByQrCode(qrCode: string): Promise<CarteAvecUtilisateur | null> {
return prisma.carteEtudiante.findFirst({
where: { qrCode },
include: { utilisateur: { include: { role: true, campus: true } } },
});
}
@@ -0,0 +1,20 @@
import { Prisma } from '@prisma/client';
import { prisma } from '../db/prisma';
import { StatutEmprunt } from '../models/enums';
export type EmpruntAvecMateriel = Prisma.EmpruntGetPayload<{
include: { materiel: { include: { categorie: true } } };
}>;
const STATUTS_EN_COURS: StatutEmprunt[] = ['EN_COURS', 'EN_RETARD'];
export function findEnCoursParUtilisateur(utilisateurId: number): Promise<EmpruntAvecMateriel[]> {
return prisma.emprunt.findMany({
where: {
utilisateurId,
statut: { in: STATUTS_EN_COURS },
},
include: { materiel: { include: { categorie: true } } },
orderBy: { dateRetourPrevue: 'asc' },
});
}
@@ -0,0 +1,37 @@
import { Prisma } from '@prisma/client';
import { prisma } from '../db/prisma';
export type MaterielAvecCategorie = Prisma.MaterielGetPayload<{ include: { categorie: true } }>;
export interface CatalogueFiltres {
categorieId?: number;
recherche?: string;
}
export function findDisponiblesParCampus(
campusId: number,
filtres: CatalogueFiltres,
): Promise<MaterielAvecCategorie[]> {
const { categorieId, recherche } = filtres;
return prisma.materiel.findMany({
where: {
campusId,
actif: true,
statut: 'DISPONIBLE',
...(categorieId !== undefined ? { categorieId } : {}),
...(recherche
? {
OR: [
{ nom: { contains: recherche } },
{ marque: { contains: recherche } },
{ modele: { contains: recherche } },
{ reference: { contains: recherche } },
],
}
: {}),
},
include: { categorie: true },
orderBy: { nom: 'asc' },
});
}
@@ -0,0 +1,22 @@
import { Prisma } from '@prisma/client';
import { prisma } from '../db/prisma';
export type UtilisateurAvecRole = Prisma.UtilisateurGetPayload<{ include: { role: true } }>;
export type UtilisateurProfil = Prisma.UtilisateurGetPayload<{
include: { role: true; campus: true };
}>;
export function findByEmail(email: string): Promise<UtilisateurAvecRole | null> {
return prisma.utilisateur.findUnique({
where: { email },
include: { role: true },
});
}
export function findById(id: number): Promise<UtilisateurProfil | null> {
return prisma.utilisateur.findUnique({
where: { id },
include: { role: true, campus: true },
});
}
+11
View File
@@ -0,0 +1,11 @@
import { Router } from 'express';
import { currentUser } from '../middlewares/current-user';
import { getMe, identifier } from '../controllers/auth.controller';
export const authRoutes: Router = Router();
// Identification par QR code : point d'entrée du parcours, donc public.
authRoutes.get('/carte/:qr', identifier);
// Profil de l'utilisateur déjà identifié : protégé.
authRoutes.get('/me', currentUser, getMe);
+6
View File
@@ -0,0 +1,6 @@
import { Router } from 'express';
import { getMesEmprunts } from '../controllers/emprunt.controller';
export const empruntRoutes: Router = Router();
empruntRoutes.get('/', getMesEmprunts);
+14
View File
@@ -0,0 +1,14 @@
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';
export const apiRouter: Router = Router();
// /auth gère lui-même ses routes publiques/protégées.
apiRouter.use('/auth', authRoutes);
// Le reste exige un utilisateur identifié.
apiRouter.use('/materiels', currentUser, materielRoutes);
apiRouter.use('/mes-emprunts', currentUser, empruntRoutes);
@@ -0,0 +1,6 @@
import { Router } from 'express';
import { getCatalogue } from '../controllers/materiel.controller';
export const materielRoutes: Router = Router();
materielRoutes.get('/', getCatalogue);
@@ -0,0 +1,7 @@
import { findEnCoursParUtilisateur, EmpruntAvecMateriel } from '../repositories/emprunt.repository';
/* RG15 : un étudiant ne consulte que ses propres emprunts, et uniquement ceux
non encore restitués (en cours ou en retard). */
export function listerMesEmpruntsEnCours(utilisateurId: number): Promise<EmpruntAvecMateriel[]> {
return findEnCoursParUtilisateur(utilisateurId);
}
@@ -0,0 +1,14 @@
import {
findDisponiblesParCampus,
CatalogueFiltres,
MaterielAvecCategorie,
} from '../repositories/materiel.repository';
/* RG10 : après identification, seuls les matériels disponibles du campus de
l'étudiant sont affichés. */
export function listerCatalogue(
campusId: number,
filtres: CatalogueFiltres,
): Promise<MaterielAvecCategorie[]> {
return findDisponiblesParCampus(campusId, filtres);
}
@@ -0,0 +1,31 @@
import { AppError } from '../errors/app-error';
import { findById, UtilisateurProfil } from '../repositories/utilisateur.repository';
import { findByQrCode } from '../repositories/carte-etudiante.repository';
export async function getProfil(utilisateurId: number): Promise<UtilisateurProfil> {
const profil = await findById(utilisateurId);
if (!profil) {
throw new AppError(404, 'Utilisateur introuvable');
}
return profil;
}
/* RG02 : identification de l'étudiant par le QR code de sa carte. La carte doit
exister, être active et non expirée, et le compte doit être actif. */
export async function identifierParCarte(qrCode: string): Promise<UtilisateurProfil> {
const carte = await findByQrCode(qrCode);
if (!carte || !carte.actif) {
throw new AppError(404, 'Carte non reconnue');
}
if (carte.dateExpiration.getTime() < Date.now()) {
throw new AppError(401, 'Carte expiree');
}
if (!carte.utilisateur.actif) {
throw new AppError(401, 'Compte inactif');
}
return carte.utilisateur;
}
@@ -0,0 +1,6 @@
export interface AuthenticatedUser {
id: number;
email: string;
roleCode: string;
campusId: number;
}
+9
View File
@@ -0,0 +1,9 @@
import { AuthenticatedUser } from './authenticated-user';
declare global {
namespace Express {
interface Request {
user?: AuthenticatedUser;
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src/**/*"]
}
+3 -4
View File
@@ -2,16 +2,15 @@
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"module": "commonjs", "module": "commonjs",
"rootDir": "./src",
"outDir": "./dist",
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"sourceMap": true, "sourceMap": true,
"types": ["node"] "types": ["node"],
"noEmit": true
}, },
"include": ["src/**/*"], "include": ["src/**/*", "prisma/**/*"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }
+53 -9
View File
@@ -22,6 +22,7 @@ Ces décisions s'appliquent à tout le projet, sauf mention contraire.
| ERD tenu à jour en miroir du schéma (`docs/conceptions/uml/ERD.md`) | La source de vérité doit refléter le code réel (nullable, `UK`, etc.). | | ERD tenu à jour en miroir du schéma (`docs/conceptions/uml/ERD.md`) | La source de vérité doit refléter le code réel (nullable, `UK`, etc.). |
| Workflow Git : commits sur `develop` uniquement | `master` est figé ; `develop` reste toujours à jour de `master` (aucune divergence puisque master ne bouge plus). | | Workflow Git : commits sur `develop` uniquement | `master` est figé ; `develop` reste toujours à jour de `master` (aucune divergence puisque master ne bouge plus). |
| Process avant chaque migration : `prisma format` -> `prisma validate` -> `npm run build` -> migration | Détecter toute erreur de schéma/typage avant de toucher la base. | | Process avant chaque migration : `prisma format` -> `prisma validate` -> `npm run build` -> migration | Détecter toute erreur de schéma/typage avant de toucher la base. |
| DTO nommés `XxxResponse` (sortie) et `XxxRequest` (entrée) | Le sens est explicite dans le nom. Sortie = mapping (`toXxxResponse`), entrée = validation. |
### Note outillage — migrations en environnement non-interactif ### Note outillage — migrations en environnement non-interactif
@@ -45,26 +46,26 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
- Defaults + timestamps auto ajoutés sur Campus, Utilisateur, CategorieMateriel (Role n'a pas de timestamps -> non modifié). - Defaults + timestamps auto ajoutés sur Campus, Utilisateur, CategorieMateriel (Role n'a pas de timestamps -> non modifié).
- `@unique` sur `Role.code`, `Utilisateur.microsoftId`, `Utilisateur.email`. - `@unique` sur `Role.code`, `Utilisateur.microsoftId`, `Utilisateur.email`.
- `Utilisateur.classe` -> nullable : un `RESPONSABLE` n'a pas de classe (seuls les étudiants en ont). - `Utilisateur.classe` -> nullable : un `RESPONSABLE` n'a pas de classe (seuls les étudiants en ont).
- ERD mis à jour en miroir (`UK`, `nullable`). - Ajout dans l'ERD des annotations `UK` (sur `code`, `microsoft_id`, `email`) et `nullable` (sur `classe`).
- Choix laissé de côté : tailles `@db.NVarChar(n)` (colonnes restées en `NVARCHAR(1000)` par défaut). À reconsidérer plus tard. - Choix laissé de côté : tailles `@db.NVarChar(n)` (colonnes restées en `NVARCHAR(1000)` par défaut). À reconsidérer plus tard.
### Étape 3 — Bloc A : Infrastructure (SallePret, PosteEmprunt) — commit `d026b75` ### Étape 3 — Bloc A : Infrastructure (SallePret, PosteEmprunt) — commit `d026b75`
- `SallePret` rattachée à `Campus` ; `PosteEmprunt` rattaché à `SallePret`. Conforme à l'ERD. - `SallePret` rattachée à `Campus` (FK `campus_id`) ; `PosteEmprunt` rattaché à `SallePret` (FK `salle_pret_id`).
### Étape 4 — Bloc B : CarteEtudiante — commit `dfa73cd` ### Étape 4 — Bloc B : CarteEtudiante — commit `dfa73cd`
- Rattachée à `Utilisateur`. - Rattachée à `Utilisateur`.
- Dette notée : `qr_code` devrait être `@unique` (sert à l'identification, RG02) — non posé pour rester fidèle à l'ERD. - Dette notée : `qr_code` devrait être `@unique` (sert à l'identification, RG02) — non posé pour rester fidèle à l'ERD.
### Étape 5 — Bloc C : Matériel (Materiel, Accessoire, MaterielAccessoire) — commit `0774ee3` ### Étape 5 — Bloc C : Matériel (Materiel, Accessoire, MaterielAccessoire) — commit `0774ee3`
- Table de liaison `MaterielAccessoire` (sans timestamps, conforme ERD). - Table de liaison `MaterielAccessoire` entre `Materiel` et `Accessoire`, sans colonnes `created_at`/`updated_at`.
- Dette notée : `@@unique([materielId, accessoireId])` et `Materiel.statut @default("DISPONIBLE")`. - Dette notée : `@@unique([materielId, accessoireId])` et `Materiel.statut @default("DISPONIBLE")`.
### Étape 6 — Bloc D : Transactions (Emprunt, Checklist, ChecklistElement) — commit `6908a2b` ### Étape 6 — Bloc D : Transactions (Emprunt, Checklist, ChecklistElement) — commit `6908a2b`
- Champs de retour/optionnels nullable : `dateRetourReelle`, `modeIdentificationRetour`, `commentaireDepart`, `commentaireRetour`, `Checklist.commentaire`, `ChecklistElement.commentaire`. - Champs de retour/optionnels nullable : `dateRetourReelle`, `modeIdentificationRetour`, `commentaireDepart`, `commentaireRetour`, `Checklist.commentaire`, `ChecklistElement.commentaire`.
- Pourquoi : RG12 crée l'emprunt au départ ; les infos de retour n'existent pas encore. En `NOT NULL`, la création serait impossible. - Pourquoi : RG12 crée l'emprunt au départ ; les infos de retour n'existent pas encore. En `NOT NULL`, la création serait impossible.
- `ChecklistElement.accessoireId` nullable : conforme au diagramme de classes (`Accessoire "0..1"`), un élément peut être un libellé libre sans accessoire catalogué. - `ChecklistElement.accessoireId` rendu nullable : un élément de checklist peut être un libellé libre, sans accessoire catalogué associé (cardinalité 0..1).
- Les 9 FK en `NoAction` : traçabilité + évitement des cascade paths. - Les 9 FK en `NoAction` : traçabilité + évitement des cascade paths.
- ERD mis à jour en miroir (champs annotés `nullable`). - Annotation `nullable` ajoutée dans l'ERD sur les champs de retour/commentaires d'`Emprunt` et `accessoire_id`/`commentaire` de checklist.
### Étape 7 — Stratégie Git : branche `develop` ### Étape 7 — Stratégie Git : branche `develop`
- Création de `develop` depuis `master`. Désormais tous les commits vont sur `develop` ; `master` figé au Bloc C. - Création de `develop` depuis `master`. Désormais tous les commits vont sur `develop` ; `master` figé au Bloc C.
@@ -75,8 +76,8 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
- Double relation `Anomalie` vers `Utilisateur` nommée : `@relation("AnomalieEtudiant")` (étudiant concerné) et `@relation("AnomalieResponsable")` (responsable qui traite). - Double relation `Anomalie` vers `Utilisateur` nommée : `@relation("AnomalieEtudiant")` (étudiant concerné) et `@relation("AnomalieResponsable")` (responsable qui traite).
- Defaults : `Anomalie.detecteeAutomatiquement @default(true)`, `Notification.lu @default(false)`, `Notification.dateCreation @default(now())`. - Defaults : `Anomalie.detecteeAutomatiquement @default(true)`, `Notification.lu @default(false)`, `Notification.dateCreation @default(now())`.
- Les 12 FK en `NoAction` (traçabilité). - Les 12 FK en `NoAction` (traçabilité).
- `Notification` et `Historique` sans `updatedAt` (conforme ERD). - `Notification` et `Historique` créées sans colonne `updatedAt` (seulement `date_creation`/`created_at`).
- ERD mis à jour en miroir. - Annotation `nullable` ajoutée dans l'ERD sur `traitee_par_id`, `observation`, `date_resolution`, `anomalie_id` et les 5 FK optionnelles d'`Historique`.
### Étape 9 — Seeds de référence — commit `2c3a5db` ### Étape 9 — Seeds de référence — commit `2c3a5db`
- Fichier `prisma/seed.ts` autonome (adapter MSSQL, lecture directe des variables DB), lancé via `npm run seed` (script ajouté au `package.json`). - Fichier `prisma/seed.ts` autonome (adapter MSSQL, lecture directe des variables DB), lancé via `npm run seed` (script ajouté au `package.json`).
@@ -106,6 +107,48 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
- Express 5 propage nativement les erreurs des handlers async : pas de wrapper `asyncHandler` nécessaire. - Express 5 propage nativement les erreurs des handlers async : pas de wrapper `asyncHandler` nécessaire.
- Vérifié au runtime : `/health` 200, route inconnue 404 en JSON, requêtes journalisées. - Vérifié au runtime : `/health` 200, route inconnue 404 en JSON, requêtes journalisées.
### Étape 12 — Block 1 : ESLint + Prettier
- ESLint 10 (flat config `eslint.config.mjs`) + typescript-eslint 8 ; Prettier 3 (`.prettierrc.json`).
- Règles de qualité strictes encodées : `no-explicit-any`, `no-non-null-assertion`, `explicit-module-boundary-types`, `no-unused-vars` (ignore le préfixe `_`), `prefer-const`. `eslint-config-prettier` désactive les règles en conflit avec Prettier.
- Prettier : quotes simples, point-virgule, 100 colonnes, 2 espaces, trailing commas.
- Scripts : `lint`, `lint:fix`, `format`, `format:check`.
- État : lint vert sur tout le code existant ; formatage appliqué (3 fichiers) ; build OK.
### Étape 13 — Correctif tooling : type-check des scripts Prisma
- Problème : `prisma/seed.ts` et `fixtures.ts` étaient hors du `include` du tsconfig ; l'IDE ne chargeait pas les types Node (`process` non reconnu, 2 erreurs).
- Correctif : `tsconfig.json` élargi à `src` + `prisma` en `noEmit` (IDE et `tsc --noEmit`) ; nouveau `tsconfig.build.json` dédié à la compilation `src` -> `dist` (`npm run build`).
- Bénéfice : les scripts Prisma sont désormais type-checkés (angle mort comblé).
### Étape 14 — Block 4 : architecture en couches + 1er endpoint (catalogue matériel)
- Mise en place de l'architecture en couches : `routes/` -> `controllers/` -> `services/` -> `repositories/` (un dossier par couche).
- Auth simulée temporaire : middleware `current-user` qui résout l'utilisateur via l'en-tête `x-user-email` et pose `req.user` (typé via `types/authenticated-user.ts` + augmentation `Express.Request`). À remplacer par la validation JWT au Block 3.
- Endpoint `GET /api/materiels` (catalogue) : applique RG10 (matériels `DISPONIBLE` du campus de l'étudiant), filtres `categorieId` et `q` (recherche nom/marque/modèle/référence).
- Format de réponse standardisé : `{ "data": ... }` (cohérent avec `{ "error": ... }`).
- Vérifié au runtime : 401 sans en-tête / utilisateur inconnu, 200 avec les disponibles du campus, filtres OK, 400 sur `categorieId` invalide.
### Étape 15 — Block 4 : endpoint "mes emprunts en cours"
- Endpoint `GET /api/mes-emprunts` : retourne les emprunts non restitués (`EN_COURS`, `EN_RETARD`) de l'utilisateur courant, triés par date de retour prévue, avec le matériel et sa catégorie inclus.
- RG15 : filtrage par `utilisateurId` (chacun ne voit que ses propres emprunts).
- Réutilise l'architecture en couches et l'auth simulée déjà en place.
- Vérifié au runtime : Marie 1 (`EN_COURS`, son `CLOTURE` exclu), Lucas 1 (`EN_RETARD`), Sofia 0 (son `RETOUR_NON_CONFORME` exclu), 401 sans en-tête.
### Étape 16 — Block 4 : profil et identification
- `GET /api/auth/me` (protégé) : renvoie le profil complet de l'utilisateur courant (role + campus).
- `GET /api/auth/carte/:qr` (public) : identification par QR code (RG02/RG03) ; contrôles carte active, non expirée et compte actif ; renvoie le profil.
- Refactor du routage : `currentUser` appliqué par sous-routeur (au lieu d'un middleware global) pour laisser la route d'identification publique.
- Vérifié au runtime : `/me` 200 (profil Marie) et 401 sans en-tête ; `/carte` QR valide 200 sans en-tête (route publique), QR inconnu 404.
### Étape 17 — DTO de sortie sur les endpoints existants
- Dossier `src/dtos/` : par ressource, une interface `XxxResponse` + un mapper pur `toXxxResponse` (utilisateur, matériel, emprunt).
- Mapping effectué dans le controller (couche présentation) ; les services continuent de renvoyer les entités (réutilisables).
- Champs internes désormais masqués : `microsoftId`, timestamps, FK brutes, `numeroInventaire`/`numeroSerie`.
- Vérifié au runtime sur `/me`, `/materiels`, `/mes-emprunts`.
### Étape 18 — Durcissement du .gitignore (sécurité)
- Trou comblé : `**/.env` ne couvrait pas les variantes (`.env.local`, `.env.production`...). Ajout de `**/.env.*` avec exception `!**/.env.example`.
- 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é.
--- ---
## Dette technique en attente ## Dette technique en attente
@@ -116,8 +159,9 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
2. `MaterielAccessoire` -> `@@unique([materielId, accessoireId])` (un accessoire une seule fois par matériel) 2. `MaterielAccessoire` -> `@@unique([materielId, accessoireId])` (un accessoire une seule fois par matériel)
3. `Materiel.statut` -> `@default("DISPONIBLE")` (un matériel neuf est disponible, RG07) 3. `Materiel.statut` -> `@default("DISPONIBLE")` (un matériel neuf est disponible, RG07)
4. Tailles de colonnes `@db.NVarChar(n)` (actuellement `NVARCHAR(1000)` partout) 4. Tailles de colonnes `@db.NVarChar(n)` (actuellement `NVARCHAR(1000)` partout)
5. Block 1 : middleware d'erreurs global, logs, ESLint + Prettier, Swagger/OpenAPI 5. Swagger/OpenAPI (Block 1, reporté après les endpoints)
6. Auth réelle Azure AD (Block 3) — remplacera l'auth simulée par en-tête
--- ---
*Dernière mise à jour : 2026-06-15Middlewares backend (gestion d'erreurs, logs) en place.* *Dernière mise à jour : 2026-06-22Durcissement .gitignore (sécurité secrets).*