diff --git a/backend/server.ts b/backend/server.ts
index f6b425b..d691fef 100644
--- a/backend/server.ts
+++ b/backend/server.ts
@@ -62,7 +62,7 @@ async function startServer() {
app.use('/api/discord', authenticateUser, discordRoutes);
app.use('/api/tent', authenticateUser, tentRoutes);
app.use('/api/bus', authenticateUser, busRoutes);
- app.use("/api/uploads/imgnews", express.static(path.join(__dirname, "/uploads/imgnews")));
+ app.use("/api/uploads/news", express.static(path.join(__dirname, "/uploads/news")));
app.use("/api/uploads/foodmenu", express.static(path.join(__dirname, "/uploads/foodmenu")));
app.use("/api/uploads/plannings", express.static(path.join(__dirname, "/uploads/plannings")));
app.use("/api/exports/bus", express.static(path.join(__dirname, "/exports/bus")));
diff --git a/backend/src/controllers/im_export.controller.ts b/backend/src/controllers/im_export.controller.ts
index 3e5978f..60ae264 100644
--- a/backend/src/controllers/im_export.controller.ts
+++ b/backend/src/controllers/im_export.controller.ts
@@ -8,6 +8,7 @@ import * as team_service from "../services/team.service";
import * as user_service from '../services/user.service';
import { Error, Ok } from "../utils/responses";
import { spreadsheet_id } from "../utils/secret";
+import { getLatestUploadedDocument, isSafeUploadSegment, removeUploadedDocuments, toUploadedDocumentStatus } from "../utils/uploadDocuments";
export const exportAllDataToSheets = async (req: Request, res: Response) => {
try {
@@ -108,6 +109,7 @@ export const updateFoodMenu = async (req: Request, res: Response) => {
const file = req.file;
try {
+
// Supprimer l'ancien Menu si un nouveau est uploadé
if (file) {
const targetDir = path.join(__dirname, "../../foodmenu");
@@ -116,6 +118,7 @@ export const updateFoodMenu = async (req: Request, res: Response) => {
fs.rmSync(targetDir, { recursive: true, force: true });
fs.mkdirSync(targetDir);
}
+
}
Ok(res, { msg: "Menu mis à jour avec succès" });
@@ -127,19 +130,7 @@ export const updateFoodMenu = async (req: Request, res: Response) => {
};
export const updatePlannings = async (req: Request, res: Response) => {
- const file = req.file;
-
try {
- // Supprimer l'ancien Planning si un nouveau est uploadé
- if (file) {
- const targetDir = path.join(__dirname, "../../plannings");
-
- if (fs.existsSync(targetDir)) {
- fs.rmSync(targetDir, { recursive: true, force: true });
- fs.mkdirSync(targetDir);
- }
- }
-
Ok(res, { msg: "Planning mis à jour avec succès" });
return;
} catch (err) {
@@ -157,3 +148,56 @@ export const exportUsersCSV = async (req: Request, res: Response) => {
Error(res, { msg: "Erreur lors de l'export CSV" });
}
};
+
+export const getUploadedDocumentStatus = async (req: Request, res: Response) => {
+ const { category, item } = req.params;
+
+ if (!isSafeUploadSegment(category) || !isSafeUploadSegment(item)) {
+ Error(res, { msg: "Paramètres invalides" });
+ return;
+ }
+
+ try {
+ const latestDocument = await getLatestUploadedDocument(category, item);
+
+ Ok(res, {
+ data: toUploadedDocumentStatus(category, latestDocument),
+ });
+ } catch (err: any) {
+ if (err?.code === "ENOENT") {
+ Ok(res, {
+ data: toUploadedDocumentStatus(category, null),
+ });
+ return;
+ }
+
+ console.error(err);
+ Error(res, { msg: "Erreur lors de la vérification du document" });
+ }
+};
+
+export const deleteDocument = async (req: Request, res: Response) => {
+ const { category, item } = req.params;
+
+ if (!isSafeUploadSegment(category) || !isSafeUploadSegment(item)) {
+ Error(res, { msg: "Paramètres invalides" });
+ return;
+ }
+
+ try {
+ const deletedCount = await removeUploadedDocuments(category, item);
+
+ if (deletedCount === 0) {
+ return Ok(res, { msg: "Aucun document à supprimer" });
+ }
+
+ Ok(res, {});
+ } catch (err: any) {
+ if (err?.code === "ENOENT") {
+ Ok(res, {});
+ return;
+ }
+
+ Error(res, { msg: "Erreur lors de la vérification du document" });
+ }
+};
diff --git a/backend/src/controllers/news.controller.ts b/backend/src/controllers/news.controller.ts
index a910932..0b97ac2 100644
--- a/backend/src/controllers/news.controller.ts
+++ b/backend/src/controllers/news.controller.ts
@@ -7,19 +7,72 @@ import * as user_service from '../services/user.service';
import * as template from "../utils/emailtemplates";
import { Error, Ok } from "../utils/responses";
+const toStoredUploadPath = (imageUrl: string) => {
+ if (!imageUrl) {
+ return null;
+ }
+
+ let normalized = imageUrl.trim();
+ if (!normalized) {
+ return null;
+ }
+
+ // Accept absolute URLs and keep only the pathname part.
+ if (normalized.startsWith("http://") || normalized.startsWith("https://")) {
+ try {
+ normalized = new URL(normalized).pathname;
+ } catch {
+ return null;
+ }
+ }
+
+ if (normalized.startsWith("/api/")) {
+ normalized = normalized.slice(4);
+ }
+
+ if (!normalized.startsWith("/uploads/")) {
+ return null;
+ }
+
+ return normalized;
+};
+
+const resolveStoredImagePath = (imageUrl: string) => {
+ const storedPath = toStoredUploadPath(imageUrl);
+ if (!storedPath) {
+ return null;
+ }
+
+ return path.resolve(process.cwd(), storedPath.replace(/^\//, ""));
+};
+
+const deleteImageIfExists = (imageUrl: string) => {
+ const imagePath = resolveStoredImagePath(imageUrl);
+ if (!imagePath) {
+ return;
+ }
+
+ if (fs.existsSync(imagePath)) {
+ fs.unlinkSync(imagePath);
+ }
+};
+
export const createNews = async (req: Request, res: Response) => {
- const { title, description, type, published, target } = req.body;
+ const { title, description, type, published, target, image_url } = req.body;
const file = req.file;
try {
- const image_url = file ? `/api/uploads/imgnews/${file.filename}` : undefined;
+ const resolvedImageUrl = file
+ ? `/uploads/news/${file.filename}`
+ : image_url;
+
const news = await news_service.createNews(
title,
description,
type,
- published,
+ published === true || published === "true",
target,
- image_url);
+ resolvedImageUrl);
Ok(res, { msg: "Actu créée avec succès", data: news });
} catch (err) {
console.error(err);
@@ -103,41 +156,53 @@ export const deleteNews = async (req: Request, res: Response) => {
try {
const existing = await news_service.getNewsById(Number(newsId));
if (existing?.image_url) {
- const imagePath = path.join(__dirname, "../../", existing.image_url);
- if (fs.existsSync(imagePath)) {
- fs.unlinkSync(imagePath);
- }
+ deleteImageIfExists(existing.image_url);
}
+
await news_service.deleteNews(Number(newsId));
Ok(res, { msg: "Actus supprimée avec succès !" });
- ;
- } catch {
+ } catch (err) {
+ console.error(err);
Error(res, { msg: "Erreur lors de la suppression de l'actus" });
}
};
export const updateNews = async (req: Request, res: Response) => {
- const { id, title, description, type, target } = req.body;
+ const { id, title, description, type, target, image_url } = req.body;
const file = req.file;
- const image_url = file ? `/api/uploads/imgnews/${file.filename}` : undefined;
+ const hasImageUrlField = Object.prototype.hasOwnProperty.call(req.body, "image_url");
+ const resolvedImageUrl = file
+ ? `/uploads/news/${file.filename}`
+ : hasImageUrlField
+ ? (image_url ?? null)
+ : undefined;
try {
const existing = await news_service.getNewsById(Number(id));
if (!existing) {
Error(res, { msg: "Actu introuvable" });
+ return;
}
- // Supprimer l'ancienne image si une nouvelle est uploadée
- if (file && existing.image_url) {
- const oldPath = path.join(__dirname, "../../", existing.image_url);
- if (fs.existsSync(oldPath)) {
- fs.unlinkSync(oldPath);
- }
+ const shouldReplaceImage = typeof resolvedImageUrl === "string";
+ const shouldRemoveImage = resolvedImageUrl === null;
+
+ // Supprimer l'ancienne image si elle est remplacée ou explicitement supprimée.
+ if (existing.image_url && ((shouldReplaceImage && existing.image_url !== resolvedImageUrl) || shouldRemoveImage)) {
+ deleteImageIfExists(existing.image_url);
}
- const updates: any = { title, description, type, target };
- if (image_url) updates.image_url = image_url;
+ const updates: {
+ title: string;
+ description: string;
+ type: string;
+ target: string;
+ image_url?: string | null;
+ } = { title, description, type, target };
+ if (resolvedImageUrl !== undefined) {
+ updates.image_url = resolvedImageUrl;
+ }
const updated = await news_service.updateNews(Number(id), updates);
diff --git a/backend/src/middlewares/multer.middleware.ts b/backend/src/middlewares/multer.middleware.ts
index 1ce4a37..6f4baac 100644
--- a/backend/src/middlewares/multer.middleware.ts
+++ b/backend/src/middlewares/multer.middleware.ts
@@ -3,13 +3,29 @@ import fs from "fs/promises";
import multer from "multer";
import path from "path";
import { Error } from "../utils/responses";
+import { isSafeUploadSegment, removeUploadedDocuments } from "../utils/uploadDocuments";
-export const createUploadMiddleware = (
- relativeUploadDir: string,
- modifiedName: boolean = true
-) => {
- const uploadPath = path.resolve(process.cwd(), relativeUploadDir);
+export enum MIMEType {
+ PDF = "application/pdf",
+ PNG = "image/png",
+ JPEG = "image/jpeg",
+}
+const acceptedMIMETypesByItem: Record> = {
+ foodmenu: {
+ menu: [MIMEType.PDF],
+ },
+ news: {},
+ plannings: {
+ tc: [MIMEType.PDF],
+ bacheloria: [MIMEType.PDF],
+ fise: [MIMEType.PDF],
+ fisea: [MIMEType.PDF],
+ master: [MIMEType.PDF],
+ },
+};
+
+export const createUploadMiddleware = () => {
// On stocke d'abord en mémoire pour vérifier le type réel
const storage = multer.memoryStorage();
@@ -24,46 +40,73 @@ export const createUploadMiddleware = (
const verifyAndSave = async (
req: Request,
res: Response,
- next: NextFunction
+ next: NextFunction,
) => {
try {
if (!req.file) return Error(res, { msg: "Aucun fichier reçu" });
+
+ const category = req.params.category;
+ const item = req.params.item;
+
+ if (!category || !item) {
+ return Error(res, { msg: "Catégorie ou item manquant" });
+ }
+
+ if (!isSafeUploadSegment(category) || !isSafeUploadSegment(item)) {
+ return Error(res, { msg: "Paramètres invalides" });
+ }
+
const { originalname, buffer } = req.file;
// Vérif du vrai type
const { fileTypeFromBuffer } = await import("file-type");
const detected = await fileTypeFromBuffer(buffer);
- console.log(
- `[UPLOAD] Type détecté: ${detected?.mime || "inconnu"}`
- );
- const isImage = detected?.mime?.startsWith("image/");
- const isPDF = detected?.mime === "application/pdf";
+ const detectedMime = detected?.mime as MIMEType | undefined;
+
+ const acceptedTypes = category === "news" ? [MIMEType.PNG, MIMEType.JPEG] : acceptedMIMETypesByItem[category]?.[item];
+
- if (!isImage && !isPDF) {
- Error(res, { msg: "Seules les images et les PDF sont autorisés" });
+
+ if (!acceptedTypes) {
+ return Error(res, { msg: "Catégorie ou item inconnu" });
+ }
+
+ const isAccepted = !!detectedMime && acceptedTypes.includes(detectedMime);
+
+ if (!isAccepted) {
+ Error(res, { msg: "Type de fichier non autorisé" });
return;
}
+ const uploadPath = path.resolve(process.cwd(), "uploads", category);
+
// Création dossier si nécessaire
await fs.mkdir(uploadPath, { recursive: true });
+ if (category === "news") {
+ if (!/^\d+$/.test(item)) {
+ return Error(res, { msg: "Le nom du fichier doit être composé uniquement de chiffres." });
+ }
+
+ const currentEntries = await fs.readdir(uploadPath);
+ if (currentEntries.length >= 100) {
+ return Error(res, { msg: "Le nombre maximal d'images d'actualités a été atteind." });
+ }
+ }
+
+ // Supprime tout document existant avec le même basename, quelle que soit l'extension.
+ await removeUploadedDocuments(category, item);
+
const ext = path.extname(originalname);
- const baseName = path.basename(originalname, ext);
- const timestamp = Date.now();
- const finalName = modifiedName
- ? `${baseName}-${timestamp}${ext}`
- : originalname;
+ const finalName = `${item}${ext}`;
const finalPath = path.join(uploadPath, finalName);
// Sauvegarde du fichier sur disque
await fs.writeFile(finalPath, buffer);
- // On rajoute le chemin pour les middlewares suivants
- (req as any).savedFilePath = finalPath;
-
next();
} catch (err) {
next(err);
diff --git a/backend/src/routes/im_export.routes.ts b/backend/src/routes/im_export.routes.ts
index 8d73cbe..38077cd 100644
--- a/backend/src/routes/im_export.routes.ts
+++ b/backend/src/routes/im_export.routes.ts
@@ -3,13 +3,18 @@ import * as imexportController from '../controllers/im_export.controller';
import { createUploadMiddleware } from "../middlewares/multer.middleware";
import { checkRole } from '../middlewares/user.middleware';
-const uploadFoodMenu = createUploadMiddleware("uploads/foodmenu/", false);
-const uploadPlannings = createUploadMiddleware("uploads/plannings/", false);
+const uploadMiddleware = createUploadMiddleware();
const imexportRouter = express.Router();
-imexportRouter.post('/admin/foodimport', checkRole("Admin", []), uploadFoodMenu.multerUpload.single("foodFile"), uploadFoodMenu.verifyAndSave, imexportController.updateFoodMenu);
-imexportRouter.post('/admin/plannings', checkRole("Admin", []), uploadPlannings.multerUpload.single("planningFile"), uploadPlannings.verifyAndSave, imexportController.updatePlannings);
+imexportRouter.post(
+ '/admin/import/:category/:item', checkRole("Admin", []),
+ uploadMiddleware.multerUpload.single("file"),
+ uploadMiddleware.verifyAndSave,
+ imexportController.updateFoodMenu);
+
imexportRouter.post('/admin/exportgsheet', checkRole("Admin", []), imexportController.exportAllDataToSheets);
imexportRouter.get('/admin/exportbus', checkRole("Admin", []), imexportController.exportUsersCSV);
+imexportRouter.get('/admin/document/:category/:item', checkRole("Admin", []), imexportController.getUploadedDocumentStatus);
+imexportRouter.delete('/admin/document/:category/:item', checkRole("Admin", []), imexportController.deleteDocument);
export default imexportRouter;
diff --git a/backend/src/routes/news.routes.ts b/backend/src/routes/news.routes.ts
index cc21fe2..7b74e4b 100644
--- a/backend/src/routes/news.routes.ts
+++ b/backend/src/routes/news.routes.ts
@@ -1,14 +1,12 @@
import express from "express";
import * as newsController from "../controllers/news.controller";
-import { createUploadMiddleware } from "../middlewares/multer.middleware";
import { checkRole } from "../middlewares/user.middleware";
-const uploadImgNews = createUploadMiddleware("uploads/imgnews/", true);
const newsRouter = express.Router();
//Admin routes
-newsRouter.post("/admin/createnews", checkRole("Admin", ["Communication"]), uploadImgNews.multerUpload.single("file"), uploadImgNews.verifyAndSave, newsController.createNews);
-newsRouter.post("/admin/updatenews", checkRole("Admin", ["Communication"]), uploadImgNews.multerUpload.single("file"), uploadImgNews.verifyAndSave, newsController.updateNews);
+newsRouter.post("/admin/createnews", checkRole("Admin", ["Communication"]), newsController.createNews);
+newsRouter.post("/admin/updatenews", checkRole("Admin", ["Communication"]), newsController.updateNews);
newsRouter.get("/admin/all", checkRole("Admin", ["Communication"]), newsController.listAllNews);
newsRouter.post("/admin/publish", checkRole("Admin", ["Communication"]), newsController.publishNews);
newsRouter.delete("/admin/deletenews", checkRole("Admin", ["Communication"]), newsController.deleteNews);
diff --git a/backend/src/utils/responses.ts b/backend/src/utils/responses.ts
index 1e6931b..acc1ff8 100644
--- a/backend/src/utils/responses.ts
+++ b/backend/src/utils/responses.ts
@@ -28,6 +28,12 @@ export const Unauthorized = (res: Response, details: { data?: any, msg?: string
res.status(Code.UNAUTHORIZED).json(new HttpResponse(Code.UNAUTHORIZED, msg, details.data));
};
+export const Forbidden = (res: Response, details: { data?: any, msg?: string }) => {
+ const msg = details.msg || 'Forbidden';
+ res.status(Code.FORBIDDEN).json(new HttpResponse(Code.FORBIDDEN, msg, details.data));
+};
+
+
export const Conflict = (res: Response, details: { data?: any, msg?: string }) => {
const msg = details.msg || "Conflict";
res.status(Code.CONFLICT).json(new HttpResponse(Code.CONFLICT, msg, details.data));
@@ -40,11 +46,12 @@ export const Teapot = (res: Response, details: { data?: any, msg?: string }) =>
export enum Code {
OK = 200,
+ CREATED = 201,
ACCEPTED = 202,
- NOT_FOUND = 404,
BAD_REQUEST = 400,
UNAUTHORIZED = 401,
- CREATED = 201,
+ FORBIDDEN = 403,
+ NOT_FOUND = 404,
CONFLICT = 409,
IM_A_TEAPOT = 418,
ISE = 500
@@ -76,6 +83,8 @@ export class HttpResponse {
return 'INTERNAL_SERVER_ERROR';
case Code.UNAUTHORIZED:
return 'UNAUTHORIZED';
+ case Code.FORBIDDEN:
+ return 'FORBIDDEN';
case Code.CONFLICT:
return 'CONFLICT';
case Code.IM_A_TEAPOT:
diff --git a/backend/src/utils/uploadDocuments.ts b/backend/src/utils/uploadDocuments.ts
new file mode 100644
index 0000000..91d61dd
--- /dev/null
+++ b/backend/src/utils/uploadDocuments.ts
@@ -0,0 +1,84 @@
+import fs from "fs/promises";
+import path from "path";
+
+export type UploadedDocument = {
+ name: string;
+ fullPath: string;
+ extension: string | null;
+ mtimeMs: number;
+};
+
+export type UploadedDocumentStatus = {
+ exists: boolean;
+ extension: string | null;
+ fileName: string | null;
+ relativePath: string | null;
+};
+
+export const isSafeUploadSegment = (value: string) => /^[a-zA-Z0-9_-]+$/.test(value);
+
+export const getUploadDirectory = (category: string) => path.resolve(process.cwd(), "uploads", category);
+
+export const findUploadedDocuments = async (category: string, item: string): Promise => {
+ const uploadsDir = getUploadDirectory(category);
+
+ const entries = await fs.readdir(uploadsDir);
+ const candidates = entries.filter((name) => path.parse(name).name === item);
+
+ return Promise.all(
+ candidates.map(async (name) => {
+ const fullPath = path.join(uploadsDir, name);
+ const stats = await fs.stat(fullPath);
+
+ return {
+ name,
+ fullPath,
+ extension: path.extname(name).replace(/^\./, "").toLowerCase() || null,
+ mtimeMs: stats.mtimeMs,
+ };
+ })
+ );
+};
+
+export const getLatestUploadedDocument = async (
+ category: string,
+ item: string,
+): Promise => {
+ const documents = await findUploadedDocuments(category, item);
+
+ if (documents.length === 0) {
+ return null;
+ }
+
+ documents.sort((a, b) => b.mtimeMs - a.mtimeMs);
+ return documents[0];
+};
+
+export const removeUploadedDocuments = async (category: string, item: string): Promise => {
+ const documents = await findUploadedDocuments(category, item);
+
+ await Promise.all(documents.map((document) => fs.unlink(document.fullPath)));
+
+ return documents.length;
+};
+
+export const toUploadedDocumentStatus = (
+ category: string,
+ document: UploadedDocument | null,
+): UploadedDocumentStatus => {
+ if (!document) {
+ return {
+ exists: false,
+ extension: null,
+ fileName: null,
+ relativePath: null,
+ };
+ }
+
+ return {
+ exists: true,
+ extension: document.extension,
+ fileName: document.name,
+ relativePath: `/uploads/${category}/${document.name}`,
+ };
+};
diff --git a/frontend/src/components/Admin/adminExportImport.tsx b/frontend/src/components/Admin/adminExportImport.tsx
index 883b8ab..009086b 100644
--- a/frontend/src/components/Admin/adminExportImport.tsx
+++ b/frontend/src/components/Admin/adminExportImport.tsx
@@ -1,9 +1,10 @@
-import { FileText } from "lucide-react";
-import { type ChangeEvent, useState } from "react";
+import { useState } from "react";
-import { exportBus, exportDb, importFoodMenu, importPlannings } from "../../services/requests/im_export.service";
+import { exportBus, exportDb } from "../../services/requests/im_export.service";
import { Button } from "../ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
+import { AdminFileImport } from "./adminFileImport";
+
export const AdminExportConnect = () => {
const [loading, setLoading] = useState<{ db: boolean; bus: boolean }>({
@@ -101,106 +102,16 @@ export const AdminExportConnect = () => {
export const AdminImportFoodMenu = () => {
- const [menu, setMenu] = useState(null);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
- const [message, setMessage] = useState("");
-
- const handleFileChange = (e: ChangeEvent) => {
- setError(null);
- setMessage("");
- if (e.target.files && e.target.files.length > 0) {
- const selected = e.target.files[0];
- if (selected.type !== "application/pdf") {
- setError("Seuls les fichiers PDF sont autorisés");
- setMenu(null);
- } else {
- setMenu(selected);
- }
- }
- };
-
- const handleImport = async () => {
- if (!menu) {
- setError("Veuillez sélectionner un fichier PDF avant d'importer.");
- return;
- }
-
- setLoading(true);
- setError(null);
- setMessage("");
-
- try {
- const formData = new FormData();
- formData.append("foodFile", menu);
-
- const response = await importFoodMenu(formData);
- setMessage(response.message || "Importation réussie !");
- } catch (err) {
- console.error("Erreur lors de l'importation du menu", err);
- setError("Une erreur est survenue pendant l'importation.");
- } finally {
- setLoading(false);
- }
- };
return (
- Importer le menu au format PDF
+ Importer le menu
-
- {/* Rappel des règles de nommage */}
-
- ⚠️ Le fichier doit être nommé FoodMenu.pdf
-
-
-
- {/* Input fichier masqué */}
-
-
-
-
- {/* Affiche le nom du fichier sélectionné */}
- {menu && (
-
-
- {menu.name}
-
- )}
-
- {/* Bouton importer */}
-
-
-
- {error && (
- {error}
- )}
- {message && (
-
- {message}
-
- )}
+
);
@@ -208,154 +119,19 @@ export const AdminImportFoodMenu = () => {
export const AdminImportPlannings = () => {
- const [planning, setPlanning] = useState(null);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
- const [message, setMessage] = useState("");
- const [selectedPlanning, setSelectedPlanning] = useState("");
-
- const handleFileChange = (e: ChangeEvent) => {
- setError(null);
- setMessage("");
- if (e.target.files && e.target.files.length > 0) {
- const selected = e.target.files[0];
- if (selected.type !== "application/pdf") {
- setError("Seuls les fichiers PDF sont autorisés");
- setPlanning(null);
- } else {
- setPlanning(selected);
- }
- }
- };
-
- const handleImport = async (planningName: string) => {
- if (!planning) {
- setError("Veuillez sélectionner un fichier PDF avant d'importer.");
- return;
- }
-
- setLoading(true);
- setError(null);
- setMessage("");
- setSelectedPlanning(planningName);
-
- try {
- const formData = new FormData();
- formData.append("planningFile", planning);
-
- const response = await importPlannings(formData);
- setMessage(response.message || `Importation réussie pour ${planningName} !`);
- } catch (err) {
- console.error("Erreur lors de l'importation du planning", err);
- setError("Une erreur est survenue pendant l'importation.");
- } finally {
- setLoading(false);
- }
- };
-
return (
- Importer les plannings au format PDF
+ Importer les plannings
-
-
- {/* Rappel des règles de nommage */}
-
- ⚠️ Le fichier doit être nommé en minuscules, sans accents, au format
- filiere.pdf (ex: tc.pdf, bachelor.pdf)
-
-
-
- {/* Input fichier masqué */}
-
-
-
-
- {/* Affiche le nom du fichier sélectionné */}
- {planning && (
-
-
- {planning.name}
-
- )}
-
- {/* Différents boutons d'import */}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Messages */}
- {error && (
- {error}
- )}
- {message && (
-
- {message}
-
- )}
+
+
+
+
+
+
);
diff --git a/frontend/src/components/Admin/adminFileImport.tsx b/frontend/src/components/Admin/adminFileImport.tsx
new file mode 100644
index 0000000..8274db5
--- /dev/null
+++ b/frontend/src/components/Admin/adminFileImport.tsx
@@ -0,0 +1,331 @@
+import { ExternalLink, FilePenLine, FilePlus, FileText, Trash } from "lucide-react";
+import { type ChangeEvent, useCallback, useEffect, useState } from "react";
+import Swal from "sweetalert2";
+
+import { MIMEType } from "../../interfaces/import.interface";
+import { checkIfExistingDocument, deleteFile, importFile } from "../../services/requests/im_export.service";
+import { Card, CardContent } from "../ui/card";
+
+type AdminFileImportProps = {
+ category: string;
+ item?: string;
+ acceptedTypes?: MIMEType[];
+ title: string;
+ draft?: boolean;
+ draftInitialUrl?: string | null;
+ onDraftFileChange?: (file: File | null) => void;
+ onDraftDelete?: () => void;
+ onDraftSubmitReady?: (submit: (itemOverride?: string) => Promise) => void;
+};
+
+type ExistingFile = {
+ exists: boolean;
+ extension: string | null;
+ fileName: string | null;
+ relativePath: string | null;
+};
+
+export const AdminFileImport = (
+ {
+ category,
+ item,
+ acceptedTypes = [MIMEType.PDF],
+ title,
+ draft = false,
+ draftInitialUrl,
+ onDraftFileChange,
+ onDraftDelete,
+ onDraftSubmitReady,
+ }: AdminFileImportProps,
+) => {
+ const [existingFile, setExistingFile] = useState(null);
+ const [fileURL, setFileURL] = useState(null);
+ const [draftFile, setDraftFile] = useState(null);
+ const [draftPreviewURL, setDraftPreviewURL] = useState(null);
+
+ const resolvePublicFileUrl = useCallback((url: string) => {
+ if (url.startsWith("http://") || url.startsWith("https://")) {
+ return url;
+ }
+
+ const baseUrl = import.meta.env.VITE_API_URL as string;
+ if (url.startsWith("/api/") && baseUrl.endsWith("/api")) {
+ return `${baseUrl}${url.slice(4)}`;
+ }
+
+ return `${baseUrl}${url}`;
+ }, []);
+
+ const getExtensionFromPath = useCallback((url: string) => {
+ const cleanUrl = url.split("?")[0].split("#")[0];
+ const fileName = cleanUrl.split("/").pop() ?? "";
+ if (!fileName.includes(".")) {
+ return null;
+ }
+
+ return fileName.split(".").pop()?.toLowerCase() ?? null;
+ }, []);
+
+ const checkFileStatus = useCallback(async () => {
+ if (draft || !item) {
+ return;
+ }
+
+ try {
+ const status = await checkIfExistingDocument(category, item);
+ setExistingFile(status);
+
+ if (status.exists && status.relativePath) {
+ setFileURL(resolvePublicFileUrl(status.relativePath));
+ } else {
+ setFileURL(null);
+ }
+ } catch {
+ setExistingFile(null);
+ setFileURL(null);
+ }
+ }, [category, draft, item, resolvePublicFileUrl]);
+
+ useEffect(() => {
+ if (!draft || draftFile || draftPreviewURL) {
+ return;
+ }
+
+ if (!draftInitialUrl) {
+ setExistingFile(null);
+ setFileURL(null);
+ return;
+ }
+
+ const resolvedUrl = resolvePublicFileUrl(draftInitialUrl);
+ const extension = getExtensionFromPath(draftInitialUrl);
+ const fileName = draftInitialUrl.split("?")[0].split("#")[0].split("/").pop() ?? null;
+
+ setExistingFile({
+ exists: true,
+ extension,
+ fileName,
+ relativePath: null,
+ });
+ setFileURL(resolvedUrl);
+ }, [draft, draftFile, draftInitialUrl, draftPreviewURL, getExtensionFromPath, resolvePublicFileUrl]);
+
+ useEffect(() => {
+ if (!draft) {
+ void checkFileStatus();
+ }
+ }, [checkFileStatus, draft]);
+
+ const submitDraftUpload = useCallback(async (itemOverride?: string): Promise => {
+ if (!draft || !draftFile) {
+ return null;
+ }
+
+ const targetItem = itemOverride ?? item;
+
+ if (!targetItem) {
+ throw new Error("L'item est requis pour la soumission du brouillon.");
+ }
+
+ const formData = new FormData();
+ formData.append("file", draftFile);
+
+ await importFile(formData, category, targetItem);
+
+ const status = await checkIfExistingDocument(category, targetItem);
+ setExistingFile(status);
+ if (status.exists && status.relativePath) {
+ setFileURL(resolvePublicFileUrl(status.relativePath));
+ }
+
+ setDraftFile(null);
+ if (draftPreviewURL) {
+ URL.revokeObjectURL(draftPreviewURL);
+ setDraftPreviewURL(null);
+ }
+ onDraftFileChange?.(null);
+
+ return status.relativePath ?? null;
+ }, [category, draft, draftFile, draftPreviewURL, item, onDraftFileChange, resolvePublicFileUrl]);
+
+ useEffect(() => {
+ if (draft && onDraftSubmitReady) {
+ onDraftSubmitReady(submitDraftUpload);
+ }
+ }, [draft, onDraftSubmitReady, submitDraftUpload]);
+
+ const inputId = `${category}-${item ?? "draft"}-fileInput`;
+
+ const handleFileChange = async (e: ChangeEvent) => {
+ if (!e.target.files || e.target.files.length === 0) {
+ Swal.fire({
+ icon: "error",
+ title: "Aucun fichier sélectionné"
+ });
+ return;
+ }
+
+ const selected = e.target.files[0];
+ if (!selected || !acceptedTypes.includes(selected.type as MIMEType)) {
+ Swal.fire({
+ icon: "error",
+ title: "Format incompatible",
+ text: "Le fichier sélectionné n'est pas autorisé pour ce champ."
+ });
+ return;
+ }
+
+ if (draft) {
+ const extension = selected.name.includes(".")
+ ? selected.name.split(".").pop()?.toLowerCase() ?? null
+ : null;
+
+ if (draftPreviewURL) {
+ URL.revokeObjectURL(draftPreviewURL);
+ }
+ const localPreviewURL = URL.createObjectURL(selected);
+
+ setDraftFile(selected);
+ setExistingFile({
+ exists: true,
+ extension,
+ fileName: selected.name,
+ relativePath: null,
+ });
+ setDraftPreviewURL(localPreviewURL);
+ setFileURL(localPreviewURL);
+ onDraftFileChange?.(selected);
+ return;
+ }
+
+ if (!item) {
+ Swal.fire({
+ icon: "error",
+ title: "Configuration invalide",
+ text: "L'item est requis pour l'upload direct.",
+ });
+ return;
+ }
+
+ try {
+ const formData = new FormData();
+ formData.append("file", selected);
+
+ const response = await importFile(formData, category, item);
+ await checkFileStatus();
+ await Swal.fire("✅ Import réussi", response.message, "success");
+ } catch (err) {
+ console.error("Erreur lors de l'importation du menu", err);
+ Swal.fire({
+ icon: "error",
+ title: "Erreur",
+ text: "Erreur lors de l'importation du menu.",
+ });
+ }
+ };
+
+ const handleDelete = async () => {
+ if (draft) {
+ if (draftPreviewURL) {
+ URL.revokeObjectURL(draftPreviewURL);
+ }
+ setDraftFile(null);
+ setDraftPreviewURL(null);
+ setExistingFile(null);
+ setFileURL(null);
+ onDraftFileChange?.(null);
+ onDraftDelete?.();
+ return;
+ }
+
+ if (!item) {
+ return;
+ }
+
+ const confirm = await Swal.fire({
+ title: `Supprimer le document ${title} ?`,
+ text: "Cette action est irreversible.",
+ icon: "warning",
+ showCancelButton: true,
+ confirmButtonColor: "#2563eb",
+ cancelButtonColor: "#d33",
+ confirmButtonText: "🚍 Oui",
+ cancelButtonText: "Annuler",
+ });
+
+ if (confirm.isConfirmed) {
+ try {
+ const response = await deleteFile(category, item);
+ await checkFileStatus();
+ await Swal.fire("✅ Suppression réussie", response.message, "success");
+ } catch (err) {
+ console.error("Erreur lors de la suppression du document", err);
+ Swal.fire({
+ icon: "error",
+ title: "Erreur",
+ text: "Erreur lors de la suppression du document.",
+ });
+ }
+ }
+ };
+
+ return (
+
+
+
+
+
{title}
+
+
+ {existingFile?.exists ? (
+ <>
+
+
.{existingFile?.extension}
+ >
+ ) : (
+
+ )}
+
+
+
+
`${ext}`).join(",")}
+ onChange={handleFileChange}
+ className="hidden"
+ />
+
+
+
+ {(existingFile?.exists || !!draftPreviewURL) && (
+ <>
+ {(draftPreviewURL || fileURL) && (
+
+
+
+ )}
+
+
+ >
+ )}
+
+
+
+
+ );
+};
diff --git a/frontend/src/components/Admin/adminNews.tsx b/frontend/src/components/Admin/adminNews.tsx
index 4ce24f1..eab5435 100644
--- a/frontend/src/components/Admin/adminNews.tsx
+++ b/frontend/src/components/Admin/adminNews.tsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import Swal from "sweetalert2";
+import { MIMEType } from "../../interfaces/import.interface";
import { type News } from "../../interfaces/news.interface";
import {
createNews,
@@ -13,13 +14,14 @@ import { Button } from "../ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import { Input } from "../ui/input";
import { Textarea } from "../ui/textarea";
+import { AdminFileImport } from "./adminFileImport";
export const AdminNews = () => {
const [newsList, setNewsList] = useState([]);
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState(null);
- const [selectedFile, setSelectedFile] = useState(null);
- const [previewUrl, setPreviewUrl] = useState(null);
+ const [editingImageUrl, setEditingImageUrl] = useState(null);
+ const [submitDraftImage, setSubmitDraftImage] = useState<((itemOverride?: string) => Promise) | null>(null);
const [formData, setFormData] = useState({
title: "",
@@ -52,41 +54,78 @@ export const AdminNews = () => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
- const handleImageChange = (e: React.ChangeEvent) => {
- const file = e.target.files?.[0];
- if (file) {
- setSelectedFile(file);
- setPreviewUrl(URL.createObjectURL(file));
+ const getErrorMessage = (err: unknown, fallback: string) => {
+ if (typeof err === "object" && err !== null && "response" in err) {
+ const response = (err as { response?: { data?: { msg?: string } } }).response;
+ if (response?.data?.msg) {
+ return response.data.msg;
+ }
}
+
+ return fallback;
};
- const handleCreateOrUpdate = async () => {
- try {
- const formDataToSend = new FormData();
+ const resolveNewsImageUrl = (imageUrl: string) => {
+ if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
+ return imageUrl;
+ }
- if (selectedFile) {
- formDataToSend.append("file", selectedFile);
- }
+ const baseUrl = import.meta.env.VITE_API_URL as string;
+ if (imageUrl.startsWith("/api/") && baseUrl.endsWith("/api")) {
+ return `${baseUrl}${imageUrl.slice(4)}`;
+ }
+
+ return `${baseUrl}${imageUrl}`;
+ };
- formDataToSend.append("title", formData.title);
- formDataToSend.append("description", formData.description);
- formDataToSend.append("type", formData.type);
- formDataToSend.append("published", String(formData.published));
- formDataToSend.append("target", formData.target);
+ const handleCreateOrUpdate = async () => {
+ try {
+ const payload = {
+ title: formData.title,
+ description: formData.description,
+ type: formData.type,
+ published: formData.published,
+ target: formData.target,
+ };
let response;
if (editingId) {
- formDataToSend.append("id", String(editingId));
- response = await updateNews(formDataToSend);
+ const uploadedImageUrl = submitDraftImage
+ ? await submitDraftImage(String(editingId))
+ : null;
+
+ const image_url = uploadedImageUrl
+ ? uploadedImageUrl
+ : editingImageUrl === null
+ ? null
+ : undefined;
+
+ response = await updateNews({
+ ...payload,
+ id: String(editingId),
+ image_url,
+ });
} else {
- response = await createNews(formDataToSend);
+ response = await createNews(payload);
+
+ const createdId = response?.data?.id;
+ if (createdId && submitDraftImage) {
+ const image_url = await submitDraftImage(String(createdId));
+ if (image_url) {
+ await updateNews({
+ ...payload,
+ id: String(createdId),
+ image_url,
+ });
+ }
+ }
}
await Swal.fire("✅ Succès", response.message, "success");
resetForm();
fetchNews();
- } catch (err: any) {
- Swal.fire("❌ Erreur", err.response?.data?.msg || "Erreur lors de la sauvegarde", "error");
+ } catch (err: unknown) {
+ Swal.fire("❌ Erreur", getErrorMessage(err, "Erreur lors de la sauvegarde"), "error");
}
};
@@ -137,8 +176,8 @@ export const AdminNews = () => {
const response = await publishNews(news, sendEmail.isConfirmed);
await Swal.fire("✅ Publiée", response.message, "success");
fetchNews();
- } catch (err: any) {
- Swal.fire("❌ Erreur", err.response?.data?.msg || "Erreur lors de la publication", "error");
+ } catch (err: unknown) {
+ Swal.fire("❌ Erreur", getErrorMessage(err, "Erreur lors de la publication"), "error");
}
};
@@ -151,6 +190,7 @@ export const AdminNews = () => {
target: news.target,
});
setEditingId(news.id);
+ setEditingImageUrl(news.image_url ?? null);
window.scrollTo({ top: 0, behavior: "smooth" });
};
@@ -162,14 +202,10 @@ export const AdminNews = () => {
published: false,
target: "Tous",
});
- setSelectedFile(null);
- setPreviewUrl(null);
setEditingId(null);
+ setEditingImageUrl(null);
};
- const handleRemoveImage = () => {
- setPreviewUrl(null);
- };
return (
@@ -194,48 +230,19 @@ export const AdminNews = () => {
placeholder="Contenu de l'actu"
/>
- {/* Image upload amélioré */}
-
-
-
-
-
- {selectedFile && (
-
{selectedFile.name}
- )}
-
- {previewUrl && (
-
-
-

-
-
-
- )}
-
+ {
+ setEditingImageUrl(null);
+ setSubmitDraftImage(null);
+ }}
+ onDraftSubmitReady={(submit) => setSubmitDraftImage(() => submit)}
+ />
{news.image_url && (
diff --git a/frontend/src/components/Plannings/planningSection.tsx b/frontend/src/components/Plannings/planningSection.tsx
index 3ad598b..80de0b1 100644
--- a/frontend/src/components/Plannings/planningSection.tsx
+++ b/frontend/src/components/Plannings/planningSection.tsx
@@ -14,11 +14,11 @@ const plannings: Planning[] = [
},
{
name: "Planning Bachelor IA",
- url: `${import.meta.env.VITE_API_URL}/uploads/plannings/bachelor.pdf`,
+ url: `${import.meta.env.VITE_API_URL}/uploads/plannings/bachelor_ia.pdf`,
},
{
name: "Planning Branche (non-alternant)",
- url: `${import.meta.env.VITE_API_URL}/uploads/plannings/branche.pdf`,
+ url: `${import.meta.env.VITE_API_URL}/uploads/plannings/fise.pdf`,
},
{
name: "Planning Branche FISEA (alternants)",
diff --git a/frontend/src/components/WEI_SDI_Food/foodSection.tsx b/frontend/src/components/WEI_SDI_Food/foodSection.tsx
index 2d33ca5..44ee840 100644
--- a/frontend/src/components/WEI_SDI_Food/foodSection.tsx
+++ b/frontend/src/components/WEI_SDI_Food/foodSection.tsx
@@ -9,7 +9,7 @@ export const FoodSection = () => {
const [isMenuAvailable, setIsMenuAvailable] = useState(false);
const permission = getPermission();
- const menuUrl = `${import.meta.env.VITE_API_URL}/uploads/foodmenu/FoodMenu.pdf`;
+ const menuUrl = `${import.meta.env.VITE_API_URL}/uploads/foodmenu/menu.pdf`;
useEffect(() => {
const script = document.createElement("script");
diff --git a/frontend/src/components/news/newsSection.tsx b/frontend/src/components/news/newsSection.tsx
index 40d4434..ea31b0f 100644
--- a/frontend/src/components/news/newsSection.tsx
+++ b/frontend/src/components/news/newsSection.tsx
@@ -70,7 +70,7 @@ export const MyNews = () => {
>
{news.image_url && (
diff --git a/frontend/src/interfaces/import.interface.ts b/frontend/src/interfaces/import.interface.ts
new file mode 100644
index 0000000..d77f71a
--- /dev/null
+++ b/frontend/src/interfaces/import.interface.ts
@@ -0,0 +1,5 @@
+export enum MIMEType {
+ PDF = "application/pdf",
+ PNG = "image/png",
+ JPEG = "image/jpeg",
+}
diff --git a/frontend/src/services/requests/im_export.service.ts b/frontend/src/services/requests/im_export.service.ts
index f4a15c0..dd0c4da 100644
--- a/frontend/src/services/requests/im_export.service.ts
+++ b/frontend/src/services/requests/im_export.service.ts
@@ -1,5 +1,12 @@
import api from "../api";
+type ExistingDocumentStatus = {
+ exists: boolean;
+ extension: string | null;
+ fileName: string | null;
+ relativePath: string | null;
+};
+
// Fonction export
export const exportDb = async () => {
const response = await api.post('/imexport/admin/exportgsheet');
@@ -7,21 +14,24 @@ export const exportDb = async () => {
};
// Fonction import
-export const importFoodMenu = async (formData: FormData) => {
- const response = await api.post('/imexport/admin/foodimport', formData, {
+export const importFile = async (formData: FormData, category: string, item: string) => {
+ const response = await api.post(`/imexport/admin/import/${category}/${item}`, formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return response.data;
};
-export const importPlannings = async (formData: FormData) => {
- const response = await api.post('/imexport/admin/plannings', formData, {
- headers: { "Content-Type": "multipart/form-data" },
- });
+export const exportBus = async () => {
+ const response = await api.get('/imexport/admin/exportbus');
return response.data;
};
-export const exportBus = async () => {
- const response = await api.get('/imexport/admin/exportbus');
+export const checkIfExistingDocument = async (category: string, item: string) => {
+ const response = await api.get('/imexport/admin/document/' + category + '/' + item);
+ return response.data.data as ExistingDocumentStatus;
+};
+
+export const deleteFile = async (category: string, item: string) => {
+ const response = await api.delete('/imexport/admin/document/' + category + '/' + item);
return response.data;
};
diff --git a/frontend/src/services/requests/news.service.ts b/frontend/src/services/requests/news.service.ts
index 41ad898..7ace94e 100644
--- a/frontend/src/services/requests/news.service.ts
+++ b/frontend/src/services/requests/news.service.ts
@@ -1,21 +1,27 @@
+import { type News } from '../../interfaces/news.interface';
import api from '../api';
+type NewsPayload = {
+ id?: string;
+ title: string;
+ description: string;
+ type: string;
+ published: boolean;
+ target: string;
+ image_url?: string | null;
+};
+
export const getAllNews = async () => {
const res = await api.get("/news/admin/all");
return res.data.data;
};
-export const createNews = async (formData: FormData) => {
- const response = await api.post("/news/admin/createnews", formData,
- {
- headers: {
- "Content-Type": "multipart/form-data",
- },
- });
+export const createNews = async (payload: NewsPayload) => {
+ const response = await api.post("/news/admin/createnews", payload);
return response.data;
};
-export const publishNews = async (news: any, sendEmail: boolean) => {
+export const publishNews = async (news: Pick, sendEmail: boolean) => {
const res = await api.post("/news/admin/publish", {
id: news.id,
sendEmail: sendEmail
@@ -33,12 +39,7 @@ export const deleteNews = async (newsId: number) => {
return res.data;
};
-export const updateNews = async (formData: FormData) => {
- const response = await api.post("/news/admin/updatenews", formData,
- {
- headers: {
- "Content-Type": "multipart/form-data",
- },
- });
+export const updateNews = async (payload: NewsPayload) => {
+ const response = await api.post("/news/admin/updatenews", payload);
return response.data;
};