fix: messagerie

This commit is contained in:
Luc SORIGNET
2026-04-05 15:02:51 +02:00
parent 4431c428d3
commit a81b76ecea
8 changed files with 103 additions and 46 deletions

View File

@ -72,11 +72,11 @@ class ChatConsumer(AsyncWebsocketConsumer):
if presence: if presence:
await self.broadcast_presence_update(self.user_id, 'online') await self.broadcast_presence_update(self.user_id, 'online')
await self.accept()
# Envoyer les statuts de présence existants des autres utilisateurs connectés # Envoyer les statuts de présence existants des autres utilisateurs connectés
await self.send_existing_user_presences() await self.send_existing_user_presences()
await self.accept()
logger.info(f"User {self.user_id} connected to chat") logger.info(f"User {self.user_id} connected to chat")
async def send_existing_user_presences(self): async def send_existing_user_presences(self):

View File

@ -34,12 +34,13 @@ import Footer from '@/components/Footer';
import MobileTopbar from '@/components/MobileTopbar'; import MobileTopbar from '@/components/MobileTopbar';
import { RIGHTS } from '@/utils/rights'; import { RIGHTS } from '@/utils/rights';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { useChatConnection } from '@/context/ChatConnectionContext';
export default function Layout({ children }) { export default function Layout({ children }) {
const t = useTranslations('sidebar'); const t = useTranslations('sidebar');
const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const { profileRole, establishments, clearContext } = const { profileRole, establishments, clearContext } = useEstablishment();
useEstablishment(); const { totalUnreadCount, resetUnreadCount } = useChatConnection();
const sidebarItems = { const sidebarItems = {
admin: { admin: {
@ -83,6 +84,7 @@ export default function Layout({ children }) {
name: t('messagerie'), name: t('messagerie'),
url: FE_ADMIN_MESSAGERIE_URL, url: FE_ADMIN_MESSAGERIE_URL,
icon: MessageSquare, icon: MessageSquare,
badge: totalUnreadCount,
}, },
feedback: { feedback: {
id: 'feedback', id: 'feedback',
@ -119,7 +121,11 @@ export default function Layout({ children }) {
useEffect(() => { useEffect(() => {
// Fermer la sidebar quand on change de page sur mobile // Fermer la sidebar quand on change de page sur mobile
setIsSidebarOpen(false); setIsSidebarOpen(false);
}, [pathname]); // Réinitialiser le compteur non lu quand on ouvre la messagerie
if (pathname?.includes('/messagerie')) {
resetUnreadCount();
}
}, [pathname, resetUnreadCount]);
// Filtrage dynamique des items de la sidebar selon le rôle // Filtrage dynamique des items de la sidebar selon le rôle
let sidebarItemsToDisplay = Object.values(sidebarItems); let sidebarItemsToDisplay = Object.values(sidebarItems);

View File

@ -4,10 +4,7 @@ import React, { useState, useEffect } from 'react';
import Sidebar from '@/components/Sidebar'; import Sidebar from '@/components/Sidebar';
import { useRouter, usePathname } from 'next/navigation'; import { useRouter, usePathname } from 'next/navigation';
import { MessageSquare, Settings, Home } from 'lucide-react'; import { MessageSquare, Settings, Home } from 'lucide-react';
import { import { FE_PARENTS_HOME_URL, FE_PARENTS_MESSAGERIE_URL } from '@/utils/Url';
FE_PARENTS_HOME_URL,
FE_PARENTS_MESSAGERIE_URL
} from '@/utils/Url';
import ProtectedRoute from '@/components/ProtectedRoute'; import ProtectedRoute from '@/components/ProtectedRoute';
import { disconnect } from '@/app/actions/authAction'; import { disconnect } from '@/app/actions/authAction';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
@ -15,6 +12,7 @@ import MobileTopbar from '@/components/MobileTopbar';
import { RIGHTS } from '@/utils/rights'; import { RIGHTS } from '@/utils/rights';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import Footer from '@/components/Footer'; import Footer from '@/components/Footer';
import { useChatConnection } from '@/context/ChatConnectionContext';
export default function Layout({ children }) { export default function Layout({ children }) {
const router = useRouter(); const router = useRouter();
@ -22,6 +20,7 @@ export default function Layout({ children }) {
const [isPopupVisible, setIsPopupVisible] = useState(false); const [isPopupVisible, setIsPopupVisible] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const { clearContext } = useEstablishment(); const { clearContext } = useEstablishment();
const { totalUnreadCount, resetUnreadCount } = useChatConnection();
const softwareName = 'N3WT School'; const softwareName = 'N3WT School';
const softwareVersion = `${process.env.NEXT_PUBLIC_APP_VERSION}`; const softwareVersion = `${process.env.NEXT_PUBLIC_APP_VERSION}`;
@ -41,7 +40,8 @@ export default function Layout({ children }) {
name: 'Messagerie', name: 'Messagerie',
url: FE_PARENTS_MESSAGERIE_URL, url: FE_PARENTS_MESSAGERIE_URL,
icon: MessageSquare, icon: MessageSquare,
} badge: totalUnreadCount,
},
]; ];
// Déterminer la page actuelle pour la sidebar // Déterminer la page actuelle pour la sidebar
@ -70,7 +70,11 @@ export default function Layout({ children }) {
useEffect(() => { useEffect(() => {
// Fermer la sidebar quand on change de page sur mobile // Fermer la sidebar quand on change de page sur mobile
setIsSidebarOpen(false); setIsSidebarOpen(false);
}, [pathname]); // Réinitialiser le compteur non lu quand on ouvre la messagerie
if (pathname?.includes('/messagerie')) {
resetUnreadCount();
}
}, [pathname, resetUnreadCount]);
return ( return (
<ProtectedRoute requiredRight={RIGHTS.PARENT}> <ProtectedRoute requiredRight={RIGHTS.PARENT}>

View File

@ -80,12 +80,7 @@ export default function Page() {
return <Loader />; // Affichez le composant Loader return <Loader />; // Affichez le composant Loader
} else { } else {
return ( return (
<div <div className="min-h-screen flex items-center justify-center p-4 bg-neutral">
className="min-h-screen flex items-center justify-center p-4"
style={{
background: 'linear-gradient(135deg, #80fdd6 100%, #2bb180 100%)',
}}
>
<div className="bg-white rounded-md border border-gray-200 shadow-sm p-8 w-full max-w-md"> <div className="bg-white rounded-md border border-gray-200 shadow-sm p-8 w-full max-w-md">
<div className="flex justify-center mb-6"> <div className="flex justify-center mb-6">
<Logo className="h-150 w-150" /> <Logo className="h-150 w-150" />

View File

@ -10,7 +10,7 @@ export default function Footer({ softwareName, softwareVersion }) {
</div> </div>
<div className="text-sm font-light flex items-center justify-between"> <div className="text-sm font-light flex items-center justify-between">
<div className="text-sm font-light mr-4"> <div className="text-sm font-light mr-4">
{softwareName} - {softwareVersion} &nbsp;{softwareName} - {softwareVersion}
</div> </div>
<Logo className="w-8 h-8" /> <Logo className="w-8 h-8" />
</div> </div>

View File

@ -3,14 +3,21 @@ import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import ProfileSelector from '@/components/ProfileSelector'; import ProfileSelector from '@/components/ProfileSelector';
const SidebarItem = ({ icon: Icon, text, active, url, onClick }) => ( const SidebarItem = ({ icon: Icon, text, active, url, onClick, badge }) => (
<div <div
onClick={onClick} onClick={onClick}
className={`flex items-center gap-3 px-2 py-2 rounded-md cursor-pointer hover:bg-primary/10 ${ className={`flex items-center gap-3 px-2 py-2 rounded-md cursor-pointer hover:bg-primary/10 ${
active ? 'bg-primary/5 text-primary' : 'text-gray-600' active ? 'bg-primary/5 text-primary' : 'text-gray-600'
}`} }`}
> >
<div className="relative shrink-0">
<Icon size={20} /> <Icon size={20} />
{badge > 0 && (
<span className="absolute -top-1.5 -right-1.5 min-w-[16px] h-4 px-0.5 bg-red-500 text-white text-[10px] font-label font-semibold rounded-full flex items-center justify-center leading-none">
{badge > 99 ? '99+' : badge}
</span>
)}
</div>
<span>{text}</span> <span>{text}</span>
</div> </div>
); );
@ -45,6 +52,7 @@ function Sidebar({ currentPage, items, onCloseMobile }) {
text={item.name} text={item.name}
active={item.id === selectedItem} active={item.id === selectedItem}
url={item.url} url={item.url}
badge={item.badge}
onClick={() => handleItemClick(item.url)} onClick={() => handleItemClick(item.url)}
/> />
))} ))}

View File

@ -6,9 +6,12 @@ import React, {
useRef, useRef,
useCallback, useCallback,
} from 'react'; } from 'react';
import { useSession, getSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { WS_CHAT_URL } from '@/utils/Url'; import {
WS_CHAT_URL,
BE_GESTIONMESSAGERIE_CONVERSATIONS_URL,
} from '@/utils/Url';
const ChatConnectionContext = createContext(); const ChatConnectionContext = createContext();
@ -22,6 +25,8 @@ export const ChatConnectionProvider = ({ children }) => {
const [reconnectAttempts, setReconnectAttempts] = useState(0); const [reconnectAttempts, setReconnectAttempts] = useState(0);
const [currentUserId, setCurrentUserId] = useState(null); const [currentUserId, setCurrentUserId] = useState(null);
const maxReconnectAttempts = 5; const maxReconnectAttempts = 5;
const isConnectedRef = useRef(false);
const [totalUnreadCount, setTotalUnreadCount] = useState(0);
// Système de callbacks pour les messages // Système de callbacks pour les messages
const messageCallbacksRef = useRef(new Set()); const messageCallbacksRef = useRef(new Set());
@ -54,15 +59,13 @@ export const ChatConnectionProvider = ({ children }) => {
}, []); }, []);
// Configuration WebSocket // Configuration WebSocket
const getWebSocketUrl = async (userId) => { const getWebSocketUrl = (userId) => {
if (!userId) { if (!userId) {
logger.warn('ChatConnection: No user ID provided for WebSocket URL'); logger.warn('ChatConnection: No user ID provided for WebSocket URL');
return null; return null;
} }
// Forcer un refresh de session pour obtenir un token JWT valide const token = session?.user?.token;
const freshSession = await getSession();
const token = freshSession?.user?.token;
if (!token) { if (!token) {
logger.warn( logger.warn(
@ -71,11 +74,8 @@ export const ChatConnectionProvider = ({ children }) => {
return null; return null;
} }
// Construire l'URL WebSocket avec le token
const baseUrl = WS_CHAT_URL(userId); const baseUrl = WS_CHAT_URL(userId);
const wsUrl = `${baseUrl}?token=${encodeURIComponent(token)}`; return `${baseUrl}?token=${encodeURIComponent(token)}`;
return wsUrl;
}; };
// Connexion WebSocket // Connexion WebSocket
@ -108,7 +108,7 @@ export const ChatConnectionProvider = ({ children }) => {
setConnectionStatus('connecting'); setConnectionStatus('connecting');
try { try {
const wsUrl = await getWebSocketUrl(userIdToUse); const wsUrl = getWebSocketUrl(userIdToUse);
if (!wsUrl) { if (!wsUrl) {
throw new Error( throw new Error(
@ -123,12 +123,15 @@ export const ChatConnectionProvider = ({ children }) => {
'ChatConnection: Connected successfully for user:', 'ChatConnection: Connected successfully for user:',
userIdToUse userIdToUse
); );
isConnectedRef.current = true;
setIsConnected(true); setIsConnected(true);
setConnectionStatus('connected'); setConnectionStatus('connected');
setReconnectAttempts(0); setReconnectAttempts(0);
fetchInitialUnreadCount();
}; };
websocketRef.current.onclose = (event) => { websocketRef.current.onclose = (event) => {
isConnectedRef.current = false;
setIsConnected(false); setIsConnected(false);
setConnectionStatus('disconnected'); setConnectionStatus('disconnected');
@ -149,6 +152,7 @@ export const ChatConnectionProvider = ({ children }) => {
websocketRef.current.onerror = (error) => { websocketRef.current.onerror = (error) => {
logger.error('ChatConnection: WebSocket error', error); logger.error('ChatConnection: WebSocket error', error);
setConnectionStatus('error'); setConnectionStatus('error');
isConnectedRef.current = false;
setIsConnected(false); setIsConnected(false);
}; };
@ -161,6 +165,17 @@ export const ChatConnectionProvider = ({ children }) => {
handlePresenceUpdate(data); handlePresenceUpdate(data);
} }
// Incrémenter le compteur de messages non lus si c'est un message entrant
if (data.type === 'new_message' && data.message) {
const senderId = String(
data.message.sender?.id ?? data.message.sender_id ?? ''
);
const currentId = String(session?.user?.user_id ?? '');
if (senderId && currentId && senderId !== currentId) {
setTotalUnreadCount((prev) => prev + 1);
}
}
// Notifier tous les callbacks enregistrés // Notifier tous les callbacks enregistrés
notifyMessageCallbacks(data); notifyMessageCallbacks(data);
} catch (error) { } catch (error) {
@ -170,6 +185,7 @@ export const ChatConnectionProvider = ({ children }) => {
} catch (error) { } catch (error) {
logger.error('ChatConnection: Error creating WebSocket', error); logger.error('ChatConnection: Error creating WebSocket', error);
setConnectionStatus('error'); setConnectionStatus('error');
isConnectedRef.current = false;
setIsConnected(false); setIsConnected(false);
} }
}; };
@ -186,6 +202,7 @@ export const ChatConnectionProvider = ({ children }) => {
websocketRef.current = null; websocketRef.current = null;
} }
isConnectedRef.current = false;
setIsConnected(false); setIsConnected(false);
setConnectionStatus('disconnected'); setConnectionStatus('disconnected');
setReconnectAttempts(0); setReconnectAttempts(0);
@ -207,24 +224,51 @@ export const ChatConnectionProvider = ({ children }) => {
// Obtenir la référence WebSocket pour les composants qui en ont besoin // Obtenir la référence WebSocket pour les composants qui en ont besoin
const getWebSocket = () => websocketRef.current; const getWebSocket = () => websocketRef.current;
// Réinitialiser le compteur de messages non lus (ex: quand on ouvre la messagerie)
const resetUnreadCount = useCallback(() => {
setTotalUnreadCount(0);
}, []);
// Récupérer le total initial des messages non lus depuis l'API
const fetchInitialUnreadCount = useCallback(async () => {
const token = session?.user?.token;
if (!token) return;
try {
const resp = await fetch(BE_GESTIONMESSAGERIE_CONVERSATIONS_URL, {
credentials: 'include',
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) return;
const data = await resp.json();
const conversations = Array.isArray(data) ? data : (data.results ?? []);
const total = conversations.reduce(
(sum, conv) => sum + (conv.unread_count || 0),
0
);
setTotalUnreadCount(total);
} catch (e) {
logger.warn(
'ChatConnection: impossible de récupérer le compteur non lu initial',
e
);
}
}, [session?.user?.token]);
// Effet pour la gestion de la session et connexion automatique // Effet pour la gestion de la session et connexion automatique
useEffect(() => { useEffect(() => {
// Si la session change vers authenticated et qu'on a un user_id, essayer de se connecter if (
if (status === 'authenticated' && session?.user?.user_id && !isConnected) { status === 'authenticated' &&
session?.user?.user_id &&
!isConnectedRef.current
) {
connectToChat(session.user.user_id); connectToChat(session.user.user_id);
} }
// Si la session devient unauthenticated, déconnecter if (status === 'unauthenticated' && isConnectedRef.current) {
if (status === 'unauthenticated' && isConnected) {
disconnectFromChat(); disconnectFromChat();
} }
}, [ // eslint-disable-next-line react-hooks/exhaustive-deps
status, }, [status, session?.user?.user_id]);
session?.user?.user_id,
isConnected,
connectToChat,
disconnectFromChat,
]);
// Nettoyage à la destruction du composant // Nettoyage à la destruction du composant
useEffect(() => { useEffect(() => {
@ -241,14 +285,16 @@ export const ChatConnectionProvider = ({ children }) => {
const value = { const value = {
isConnected, isConnected,
connectionStatus, connectionStatus,
userPresences, // Ajouter les présences utilisateur userPresences,
connectToChat, connectToChat,
disconnectFromChat, disconnectFromChat,
sendMessage, sendMessage,
getWebSocket, getWebSocket,
reconnectAttempts, reconnectAttempts,
maxReconnectAttempts, maxReconnectAttempts,
addMessageCallback, // Ajouter cette fonction addMessageCallback,
totalUnreadCount,
resetUnreadCount,
}; };
return ( return (

View File

@ -1,4 +1,3 @@
import { logger } from '@/utils/logger';
import { getToken } from 'next-auth/jwt'; import { getToken } from 'next-auth/jwt';
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL; const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL;
@ -49,7 +48,6 @@ export default async function handler(req, res) {
const buffer = Buffer.from(await backendRes.arrayBuffer()); const buffer = Buffer.from(await backendRes.arrayBuffer());
return res.send(buffer); return res.send(buffer);
} catch (error) { } catch (error) {
logger.error('Download proxy error:', error);
return res.status(500).json({ error: 'Erreur lors du téléchargement' }); return res.status(500).json({ error: 'Erreur lors du téléchargement' });
} }
} }