diff --git a/eme-backend/src/controllers/responsable.controller.ts b/eme-backend/src/controllers/responsable.controller.ts index 469bf2f..86af75c 100644 --- a/eme-backend/src/controllers/responsable.controller.ts +++ b/eme-backend/src/controllers/responsable.controller.ts @@ -3,6 +3,7 @@ import { AppError } from '../errors/app-error'; import { getDashboardResponsable, listerAnomaliesResponsable, + listerHistoriqueResponsable, listerNotificationsResponsable, listerMaterielsResponsable, listerEmpruntsResponsable, @@ -11,6 +12,8 @@ import { parseCategorieId, parseLu, parseNotificationId, + parseDateQuery, + parsePositiveIntQuery, parseStatutAnomalie, parseStatutMateriel, parseStatutEmprunt, @@ -19,10 +22,31 @@ import { toAnomalieResponsableResponse, toDashboardResponsableResponse, toEmpruntResponsableResponse, + toHistoriqueResponsableResponse, toMaterielResponsableResponse, toNotificationResponsableResponse, } from '../dtos/responsable.dto'; +function getHistoriqueFiltres(req: Request) { + const action = typeof req.query.action === 'string' ? req.query.action : undefined; + return { + action, + utilisateurId: parsePositiveIntQuery(req.query.utilisateurId, 'utilisateurId'), + materielId: parsePositiveIntQuery(req.query.materielId, 'materielId'), + empruntId: parsePositiveIntQuery(req.query.empruntId, 'empruntId'), + dateDebut: parseDateQuery(req.query.dateDebut, 'dateDebut'), + dateFin: parseDateQuery(req.query.dateFin, 'dateFin'), + }; +} + +function escapeCsv(value: string | number | Date | null): string { + if (value === null) { + return ''; + } + const raw = value instanceof Date ? value.toISOString() : String(value); + return `"${raw.replace(/"/g, '""')}"`; +} + export async function getDashboard(req: Request, res: Response): Promise { const user = req.user; if (!user) { @@ -113,3 +137,53 @@ export async function patchNotificationsLues(req: Request, res: Response): Promi const count = await marquerToutesNotificationsResponsableLues(user.id, user.roleCode); res.json({ data: { count } }); } + +export async function getHistorique(req: Request, res: Response): Promise { + const user = req.user; + if (!user) { + throw new AppError(401, 'Authentification requise'); + } + + const historique = await listerHistoriqueResponsable( + user.roleCode, + user.campusId, + getHistoriqueFiltres(req), + ); + res.json({ data: historique.map(toHistoriqueResponsableResponse) }); +} + +export async function getHistoriqueCsv(req: Request, res: Response): Promise { + const user = req.user; + if (!user) { + throw new AppError(401, 'Authentification requise'); + } + + const historique = await listerHistoriqueResponsable( + user.roleCode, + user.campusId, + getHistoriqueFiltres(req), + ); + const lignes = [ + ['id', 'dateAction', 'action', 'description', 'utilisateur', 'email', 'materiel', 'empruntId'] + .map(escapeCsv) + .join(','), + ...historique.map((item) => + [ + item.id, + item.dateAction, + item.action, + item.description, + `${item.utilisateur.prenom} ${item.utilisateur.nom}`, + item.utilisateur.email, + item.materiel ? `${item.materiel.nom} (${item.materiel.reference})` : null, + item.empruntId, + ] + .map(escapeCsv) + .join(','), + ), + ]; + + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', 'attachment; filename="historique-responsable.csv"'); + res.send(lignes.join('\n')); +} diff --git a/eme-backend/src/dtos/responsable.dto.ts b/eme-backend/src/dtos/responsable.dto.ts index bb0935b..9e0be81 100644 --- a/eme-backend/src/dtos/responsable.dto.ts +++ b/eme-backend/src/dtos/responsable.dto.ts @@ -2,6 +2,7 @@ import { ActiviteResponsable, AnomalieResponsable, EmpruntResponsable, + HistoriqueResponsable, MaterielResponsable, NotificationResponsable, StatutCount, @@ -146,6 +147,33 @@ export interface NotificationResponsableResponse { } | null; } +export interface HistoriqueResponsableResponse { + id: number; + action: string; + description: string; + dateAction: Date; + utilisateur: { + id: number; + nom: string; + prenom: string; + email: string; + }; + materiel: { + id: number; + nom: string; + reference: string; + } | null; + empruntId: number | null; + sallePret: { + id: number; + nom: string; + } | null; + posteEmprunt: { + id: number; + nom: string; + } | null; +} + function toRepartition(rows: StatutCount[]): RepartitionStatutResponse { const parStatut: Record = {}; let total = 0; @@ -318,3 +346,40 @@ export function toNotificationResponsableResponse( : null, }; } + +export function toHistoriqueResponsableResponse( + historique: HistoriqueResponsable, +): HistoriqueResponsableResponse { + return { + id: historique.id, + action: historique.action, + description: historique.description, + dateAction: historique.dateAction, + utilisateur: { + id: historique.utilisateur.id, + nom: historique.utilisateur.nom, + prenom: historique.utilisateur.prenom, + email: historique.utilisateur.email, + }, + materiel: historique.materiel + ? { + id: historique.materiel.id, + nom: historique.materiel.nom, + reference: historique.materiel.reference, + } + : null, + empruntId: historique.empruntId, + sallePret: historique.sallePret + ? { + id: historique.sallePret.id, + nom: historique.sallePret.nom, + } + : null, + posteEmprunt: historique.posteEmprunt + ? { + id: historique.posteEmprunt.id, + nom: historique.posteEmprunt.nom, + } + : null, + }; +} diff --git a/eme-backend/src/repositories/responsable.repository.ts b/eme-backend/src/repositories/responsable.repository.ts index 31bd613..2b7824c 100644 --- a/eme-backend/src/repositories/responsable.repository.ts +++ b/eme-backend/src/repositories/responsable.repository.ts @@ -48,6 +48,16 @@ export type NotificationResponsable = Prisma.NotificationGetPayload<{ }; }>; +export type HistoriqueResponsable = Prisma.HistoriqueGetPayload<{ + include: { + utilisateur: true; + materiel: true; + emprunt: true; + sallePret: true; + posteEmprunt: true; + }; +}>; + export interface EmpruntResponsableFiltres { statut?: string; } @@ -67,6 +77,15 @@ export interface NotificationResponsableFiltres { lu?: boolean; } +export interface HistoriqueResponsableFiltres { + action?: string; + utilisateurId?: number; + materielId?: number; + empruntId?: number; + dateDebut?: Date; + dateFin?: Date; +} + function mapStatutCounts(rows: Array<{ statut: string; _count: { _all: number } }>): StatutCount[] { return rows.map((row) => ({ statut: row.statut, count: row._count._all })); } @@ -239,3 +258,34 @@ export async function marquerNotificationsResponsableLues(utilisateurId: number) return result.count; } + +export function findHistoriqueResponsable( + campusId: number, + filtres: HistoriqueResponsableFiltres, +): Promise { + return prisma.historique.findMany({ + where: { + campusId, + ...(filtres.action ? { action: filtres.action } : {}), + ...(filtres.utilisateurId !== undefined ? { utilisateurId: filtres.utilisateurId } : {}), + ...(filtres.materielId !== undefined ? { materielId: filtres.materielId } : {}), + ...(filtres.empruntId !== undefined ? { empruntId: filtres.empruntId } : {}), + ...(filtres.dateDebut || filtres.dateFin + ? { + dateAction: { + ...(filtres.dateDebut ? { gte: filtres.dateDebut } : {}), + ...(filtres.dateFin ? { lte: filtres.dateFin } : {}), + }, + } + : {}), + }, + include: { + utilisateur: true, + materiel: true, + emprunt: true, + sallePret: true, + posteEmprunt: true, + }, + orderBy: { dateAction: 'desc' }, + }); +} diff --git a/eme-backend/src/routes/responsable.routes.ts b/eme-backend/src/routes/responsable.routes.ts index 94fe35a..2a31e3b 100644 --- a/eme-backend/src/routes/responsable.routes.ts +++ b/eme-backend/src/routes/responsable.routes.ts @@ -3,6 +3,8 @@ import { getAnomalies, getDashboard, getEmprunts, + getHistorique, + getHistoriqueCsv, getMateriels, getNotifications, patchNotificationLue, @@ -18,3 +20,5 @@ responsableRoutes.get('/anomalies', getAnomalies); responsableRoutes.get('/notifications', getNotifications); responsableRoutes.patch('/notifications/lu-toutes', patchNotificationsLues); responsableRoutes.patch('/notifications/:id/lu', patchNotificationLue); +responsableRoutes.get('/historique', getHistorique); +responsableRoutes.get('/historique/export.csv', getHistoriqueCsv); diff --git a/eme-backend/src/services/responsable.service.ts b/eme-backend/src/services/responsable.service.ts index d6a1bbe..d52ba20 100644 --- a/eme-backend/src/services/responsable.service.ts +++ b/eme-backend/src/services/responsable.service.ts @@ -7,6 +7,7 @@ import { findActiviteRecente, findAnomaliesResponsable, findEmpruntsResponsable, + findHistoriqueResponsable, findMaterielsResponsable, findNotificationsResponsable, marquerNotificationLue, @@ -15,6 +16,7 @@ import { ActiviteResponsable, AnomalieResponsable, EmpruntResponsable, + HistoriqueResponsable, MaterielResponsable, NotificationResponsable, } from '../repositories/responsable.repository'; @@ -54,6 +56,15 @@ export interface ListerNotificationsResponsableFiltres { lu?: boolean; } +export interface ListerHistoriqueResponsableFiltres { + action?: string; + utilisateurId?: number; + materielId?: number; + empruntId?: number; + dateDebut?: Date; + dateFin?: Date; +} + function verifierResponsable(roleCode: string): void { if (roleCode !== 'RESPONSABLE') { throw new AppError(403, 'Acces reserve au responsable materiel'); @@ -142,6 +153,38 @@ export function parseNotificationId(value: unknown): number { return parsed; } +export function parsePositiveIntQuery(value: unknown, champ: string): number | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== 'string') { + throw new AppError(400, `${champ} invalide`); + } + + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new AppError(400, `${champ} invalide`); + } + + return parsed; +} + +export function parseDateQuery(value: unknown, champ: string): Date | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== 'string') { + throw new AppError(400, `${champ} invalide`); + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new AppError(400, `${champ} invalide`); + } + + return parsed; +} + export function parseLu(value: unknown): boolean | undefined { if (value === undefined) { return undefined; @@ -212,3 +255,12 @@ export function marquerToutesNotificationsResponsableLues( verifierResponsable(roleCode); return marquerNotificationsResponsableLues(utilisateurId); } + +export function listerHistoriqueResponsable( + roleCode: string, + campusId: number, + filtres: ListerHistoriqueResponsableFiltres, +): Promise { + verifierResponsable(roleCode); + return findHistoriqueResponsable(campusId, filtres); +} diff --git a/review.md b/review.md index 6e1e578..25b0fcf 100644 --- a/review.md +++ b/review.md @@ -365,6 +365,14 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y - Endpoint `PATCH /api/responsable/notifications/lu-toutes` ajouté pour marquer toutes les notifications non lues du responsable connecté comme lues. - Réponse enrichie avec l'anomalie liée et le matériel concerné lorsque la notification pointe vers une anomalie. +### Étape 39 — API responsable : historique et export +- Branche dédiée `feat/responsable-historique-api` créée après merge des notifications responsable dans `develop`. +- Endpoint `GET /api/responsable/historique` ajouté, protégé par rôle `RESPONSABLE`. +- Filtrage par campus du responsable appliqué systématiquement (RG27). +- Filtres optionnels ajoutés : `action`, `utilisateurId`, `materielId`, `empruntId`, `dateDebut`, `dateFin`. +- Endpoint `GET /api/responsable/historique/export.csv` ajouté avec les mêmes filtres. +- Export CSV généré côté API avec les colonnes principales : date, action, description, utilisateur, email, matériel, emprunt. + --- -*Dernière mise à jour : 2026-07-15 — Dashboard, consultations responsable et notifications ajoutés côté API.* +*Dernière mise à jour : 2026-07-16 — Bloc API responsable complété côté consultation, notifications et historique/export.*