From bb56af38c5ed9abd923dcd907441528d397d5636 Mon Sep 17 00:00:00 2001 From: Arthur Dodin Date: Mon, 24 Aug 2026 21:34:57 +0200 Subject: [PATCH 1/2] feat: concurrent perms methods --- .../src/controllers/permanence.controller.ts | 41 ++ backend/src/email/email.registry.ts | 12 +- .../notify-concurrent-permanences.html | 116 ++++ backend/src/routes/automation.routes.ts | 5 + backend/src/routes/permanences.routes.ts | 62 ++- backend/src/services/permanence.service.ts | 217 +++++++- backend/src/types/email.d.ts | 9 + backend/src/types/permanence.d.ts | 14 +- frontend/src/App.tsx | 517 +++++++++--------- frontend/src/components/navbar.tsx | 15 + .../components/permanence/concurrentPerms.tsx | 44 ++ frontend/src/contexts/permanences.tsx | 83 +++ .../src/interfaces/permanence.interface.ts | 9 +- frontend/src/pages/perm/permMy.tsx | 5 + .../services/requests/permanence.service.ts | 63 ++- 15 files changed, 894 insertions(+), 318 deletions(-) create mode 100644 backend/src/email/templates/notify-concurrent-permanences.html create mode 100644 frontend/src/components/permanence/concurrentPerms.tsx create mode 100644 frontend/src/contexts/permanences.tsx diff --git a/backend/src/controllers/permanence.controller.ts b/backend/src/controllers/permanence.controller.ts index 92ca549..7bad452 100644 --- a/backend/src/controllers/permanence.controller.ts +++ b/backend/src/controllers/permanence.controller.ts @@ -391,3 +391,44 @@ export const sendDailyNotificationToUsers: AppRequestHandler = async (_req, res) Ok(res, { msg: 'Notifications quotidiennes envoyées avec succès' }); }; + +export const sendConcurrentPermanenceNotifications: AppRequestHandler = async (_req, res) => { + try { + const notifications = await permanence_service.getConcurrentPermanenceNotifications(); + await permanence_service.sendConcurrentPermanenceNotifications(notifications); + Ok(res, { msg: `${notifications.length} notification(s) de conflit envoyée(s).` }); + } catch (err) { + console.error(err); + Error(res, { msg: 'Erreur lors de l’envoi des notifications de permanences concurrentes' }); + } +}; + +export const purgeConcurrentPermanences: AppRequestHandler = async (_req, res) => { + try { + const result = await permanence_service.purgeConcurrentPermanences(); + Ok(res, { + data: result, + msg: `${result.removedRegistrations} inscription(s) supprimée(s) pour ${result.affectedUsers} utilisateur(s).`, + }); + } catch (err) { + console.error(err); + Error(res, { msg: 'Erreur lors de la purge des permanences concurrentes' }); + } +}; + +export const getConcurrentPermanences: AppRequestHandler = async (req, res) => { + const userId = req.user?.userId; + + if (!userId) { + Error(res, { msg: 'Utilisateur non identifié' }); + return; + } + + try { + const status = await permanence_service.getConcurrentPermanencesStatus(Number(userId)); + Ok(res, { data: status }); + } catch (err) { + console.error(err); + Error(res, { msg: 'Erreur lors de la vérification des permanences concurrentes' }); + } +}; diff --git a/backend/src/email/email.registry.ts b/backend/src/email/email.registry.ts index f582ca5..90f13a7 100644 --- a/backend/src/email/email.registry.ts +++ b/backend/src/email/email.registry.ts @@ -1,4 +1,9 @@ -import type { PermanenceEmailData, TemplateRenderer, TeamAssignmentEmailData } from '../types/email'; +import type { + ConcurrentPermanencesEmailData, + PermanenceEmailData, + TemplateRenderer, + TeamAssignmentEmailData, +} from '../types/email'; export const templateResetPassword = 'reset-password.html'; const templateNotebook = 'notebook.html'; @@ -7,6 +12,7 @@ const templateWelcome = 'welcome.html'; const templateNotifyNews = 'notify-news.html'; const templateNotifyTentConfirmation = 'notify-tent-confirmation.html'; const templateNotifyPermanenceReminder = 'notify-permanence-reminder.html'; +const templateNotifyConcurrentPermanences = 'notify-concurrent-permanences.html'; const templateNotifyTeamAssignment = 'notify-team-assignment.html'; const templateMentorReminder = 'mentor-reminder.html'; @@ -80,6 +86,10 @@ export const templateRenderers: Record = { return typedData; }, }, + templateNotifyConcurrentPermanences: { + fileName: templateNotifyConcurrentPermanences, + buildData: (data) => data as ConcurrentPermanencesEmailData, + }, templateMentorReminder: { fileName: templateMentorReminder, buildData: () => { diff --git a/backend/src/email/templates/notify-concurrent-permanences.html b/backend/src/email/templates/notify-concurrent-permanences.html new file mode 100644 index 0000000..ca13bc2 --- /dev/null +++ b/backend/src/email/templates/notify-concurrent-permanences.html @@ -0,0 +1,116 @@ + + + + + + Intégration UTT + + + + + + +
+ + + + + + + + + + + + + +
+ Logo Intégration UTT +
+ INTEGRATION UTT +
+

+ Permanences concurrentes +

+

+ Attention, certaines de vos permanences se deroulent sur des creneaux qui se + chevauchent : +

+ {{#each permanences}} +
+

{{name}}

+

Du {{startAt}} au {{endAt}}

+

Lieu : {{location}}

+
+ {{/each}} +

+ Rendez-vous sur la page « Mes permanences » pour verifier vos + inscriptions. +

+

+ Après un shotgun, vous avez jusqu'au lendemain midi pour supprimer + les doublons, sinon des permanences seront + retirées de manière aléatoire pour éliminer les conflits, et les + permanences libérées seront à nouveau shotgun le lendemain. +

+
+

+ Retrouve des informations utiles sur notre Instagram ! +

+
+ + Instagram + +
+

Cordialement,
L'équipe intégration UTT

+

+ Si vous avez des questions, n'hésitez pas à + nous contacter. +

+
+
+ + diff --git a/backend/src/routes/automation.routes.ts b/backend/src/routes/automation.routes.ts index 214f4a4..1561de1 100644 --- a/backend/src/routes/automation.routes.ts +++ b/backend/src/routes/automation.routes.ts @@ -6,5 +6,10 @@ const automationRoutes = express.Router(); // Permanences routes automationRoutes.post('/permanence/notification/hourly', permanenceController.sendHourlyNotificationToUsers); automationRoutes.post('/permanence/notification/daily', permanenceController.sendDailyNotificationToUsers); +automationRoutes.post( + '/permanence/concurrent/notification', + permanenceController.sendConcurrentPermanenceNotifications, +); +automationRoutes.post('/permanence/concurrent/purge', permanenceController.purgeConcurrentPermanences); export default automationRoutes; diff --git a/backend/src/routes/permanences.routes.ts b/backend/src/routes/permanences.routes.ts index 50fb6e3..162884d 100644 --- a/backend/src/routes/permanences.routes.ts +++ b/backend/src/routes/permanences.routes.ts @@ -1,33 +1,47 @@ -import express from "express"; -import multer from "multer"; -import * as permanenceController from "../controllers/permanence.controller"; -import { isRespoMiddleware } from "../middlewares/respoperm.middleware"; -import { checkRole } from "../middlewares/user.middleware"; +import express from 'express'; +import multer from 'multer'; +import * as permanenceController from '../controllers/permanence.controller'; +import { isRespoMiddleware } from '../middlewares/respoperm.middleware'; +import { checkRole } from '../middlewares/user.middleware'; const permanenceRouter = express.Router(); -const upload = multer({ dest: "uploads/permcsv/" }); +const upload = multer({ dest: 'uploads/permcsv/' }); // Admin routes -permanenceRouter.post("/admin/permanence", checkRole("Admin", ["Respo CE"]), permanenceController.createPermanence); -permanenceRouter.delete("/admin/permanence", checkRole("Admin", ["Respo CE"]), permanenceController.deletePermanence); -permanenceRouter.post("/admin/updatepermanence", checkRole("Admin", ["Respo CE"]), permanenceController.updatePermanence); -permanenceRouter.post("/admin/open", checkRole("Admin", ["Respo CE"]), permanenceController.openPermanence); -permanenceRouter.post("/admin/close", checkRole("Admin", ["Respo CE"]), permanenceController.closePermanence); -permanenceRouter.get("/admin/permanences", checkRole("Admin", ["Respo CE"]), permanenceController.getAllPermanences); -permanenceRouter.get("/admin/users", checkRole("Admin", ["Respo CE"]), permanenceController.getUsersInPermanence); -permanenceRouter.post("/admin/add", checkRole("Admin", ["Respo CE"]), permanenceController.addUserToPermanence); -permanenceRouter.post("/admin/remove", checkRole("Admin", ["Respo CE"]), permanenceController.removeUserToPermanence); -permanenceRouter.post("/admin/importpermanences", checkRole("Admin", ["Respo CE"]), upload.single("file"), permanenceController.uploadPermanencesCSV); -permanenceRouter.post("/admin/claimedmember", checkRole("Admin", ["Respo CE"]), permanenceController.claimMember); +permanenceRouter.post('/admin/permanence', checkRole('Admin', ['Respo CE']), permanenceController.createPermanence); +permanenceRouter.delete('/admin/permanence', checkRole('Admin', ['Respo CE']), permanenceController.deletePermanence); +permanenceRouter.post( + '/admin/updatepermanence', + checkRole('Admin', ['Respo CE']), + permanenceController.updatePermanence, +); +permanenceRouter.post('/admin/open', checkRole('Admin', ['Respo CE']), permanenceController.openPermanence); +permanenceRouter.post('/admin/close', checkRole('Admin', ['Respo CE']), permanenceController.closePermanence); +permanenceRouter.get('/admin/permanences', checkRole('Admin', ['Respo CE']), permanenceController.getAllPermanences); +permanenceRouter.get('/admin/users', checkRole('Admin', ['Respo CE']), permanenceController.getUsersInPermanence); +permanenceRouter.post('/admin/add', checkRole('Admin', ['Respo CE']), permanenceController.addUserToPermanence); +permanenceRouter.post('/admin/remove', checkRole('Admin', ['Respo CE']), permanenceController.removeUserToPermanence); +permanenceRouter.post( + '/admin/importpermanences', + checkRole('Admin', ['Respo CE']), + upload.single('file'), + permanenceController.uploadPermanencesCSV, +); +permanenceRouter.post('/admin/claimedmember', checkRole('Admin', ['Respo CE']), permanenceController.claimMember); //Respo de perm routes -permanenceRouter.get("/respo/respodetails", isRespoMiddleware, permanenceController.getRespoPermanencesWithMembers); -permanenceRouter.post("/respo/claimedmember", isRespoMiddleware, permanenceController.claimMember); +permanenceRouter.get('/respo/respodetails', isRespoMiddleware, permanenceController.getRespoPermanencesWithMembers); +permanenceRouter.post('/respo/claimedmember', isRespoMiddleware, permanenceController.claimMember); // Student routes -permanenceRouter.get("/user/permanences", checkRole("Student", []), permanenceController.getOpenPermanences); -permanenceRouter.post("/user/apply", checkRole("Student", []), permanenceController.applyToPermanence); -permanenceRouter.post("/user/leave", checkRole("Student", []), permanenceController.leavePermanence); -permanenceRouter.get("/user/me", checkRole("Student", []), permanenceController.getMyPermanences); -permanenceRouter.get("/user/isrespo", permanenceController.isUserRespo); +permanenceRouter.get('/user/permanences', checkRole('Student', []), permanenceController.getOpenPermanences); +permanenceRouter.get( + '/user/concurrent/status', + checkRole('Student', []), + permanenceController.getConcurrentPermanences, +); +permanenceRouter.post('/user/apply', checkRole('Student', []), permanenceController.applyToPermanence); +permanenceRouter.post('/user/leave', checkRole('Student', []), permanenceController.leavePermanence); +permanenceRouter.get('/user/me', checkRole('Student', []), permanenceController.getMyPermanences); +permanenceRouter.get('/user/isrespo', permanenceController.isUserRespo); export default permanenceRouter; diff --git a/backend/src/services/permanence.service.ts b/backend/src/services/permanence.service.ts index aa9a36e..aee8fba 100644 --- a/backend/src/services/permanence.service.ts +++ b/backend/src/services/permanence.service.ts @@ -1,8 +1,14 @@ import { and, eq, inArray, sql } from 'drizzle-orm'; import fs from 'fs'; import Papa from 'papaparse'; -import type { PermanenceEmailData } from '../types/email'; -import type { CsvPermanence, LightUser, Notification, Permanence } from '../types/permanence'; +import type { ConcurrentPermanencesEmailData, PermanenceEmailData } from '../types/email'; +import type { + ConcurrentPermanenceNotification, + CsvPermanence, + LightUser, + Notification, + Permanence, +} from '../types/permanence'; import { db } from '../database/db'; import { AlreadyRegisteredError, @@ -444,6 +450,178 @@ export const getHourlyNotifications = async (): Promise => { return getMembersFromPermanences(permanences); }; +export const getConcurrentPermanenceNotifications = async (): Promise => { + const registrations = await db + .select({ + userId: userSchema.id, + email: userSchema.email, + permanence: permanenceSchema, + }) + .from(userPermanenceSchema) + .innerJoin(userSchema, eq(userSchema.id, userPermanenceSchema.user_id)) + .innerJoin(permanenceSchema, eq(permanenceSchema.id, userPermanenceSchema.permanence_id)); + + const registrationsByUser = new Map(); + for (const registration of registrations) { + if (!registration.email) continue; + const userRegistration = registrationsByUser.get(registration.userId) ?? { + email: registration.email, + permanences: [], + }; + userRegistration.permanences.push(registration.permanence); + registrationsByUser.set(registration.userId, userRegistration); + } + + return [...registrationsByUser.entries()].flatMap(([userId, userRegistration]) => { + const { email, permanences } = userRegistration; + const concurrentIds = new Set(); + for (let firstIndex = 0; firstIndex < permanences.length; firstIndex += 1) { + const first = permanences[firstIndex]; + if (!first.start_at || !first.end_at) continue; + + for (let secondIndex = firstIndex + 1; secondIndex < permanences.length; secondIndex += 1) { + const second = permanences[secondIndex]; + if (!second.start_at || !second.end_at) continue; + + if (first.start_at < second.end_at && second.start_at < first.end_at) { + concurrentIds.add(first.id); + concurrentIds.add(second.id); + } + } + } + + const concurrentPermanences = permanences.filter((permanence) => concurrentIds.has(permanence.id)); + return concurrentPermanences.length > 0 ? [{ userId, email, permanences: concurrentPermanences }] : []; + }); +}; + +export const getConcurrentPermanencesStatus = async (userId: number) => { + const permanences = await db + .select({ + id: permanenceSchema.id, + name: permanenceSchema.name, + description: permanenceSchema.description, + location: permanenceSchema.location, + start_at: permanenceSchema.start_at, + end_at: permanenceSchema.end_at, + capacity: permanenceSchema.capacity, + is_open: permanenceSchema.is_open, + difficulty: permanenceSchema.difficulty, + }) + .from(userPermanenceSchema) + .innerJoin(permanenceSchema, eq(permanenceSchema.id, userPermanenceSchema.permanence_id)) + .where(eq(userPermanenceSchema.user_id, userId)); + + const concurrentIds = new Set(); + for (let firstIndex = 0; firstIndex < permanences.length; firstIndex += 1) { + const first = permanences[firstIndex]; + if (!first.start_at || !first.end_at) continue; + + for (let secondIndex = firstIndex + 1; secondIndex < permanences.length; secondIndex += 1) { + const second = permanences[secondIndex]; + if (!second.start_at || !second.end_at) continue; + + const overlaps = first.start_at < second.end_at && second.start_at < first.end_at; + if (overlaps) { + concurrentIds.add(first.id); + concurrentIds.add(second.id); + } + } + } + + const concurrentPermanences = permanences.filter((permanence) => concurrentIds.has(permanence.id)); + + return { + concurrentPermanences: concurrentPermanences.length > 0, + permanences: concurrentPermanences, + }; +}; + +export const purgeConcurrentPermanences = async () => { + return db.transaction(async (tx) => { + await tx.execute(sql`LOCK TABLE user_permanences IN EXCLUSIVE MODE`); + + const registrations = await tx + .select({ + userId: userPermanenceSchema.user_id, + permanenceId: permanenceSchema.id, + startAt: permanenceSchema.start_at, + endAt: permanenceSchema.end_at, + difficulty: permanenceSchema.difficulty, + }) + .from(userPermanenceSchema) + .innerJoin(permanenceSchema, eq(permanenceSchema.id, userPermanenceSchema.permanence_id)); + + const registrationsByUser = new Map(); + for (const registration of registrations) { + if (registration.userId === null) continue; + + const userRegistrations = registrationsByUser.get(registration.userId) ?? []; + userRegistrations.push(registration); + registrationsByUser.set(registration.userId, userRegistrations); + } + + let removedRegistrations = 0; + let affectedUsers = 0; + + for (const [userId, userRegistrations] of registrationsByUser) { + let removedForUser = false; + + while (true) { + const conflictingRegistrations = new Set<(typeof userRegistrations)[number]>(); + + for (let firstIndex = 0; firstIndex < userRegistrations.length; firstIndex += 1) { + const first = userRegistrations[firstIndex]; + if (!first.startAt || !first.endAt) continue; + + for (let secondIndex = firstIndex + 1; secondIndex < userRegistrations.length; secondIndex += 1) { + const second = userRegistrations[secondIndex]; + if (!second.startAt || !second.endAt) continue; + + if (first.startAt < second.endAt && second.startAt < first.endAt) { + conflictingRegistrations.add(first); + conflictingRegistrations.add(second); + } + } + } + + if (conflictingRegistrations.size === 0) break; + + const lowestDifficulty = Math.min( + ...[...conflictingRegistrations].map((registration) => registration.difficulty ?? 0), + ); + const lowestDifficultyRegistrations = [...conflictingRegistrations].filter( + (registration) => (registration.difficulty ?? 0) === lowestDifficulty, + ); + const permanenceToRemove = + lowestDifficultyRegistrations[Math.floor(Math.random() * lowestDifficultyRegistrations.length)]; + + await tx + .delete(userPermanenceSchema) + .where( + and( + eq(userPermanenceSchema.user_id, userId), + eq(userPermanenceSchema.permanence_id, permanenceToRemove.permanenceId), + ), + ); + await tx + .update(permanenceSchema) + .set({ capacity: sql`capacity + 1` }) + .where(eq(permanenceSchema.id, permanenceToRemove.permanenceId)); + + const registrationIndex = userRegistrations.indexOf(permanenceToRemove); + userRegistrations.splice(registrationIndex, 1); + removedRegistrations += 1; + removedForUser = true; + } + + if (removedForUser) affectedUsers += 1; + } + + return { removedRegistrations, affectedUsers }; + }); +}; + // Cette fonction est vouée à disparaitre lors du passage à Prisma, avec un simple "with" export const getMembersFromPermanences = async ( permanences: Permanence[], @@ -518,3 +696,38 @@ export const sendNotifications = async (notifications: Notification[]) => { } } }; + +export const sendConcurrentPermanenceNotifications = async (notifications: ConcurrentPermanenceNotification[]) => { + for (const notification of notifications) { + try { + const permanenceEmailData: ConcurrentPermanencesEmailData = { + permanences: notification.permanences.map((permanence) => ({ + name: permanence.name, + startAt: new Intl.DateTimeFormat('fr-FR', { + day: '2-digit', + month: 'long', + hour: '2-digit', + minute: '2-digit', + }).format(permanence.start_at), + endAt: new Intl.DateTimeFormat('fr-FR', { + day: '2-digit', + month: 'long', + hour: '2-digit', + minute: '2-digit', + }).format(permanence.end_at), + location: permanence.location, + })), + }; + + await sendEmail({ + from: email_from, + to: [notification.email], + subject: '[ATTENTION] Permanences concurrentes', + text: 'Certaines de vos permanences se chevauchent.', + html: generateEmailHtml('templateNotifyConcurrentPermanences', permanenceEmailData), + }); + } catch (error) { + console.error(`Erreur lors de l'envoi du conflit de permanences a ${notification.email}`, error); + } + } +}; diff --git a/backend/src/types/email.d.ts b/backend/src/types/email.d.ts index 6688387..b49186e 100644 --- a/backend/src/types/email.d.ts +++ b/backend/src/types/email.d.ts @@ -25,6 +25,15 @@ export interface PermanenceEmailData extends TemplateData { permDescription: string; } +export interface ConcurrentPermanencesEmailData extends TemplateData { + permanences: { + name: string; + startAt: string; + endAt: string; + location: string; + }[]; +} + export interface TeamAssignmentEmailData extends TemplateData { teamName: string; teamSocialLink: string; diff --git a/backend/src/types/permanence.d.ts b/backend/src/types/permanence.d.ts index 190fbb6..4b2a057 100644 --- a/backend/src/types/permanence.d.ts +++ b/backend/src/types/permanence.d.ts @@ -11,8 +11,14 @@ export type CsvPermanence = { export type Notification = { permanence: Permanence; - members: LightUser[] -} + members: LightUser[]; +}; + +export type ConcurrentPermanenceNotification = { + userId: number; + email: string; + permanences: Permanence[]; +}; export type Permanence = { id: number; @@ -24,11 +30,11 @@ export type Permanence = { capacity: number; is_open: boolean; difficulty: number; -} +}; export type LightUser = { id: number; firstName: string; lastName: string; email: string; -} +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0de1a2b..46b0562 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import AdminRoute from './components/utils/adminroute'; import PrivateRoute from './components/utils/privateroute'; import ProtectedRoute from './components/utils/protectedroute'; import { OnboardingProvider } from './contexts/onboarding'; +import { PermanencesProvider } from './contexts/permanences'; import { UserProvider } from './contexts/user'; const AdminPageBanned = lazy(() => import('./pages/admin/adminBanned')); @@ -65,270 +66,272 @@ const App: React.FC = () => { - - {/* Public */} - } /> - } /> - } /> - } /> - } /> - } /> + + + {/* Public */} + } /> + } /> + } /> + } /> + } /> + } /> - {/* Utilisateurs connectés */} + {/* Utilisateurs connectés */} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> - {/* Étudiant et Admin */} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> + {/* Étudiant et Admin */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> - {/* ResposCE et Admin */} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> + {/* ResposCE et Admin */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> - {/* ResposCE et Admin */} - - - - } - /> + {/* ResposCE et Admin */} + + + + } + /> - {/* Arbitre et Admin*/} - - - - } - /> - {/* Admin uniquement */} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> + {/* Arbitre et Admin*/} + + + + } + /> + {/* Admin uniquement */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> - {/* Fallback */} - } /> - + {/* Fallback */} + } /> + + diff --git a/frontend/src/components/navbar.tsx b/frontend/src/components/navbar.tsx index 9d5e8cf..353b923 100644 --- a/frontend/src/components/navbar.tsx +++ b/frontend/src/components/navbar.tsx @@ -5,6 +5,7 @@ import { Fragment, useEffect, useState } from 'react'; import { NavLink, useLocation, useSearchParams } from 'react-router-dom'; import { useOnboarding } from '../contexts/onboarding'; +import { usePermanences } from '../contexts/permanences'; import { decodeToken, getToken } from '../services/requests/auth.service'; import { Button } from './ui/button'; @@ -32,6 +33,7 @@ export const Navbar = () => { const [hasContactInformation, setHasContactInformation] = useState(false); const [needsVssForm, setNeedsVssForm] = useState(false); const { status: onboardingStatus, loading: onboardingLoading } = useOnboarding(); + const { concurrentPermanences, loading: permanencesLoading } = usePermanences(); const [, setSearchParams] = useSearchParams(); const handleLogout = () => { @@ -247,6 +249,19 @@ export const Navbar = () => { )} + + {isAuthenticated && !permanencesLoading && concurrentPermanences && ( +
+

+ ATTENTION : certaines de tes permanences se chevauchent. +

+ + + +
+ )} ); }; diff --git a/frontend/src/components/permanence/concurrentPerms.tsx b/frontend/src/components/permanence/concurrentPerms.tsx new file mode 100644 index 0000000..11e3312 --- /dev/null +++ b/frontend/src/components/permanence/concurrentPerms.tsx @@ -0,0 +1,44 @@ +import { format } from 'date-fns'; +import { fr } from 'date-fns/locale'; + +import type { ConcurrentPermanences } from '../../interfaces/permanence.interface'; +import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; + +interface ConcurrentPermanencesProps { + permanences: ConcurrentPermanences['permanences']; +} + +export const ConcurrentPermanencesCard: React.FC = ({ permanences }) => { + return ( + + + Permanences concurrentes +

Ces permanences se déroulent sur des créneaux qui se chevauchent.

+
+ +
+ {permanences.map((permanence) => ( + + + {permanence.name} + + +

📍 {permanence.location}

+

+ 🕒 {format(new Date(permanence.start_at), "EEEE dd MMMM 'à' HH:mm", { locale: fr })}{' '} + - {format(new Date(permanence.end_at), 'HH:mm', { locale: fr })} +

+
+
+ ))} +
+

+ Après un shotgun, vous avez jusqu'au lendemain midi pour + supprimer les doublons, sinon des permanences seront{' '} + retirées de manière aléatoire pour éliminer les conflits, + et les permanences libérées seront à nouveau shotgun le lendemain. +

+
+
+ ); +}; diff --git a/frontend/src/contexts/permanences.tsx b/frontend/src/contexts/permanences.tsx new file mode 100644 index 0000000..f7797fd --- /dev/null +++ b/frontend/src/contexts/permanences.tsx @@ -0,0 +1,83 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; + +import type { ConcurrentPermanences } from '../interfaces/permanence.interface'; +import { getToken } from '../services/requests/auth.service'; +import { getConcurrentPermanencesStatus } from '../services/requests/permanence.service'; + +type PermanencesContextType = { + concurrentPermanences: boolean; + concurrentPermanencesList: ConcurrentPermanences['permanences']; + loading: boolean; + refreshPermanences: () => Promise; +}; + +const PermanencesContext = createContext(undefined); + +let cachedPermanences: ConcurrentPermanences | null = null; + +export const usePermanences = (): PermanencesContextType => { + const ctx = useContext(PermanencesContext); + if (!ctx) throw new Error('usePermanences must be used within a PermanencesProvider'); + return ctx; +}; + +export const PermanencesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const tokenPresent = Boolean(getToken()); + const [permanences, setPermanences] = useState(cachedPermanences); + const [loading, setLoading] = useState(() => cachedPermanences === null && tokenPresent); + + const fetchStatus = async () => { + const token = getToken(); + if (!token) { + setPermanences(null); + setLoading(false); + return; + } + + setLoading(true); + try { + const s = await getConcurrentPermanencesStatus(); + setPermanences(s ?? null); + cachedPermanences = s ?? null; + } catch { + setPermanences(null); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (!tokenPresent && !cachedPermanences) { + // nothing to do when not authenticated + return; + } + + if (!cachedPermanences) void fetchStatus(); + + const handler = () => { + void fetchStatus(); + }; + + window.addEventListener('user-onboarding-updated', handler); + return () => window.removeEventListener('user-onboarding-updated', handler); + }, []); + + const refreshPermanences = async () => { + cachedPermanences = null; + await fetchStatus(); + }; + + return ( + + {children} + + ); +}; + +export default PermanencesProvider; diff --git a/frontend/src/interfaces/permanence.interface.ts b/frontend/src/interfaces/permanence.interface.ts index 577beb4..7baa3a8 100644 --- a/frontend/src/interfaces/permanence.interface.ts +++ b/frontend/src/interfaces/permanence.interface.ts @@ -1,4 +1,4 @@ -import { type User } from "./user.interface"; +import { type User } from './user.interface'; export interface Permanence { id: number; @@ -18,4 +18,9 @@ export interface Permanence { export interface PermanenceActionsProps { permanences: Permanence[]; onRefresh: () => void; -}; +} + +export interface ConcurrentPermanences { + concurrentPermanences: boolean; + permanences: Pick[]; +} diff --git a/frontend/src/pages/perm/permMy.tsx b/frontend/src/pages/perm/permMy.tsx index 4665ce3..7db2a00 100644 --- a/frontend/src/pages/perm/permMy.tsx +++ b/frontend/src/pages/perm/permMy.tsx @@ -3,12 +3,15 @@ import Swal from 'sweetalert2'; import EmergencyModal from '../../components/home/emergencyModal'; import { Navbar } from '../../components/navbar'; +import { ConcurrentPermanencesCard } from '../../components/permanence/concurrentPerms'; import { MyPermanencesList } from '../../components/permanence/permUser'; +import { usePermanences } from '../../contexts/permanences'; import { type Permanence } from '../../interfaces/permanence.interface'; import { cancelPermanence, getMyPermanences } from '../../services/requests/permanence.service'; const PermanencesPageMy: React.FC = () => { const [myPermanences, setMyPermanences] = useState([]); + const { concurrentPermanences, concurrentPermanencesList, refreshPermanences } = usePermanences(); useEffect(() => { fetchMyPermanences(); @@ -57,6 +60,7 @@ const PermanencesPageMy: React.FC = () => { const response = await cancelPermanence(permId); Swal.fire('Succès ✅', response.message, 'success'); fetchMyPermanences(); + await refreshPermanences(); } catch (err) { console.error("Erreur lors de l'annulation", err); Swal.fire('Erreur', 'Impossible de vous désinscrire.', 'error'); @@ -69,6 +73,7 @@ const PermanencesPageMy: React.FC = () => {
+ {concurrentPermanences && }
diff --git a/frontend/src/services/requests/permanence.service.ts b/frontend/src/services/requests/permanence.service.ts index e470f42..283f22b 100644 --- a/frontend/src/services/requests/permanence.service.ts +++ b/frontend/src/services/requests/permanence.service.ts @@ -1,14 +1,15 @@ -import api from "../api"; // ton instance axios ou une autre bibliothèque pour les requêtes HTTP +import { type ConcurrentPermanences } from '../../interfaces/permanence.interface'; +import api from '../api'; // ton instance axios ou une autre bibliothèque pour les requêtes HTTP // Fonction pour récupérer toutes les permanences ouvertes export const getOpenPermanences = async () => { - const response = await api.get("/permanence/user/permanences"); + const response = await api.get('/permanence/user/permanences'); return response.data.data; // La réponse est de type PermanenceResponse }; // Fonction pour récupérer toutes les permanences (admin) export const getAllPermanences = async () => { - const response = await api.get("/permanence/admin/permanences"); + const response = await api.get('/permanence/admin/permanences'); return response.data; // La réponse est de type PermanenceResponse }; @@ -29,7 +30,7 @@ export const createPermanence = async (permanenceData: { difficulty: number; respoId: number | null; }) => { - const response = await api.post("/permanence/admin/permanence", { + const response = await api.post('/permanence/admin/permanence', { name: permanenceData.name, description: permanenceData.description, location: permanenceData.location, @@ -37,21 +38,19 @@ export const createPermanence = async (permanenceData: { end_at: permanenceData.end_at, capacity: permanenceData.capacity, difficulty: permanenceData.difficulty, - respoId: permanenceData.respoId + respoId: permanenceData.respoId, }); return response.data; // La réponse est de type Permanent - }; // Fonction pour ouvrir une permanence (admin) export const openPermanence = async (permId: number) => { - const response = await api.post("/permanence/admin/open", { + const response = await api.post('/permanence/admin/open', { permId, }); return response.data; // La réponse est de type Permanent }; - // Fonction pour fermer une permanence (admin) export const closePermanence = async (permId: number) => { const response = await api.post(`/permanence/admin/close`, { permId }); @@ -68,17 +67,20 @@ export const getMyPermanences = async () => { return response.data.data; }; -export const updatePermanence = async (permId: number, permanenceData: { - name: string; - description: string; - location: string; - start_at: string | null; - end_at: string | null; - capacity: number; - difficulty: number; - respoId: number | null; -}) => { - const response = await api.post("/permanence/admin/updatepermanence", { +export const updatePermanence = async ( + permId: number, + permanenceData: { + name: string; + description: string; + location: string; + start_at: string | null; + end_at: string | null; + capacity: number; + difficulty: number; + respoId: number | null; + }, +) => { + const response = await api.post('/permanence/admin/updatepermanence', { permId, name: permanenceData.name, description: permanenceData.description, @@ -87,10 +89,9 @@ export const updatePermanence = async (permId: number, permanenceData: { end_at: permanenceData.end_at, capacity: permanenceData.capacity, difficulty: permanenceData.difficulty, - respoId: permanenceData.respoId + respoId: permanenceData.respoId, }); return response.data; // La réponse est de type Permanent - }; export const getUsersByPermanence = async (permId: number) => { @@ -106,7 +107,7 @@ export const addUserToPermanence = async (permId: number, userId: number) => { export const removeUserFromPermanence = async (permId: number, userId: number) => { const response = await api.post(`/permanence/admin/remove`, { userId, permId }); return response.data; -} +}; export const deletePermanence = async (permId: number) => { const response = await api.delete(`/permanence/admin/permanence`, { params: { permId } }); @@ -116,27 +117,27 @@ export const deletePermanence = async (permId: number) => { export const importPermanenceCSV = async (formData: FormData) => { const response = await api.post(`/permanence/admin/importpermanences`, formData, { headers: { - "Content-Type": "multipart/form-data", + 'Content-Type': 'multipart/form-data', }, }); return response.data; -} +}; export const isUserRespo = async (userId: number) => { const response = await api.get(`/permanence/user/isrespo`, { params: { userId } }); return response.data; -} +}; export const respoDetails = async () => { const response = await api.get(`/permanence/respo/respodetails`); return response.data; -} +}; export const claimedMember = async (userId: number, permId: number, claimed: boolean) => { const response = await api.post(`/permanence/respo/claimedmember`, { userId, permId, - claimed + claimed, }); return response.data; }; @@ -145,7 +146,13 @@ export const claimedMemberAdmin = async (userId: number, permId: number, claimed const response = await api.post(`/permanence/admin/claimedmember`, { userId, permId, - claimed + claimed, }); return response.data; }; + +export const getConcurrentPermanencesStatus = async () => { + const response = await api.get('/permanence/user/concurrent/status'); + const status: ConcurrentPermanences = response.data.data; + return status; +}; From d6950ee00741c8562538db7fa23ac684e8d44ef1 Mon Sep 17 00:00:00 2001 From: Arthur Dodin Date: Mon, 24 Aug 2026 22:04:31 +0200 Subject: [PATCH 2/2] refactor: optimize concurrent perms detector loops --- backend/src/services/permanence.service.ts | 101 ++++++++++----------- 1 file changed, 47 insertions(+), 54 deletions(-) diff --git a/backend/src/services/permanence.service.ts b/backend/src/services/permanence.service.ts index aee8fba..c326664 100644 --- a/backend/src/services/permanence.service.ts +++ b/backend/src/services/permanence.service.ts @@ -450,6 +450,35 @@ export const getHourlyNotifications = async (): Promise => { return getMembersFromPermanences(permanences); }; +const findConcurrentItems = ( + items: T[], + getStartAt: (item: T) => Date | null, + getEndAt: (item: T) => Date | null, +): T[] => { + const concurrentItems = new Set(); + + for (let firstIndex = 0; firstIndex < items.length; firstIndex += 1) { + const first = items[firstIndex]; + const firstStartAt = getStartAt(first); + const firstEndAt = getEndAt(first); + if (!firstStartAt || !firstEndAt) continue; + + for (let secondIndex = firstIndex + 1; secondIndex < items.length; secondIndex += 1) { + const second = items[secondIndex]; + const secondStartAt = getStartAt(second); + const secondEndAt = getEndAt(second); + if (!secondStartAt || !secondEndAt) continue; + + if (firstStartAt < secondEndAt && secondStartAt < firstEndAt) { + concurrentItems.add(first); + concurrentItems.add(second); + } + } + } + + return items.filter((item) => concurrentItems.has(item)); +}; + export const getConcurrentPermanenceNotifications = async (): Promise => { const registrations = await db .select({ @@ -474,23 +503,11 @@ export const getConcurrentPermanenceNotifications = async (): Promise { const { email, permanences } = userRegistration; - const concurrentIds = new Set(); - for (let firstIndex = 0; firstIndex < permanences.length; firstIndex += 1) { - const first = permanences[firstIndex]; - if (!first.start_at || !first.end_at) continue; - - for (let secondIndex = firstIndex + 1; secondIndex < permanences.length; secondIndex += 1) { - const second = permanences[secondIndex]; - if (!second.start_at || !second.end_at) continue; - - if (first.start_at < second.end_at && second.start_at < first.end_at) { - concurrentIds.add(first.id); - concurrentIds.add(second.id); - } - } - } - - const concurrentPermanences = permanences.filter((permanence) => concurrentIds.has(permanence.id)); + const concurrentPermanences = findConcurrentItems( + permanences, + (permanence) => permanence.start_at, + (permanence) => permanence.end_at, + ); return concurrentPermanences.length > 0 ? [{ userId, email, permanences: concurrentPermanences }] : []; }); }; @@ -512,24 +529,11 @@ export const getConcurrentPermanencesStatus = async (userId: number) => { .innerJoin(permanenceSchema, eq(permanenceSchema.id, userPermanenceSchema.permanence_id)) .where(eq(userPermanenceSchema.user_id, userId)); - const concurrentIds = new Set(); - for (let firstIndex = 0; firstIndex < permanences.length; firstIndex += 1) { - const first = permanences[firstIndex]; - if (!first.start_at || !first.end_at) continue; - - for (let secondIndex = firstIndex + 1; secondIndex < permanences.length; secondIndex += 1) { - const second = permanences[secondIndex]; - if (!second.start_at || !second.end_at) continue; - - const overlaps = first.start_at < second.end_at && second.start_at < first.end_at; - if (overlaps) { - concurrentIds.add(first.id); - concurrentIds.add(second.id); - } - } - } - - const concurrentPermanences = permanences.filter((permanence) => concurrentIds.has(permanence.id)); + const concurrentPermanences = findConcurrentItems( + permanences, + (permanence) => permanence.start_at, + (permanence) => permanence.end_at, + ); return { concurrentPermanences: concurrentPermanences.length > 0, @@ -568,29 +572,18 @@ export const purgeConcurrentPermanences = async () => { let removedForUser = false; while (true) { - const conflictingRegistrations = new Set<(typeof userRegistrations)[number]>(); - - for (let firstIndex = 0; firstIndex < userRegistrations.length; firstIndex += 1) { - const first = userRegistrations[firstIndex]; - if (!first.startAt || !first.endAt) continue; - - for (let secondIndex = firstIndex + 1; secondIndex < userRegistrations.length; secondIndex += 1) { - const second = userRegistrations[secondIndex]; - if (!second.startAt || !second.endAt) continue; - - if (first.startAt < second.endAt && second.startAt < first.endAt) { - conflictingRegistrations.add(first); - conflictingRegistrations.add(second); - } - } - } + const conflictingRegistrations = findConcurrentItems( + userRegistrations, + (registration) => registration.startAt, + (registration) => registration.endAt, + ); - if (conflictingRegistrations.size === 0) break; + if (conflictingRegistrations.length === 0) break; const lowestDifficulty = Math.min( - ...[...conflictingRegistrations].map((registration) => registration.difficulty ?? 0), + ...conflictingRegistrations.map((registration) => registration.difficulty ?? 0), ); - const lowestDifficultyRegistrations = [...conflictingRegistrations].filter( + const lowestDifficultyRegistrations = conflictingRegistrations.filter( (registration) => (registration.difficulty ?? 0) === lowestDifficulty, ); const permanenceToRemove =