mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-01-28 15:33:22 +00:00
feat: mise en place de la messagerie [#17]
This commit is contained in:
7
.github/copilot-instructions.md
vendored
7
.github/copilot-instructions.md
vendored
@ -35,6 +35,13 @@ Corriger ou améliorer le projet N3WT-SCHOOL de manière minimaliste et fonction
|
|||||||
|
|
||||||
## Exigences qualité
|
## Exigences qualité
|
||||||
|
|
||||||
|
Pour le front-end, les exigences de qualité sont les suivantes :
|
||||||
|
|
||||||
|
- **Linting** : Utiliser ESLint pour le code JavaScript/TypeScript
|
||||||
|
- **Formatage** : Utiliser Prettier pour le formatage du code
|
||||||
|
- **Tests** : Utiliser Jest pour les tests unitaires et d'intégration
|
||||||
|
- Référence : [frontend guideline](./instructions/frontend.instruction.md)
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
- Tests unitaires obligatoires pour chaque nouvelle fonctionnalité
|
- Tests unitaires obligatoires pour chaque nouvelle fonctionnalité
|
||||||
|
|||||||
0
.github/instructions/frontend.instruction.md
vendored
Normal file
0
.github/instructions/frontend.instruction.md
vendored
Normal file
24
.vscode/tasks.json
vendored
24
.vscode/tasks.json
vendored
@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
"type": "npm",
|
"label": "Start Frontend Dev Server",
|
||||||
"script": "dev",
|
"type": "shell",
|
||||||
"path": "Front-End",
|
"command": "npm run dev",
|
||||||
"problemMatcher": [],
|
"group": "build",
|
||||||
"label": "npm: dev - Front-End",
|
"isBackground": true,
|
||||||
"detail": "next dev"
|
"problemMatcher": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,61 +0,0 @@
|
|||||||
{% load static %}
|
|
||||||
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<link rel="stylesheet" href="{% static 'css/main.css' %}">
|
|
||||||
<title>Monteschool</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="sidebar">
|
|
||||||
</div>
|
|
||||||
<div class="container">
|
|
||||||
<div class="logo-circular centered">
|
|
||||||
<img src="{% static 'img/logo_min.svg' %}" alt="">
|
|
||||||
</div>
|
|
||||||
<h1 class="login-heading">Authentification</h1>
|
|
||||||
<form class="centered login-form" method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<div class="input-group">
|
|
||||||
<label for="userInput">{{ form.email.label }}</label>
|
|
||||||
<div class="input-wrapper">
|
|
||||||
<span class="icon-ctn">
|
|
||||||
<i class="icon user"></i>
|
|
||||||
</span>
|
|
||||||
<input type="text" id="userInput" placeholder='Identifiant' name="email">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="input-group">
|
|
||||||
<label for="userInput">{{ form.password.label }}</label>
|
|
||||||
<div class="input-wrapper">
|
|
||||||
<span class="icon-ctn">
|
|
||||||
<i class="icon key"></i>
|
|
||||||
</span>
|
|
||||||
<input type="password" id="userInput" placeholder="Mot de passe" name="password">
|
|
||||||
</div>
|
|
||||||
<p style="color:#FF0000">{{ message }}</p>
|
|
||||||
{% if form.errors %}
|
|
||||||
{% for field in form %}
|
|
||||||
{% for error in field.errors %}
|
|
||||||
<p style="color:#FF0000">{{ error }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
{% endfor %}
|
|
||||||
{% for error in form.non_field_errors %}
|
|
||||||
<p style="color:#FF0000">{{ error }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
<label><a class="right" href='/reset/{{code}}'>Mot de passe oublié ?</a></label>
|
|
||||||
</div>
|
|
||||||
<div class="form-group-submit">
|
|
||||||
<button href="" class="btn primary" type="submit" name="connect">Se Connecter</button>
|
|
||||||
<br>
|
|
||||||
<h2>Pas de compte ?</h2>
|
|
||||||
<br>
|
|
||||||
<button href="" class="btn " name="register">S'inscrire</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
{% load static %}
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<link rel="stylesheet" href="{% static 'css/main.css' %}">
|
|
||||||
<title>Monteschool</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container negative full-size">
|
|
||||||
<div class="logo-circular centered">
|
|
||||||
<img src="{% static 'img/logo_min.svg' %}" alt="">
|
|
||||||
</div>
|
|
||||||
<h1 class="login-heading">Nouveau Mot de Passe</h1>
|
|
||||||
<form class="negative centered login-form" method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<div class="input-group" hidden>
|
|
||||||
<label for="userInput">Identifiant</label>
|
|
||||||
<div class="input-wrapper">
|
|
||||||
<span class="icon-ctn">
|
|
||||||
<i class="icon user"></i>
|
|
||||||
</span>
|
|
||||||
<input type="text" id="userInput" placeholder='Identifiant' value='{{ identifiant }}' name="email">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="input-group">
|
|
||||||
<label for="password">{{ form.password1.label }}</label>
|
|
||||||
<div class="input-wrapper">
|
|
||||||
<span class="icon-ctn">
|
|
||||||
<i class="icon key"></i>
|
|
||||||
</span>
|
|
||||||
<input type="password" id="password" placeholder="{{ form.password1.label }}" name="password1">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="input-group">
|
|
||||||
<label for="confirmPassword">{{ form.password2.label }}</label>
|
|
||||||
<div class="input-wrapper">
|
|
||||||
<span class="icon-ctn">
|
|
||||||
<i class="icon key"></i>
|
|
||||||
</span>
|
|
||||||
<input type="password" id="confirmPassword" placeholder="{{ form.password2.label }}" name="password2">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p style="color:#FF0000">{{ message }}</p>
|
|
||||||
{% if form.errors %}
|
|
||||||
{% for field in form %}
|
|
||||||
{% for error in field.errors %}
|
|
||||||
<p style="color:#FF0000">{{ error }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
{% endfor %}
|
|
||||||
{% for error in form.non_field_errors %}
|
|
||||||
<p style="color:#FF0000">{{ error }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
<div class="form-group-submit negative">
|
|
||||||
<button href="" class="btn primary" type="submit" name="save">Enregistrer</button>
|
|
||||||
<br>
|
|
||||||
<button href="" class="btn" type="submit" name="cancel">Annuler</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
{% load static %}
|
|
||||||
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<link rel="stylesheet" href="{% static 'css/main.css' %}">
|
|
||||||
<title>Monteschool</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container negative full-size">
|
|
||||||
<div class="logo-circular centered">
|
|
||||||
<img src="{% static 'img/logo_min.svg' %}" alt="">
|
|
||||||
</div>
|
|
||||||
<h1 class="login-heading"> Réinitialiser Mot de Passe</h1>
|
|
||||||
<form class="negative centered login-form" method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<div class="input-group">
|
|
||||||
<label for="username">Identifiant</label>
|
|
||||||
<div class="input-wrapper">
|
|
||||||
<span class="icon-ctn">
|
|
||||||
<i class="icon user"></i>
|
|
||||||
</span>
|
|
||||||
<input type="text" id="username" placeholder="Identifiant" name="email">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p style="color:#FF0000">{{ message }}</p>
|
|
||||||
<div class="form-group-submit negative">
|
|
||||||
<button href="" class="btn primary" type="submit" name="reinit">Réinitialiser</button>
|
|
||||||
<br>
|
|
||||||
<button href="" class="btn" type="submit" name="cancel">Annuler</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
{% load static %}
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<link rel="stylesheet" href="{% static 'css/main.css' %}">
|
|
||||||
<title>Monteschool</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container negative full-size">
|
|
||||||
<div class="logo-circular centered">
|
|
||||||
<img src="{% static 'img/logo_min.svg' %}" alt="">
|
|
||||||
</div>
|
|
||||||
<h1 class="login-heading">S'inscrire</h1>
|
|
||||||
<form class="negative centered login-form" method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<div class="input-group">
|
|
||||||
<label for="username">{{ form.email.label }}</label>
|
|
||||||
<div class="input-wrapper">
|
|
||||||
<span class="icon-ctn">
|
|
||||||
<i class="icon user"></i>
|
|
||||||
</span>
|
|
||||||
<input type="text" id="username" placeholder="Identifiant" name="email">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="input-group">
|
|
||||||
<label for="password">{{ form.password1.label }}</label>
|
|
||||||
<div class="input-wrapper">
|
|
||||||
<span class="icon-ctn">
|
|
||||||
<i class="icon key"></i>
|
|
||||||
</span>
|
|
||||||
<input type="password" id="password" placeholder="{{ form.password1.label }}" name="password1">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="input-group">
|
|
||||||
<label for="confirmPassword">{{ form.password2.label }}</label>
|
|
||||||
<div class="input-wrapper">
|
|
||||||
<span class="icon-ctn">
|
|
||||||
<i class="icon key"></i>
|
|
||||||
</span>
|
|
||||||
<input type="password" id="confirmPassword" placeholder="{{ form.password2.label }}" name="password2">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p style="color:#FF0000">{{ message }}</p>
|
|
||||||
{% if form.errors %}
|
|
||||||
{% for field in form %}
|
|
||||||
{% for error in field.errors %}
|
|
||||||
<p style="color:#FF0000">{{ error }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
{% endfor %}
|
|
||||||
{% for error in form.non_field_errors %}
|
|
||||||
<p style="color:#FF0000">{{ error }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
<div class="form-group-submit negative">
|
|
||||||
<button href="" class="btn primary" type="submit" name="validate">Enregistrer</button>
|
|
||||||
<br>
|
|
||||||
<button href="" class="btn" name="cancel">Annuler</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
1
Back-End/GestionEmail/__init__.py
Normal file
1
Back-End/GestionEmail/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
default_app_config = 'GestionEmail.apps.GestionEmailConfig'
|
||||||
5
Back-End/GestionEmail/apps.py
Normal file
5
Back-End/GestionEmail/apps.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
class GestionEmailConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'GestionEmail'
|
||||||
9
Back-End/GestionEmail/urls.py
Normal file
9
Back-End/GestionEmail/urls.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from .views import (
|
||||||
|
SendEmailView, search_recipients
|
||||||
|
)
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('send-email/', SendEmailView.as_view(), name='send_email'),
|
||||||
|
path('search-recipients/', search_recipients, name='search_recipients'),
|
||||||
|
]
|
||||||
119
Back-End/GestionEmail/views.py
Normal file
119
Back-End/GestionEmail/views.py
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
from django.http.response import JsonResponse
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import status
|
||||||
|
from django.db.models import Q
|
||||||
|
from Auth.models import Profile, ProfileRole
|
||||||
|
|
||||||
|
import N3wtSchool.mailManager as mailer
|
||||||
|
from N3wtSchool import bdd
|
||||||
|
from drf_yasg.utils import swagger_auto_schema
|
||||||
|
from drf_yasg import openapi
|
||||||
|
from rest_framework.exceptions import NotFound
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Ajouter un logger pour debug
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class SendEmailView(APIView):
|
||||||
|
"""
|
||||||
|
API pour envoyer des emails aux parents et professeurs.
|
||||||
|
"""
|
||||||
|
def post(self, request):
|
||||||
|
# Ajouter du debug
|
||||||
|
logger.info(f"Request data received: {request.data}")
|
||||||
|
logger.info(f"Request content type: {request.content_type}")
|
||||||
|
|
||||||
|
data = request.data
|
||||||
|
recipients = data.get('recipients', [])
|
||||||
|
cc = data.get('cc', [])
|
||||||
|
bcc = data.get('bcc', [])
|
||||||
|
subject = data.get('subject', 'Notification')
|
||||||
|
message = data.get('message', '')
|
||||||
|
establishment_id = data.get('establishment_id', '')
|
||||||
|
|
||||||
|
# Debug des données reçues
|
||||||
|
logger.info(f"Recipients: {recipients} (type: {type(recipients)})")
|
||||||
|
logger.info(f"CC: {cc} (type: {type(cc)})")
|
||||||
|
logger.info(f"BCC: {bcc} (type: {type(bcc)})")
|
||||||
|
logger.info(f"Subject: {subject}")
|
||||||
|
logger.info(f"Message length: {len(message) if message else 0}")
|
||||||
|
logger.info(f"Establishment ID: {establishment_id}")
|
||||||
|
|
||||||
|
if not recipients or not message:
|
||||||
|
logger.error("Recipients or message missing")
|
||||||
|
logger.error(f"Recipients empty: {not recipients}, Message empty: {not message}")
|
||||||
|
logger.error(f"Recipients value: '{recipients}', Message value: '{message}'")
|
||||||
|
return Response({'error': 'Les destinataires et le message sont requis.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Récupérer la connexion SMTP
|
||||||
|
logger.info("Tentative de récupération de la connexion SMTP...")
|
||||||
|
connection = mailer.getConnection(establishment_id)
|
||||||
|
logger.info(f"Connexion SMTP récupérée: {connection}")
|
||||||
|
|
||||||
|
# Envoyer l'email
|
||||||
|
logger.info("Tentative d'envoi de l'email...")
|
||||||
|
result = mailer.sendMail(
|
||||||
|
subject=subject,
|
||||||
|
message=message,
|
||||||
|
recipients=recipients,
|
||||||
|
cc=cc,
|
||||||
|
bcc=bcc,
|
||||||
|
attachments=[],
|
||||||
|
connection=connection
|
||||||
|
)
|
||||||
|
logger.info(f"Email envoyé avec succès: {result}")
|
||||||
|
return result
|
||||||
|
except NotFound as e:
|
||||||
|
logger.error(f"NotFound error: {str(e)}")
|
||||||
|
return Response({'error': str(e)}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Exception during email sending: {str(e)}")
|
||||||
|
logger.error(f"Exception type: {type(e)}")
|
||||||
|
import traceback
|
||||||
|
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||||
|
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
def search_recipients(request):
|
||||||
|
"""
|
||||||
|
API pour rechercher des destinataires en fonction d'un terme de recherche et d'un établissement.
|
||||||
|
"""
|
||||||
|
query = request.GET.get('q', '').strip() # Récupérer le terme de recherche depuis les paramètres GET
|
||||||
|
establishment_id = request.GET.get('establishment_id', None) # Récupérer l'ID de l'établissement
|
||||||
|
|
||||||
|
if not query:
|
||||||
|
return JsonResponse([], safe=False) # Retourner une liste vide si aucun terme n'est fourni
|
||||||
|
|
||||||
|
if not establishment_id:
|
||||||
|
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Rechercher dans les champs pertinents (nom, prénom, email) et filtrer par establishment_id
|
||||||
|
profiles = Profile.objects.filter(
|
||||||
|
Q(first_name__icontains=query) |
|
||||||
|
Q(last_name__icontains=query) |
|
||||||
|
Q(email__icontains=query),
|
||||||
|
roles__establishment_id=establishment_id, # Utiliser 'roles' au lieu de 'profilerole'
|
||||||
|
roles__is_active=True # Filtrer uniquement les ProfileRole actifs
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
# Construire la réponse avec les rôles associés
|
||||||
|
results = []
|
||||||
|
for profile in profiles:
|
||||||
|
profile_roles = ProfileRole.objects.filter(
|
||||||
|
profile=profile,
|
||||||
|
establishment_id=establishment_id,
|
||||||
|
is_active=True # Inclure uniquement les ProfileRole actifs
|
||||||
|
).values(
|
||||||
|
'id', 'role_type', 'establishment__name', 'is_active'
|
||||||
|
)
|
||||||
|
results.append({
|
||||||
|
'id': profile.id,
|
||||||
|
'first_name': profile.first_name,
|
||||||
|
'last_name': profile.last_name,
|
||||||
|
'email': profile.email,
|
||||||
|
'roles': list(profile_roles) # Inclure tous les rôles actifs associés pour cet établissement
|
||||||
|
})
|
||||||
|
|
||||||
|
return JsonResponse(results, safe=False)
|
||||||
627
Back-End/GestionMessagerie/consumers.py
Normal file
627
Back-End/GestionMessagerie/consumers.py
Normal file
@ -0,0 +1,627 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from uuid import UUID
|
||||||
|
from decimal import Decimal
|
||||||
|
from datetime import datetime
|
||||||
|
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||||
|
from channels.db import database_sync_to_async
|
||||||
|
from django.utils import timezone
|
||||||
|
from .models import Conversation, ConversationParticipant, Message, UserPresence, MessageRead
|
||||||
|
from .serializers import MessageSerializer, ConversationSerializer
|
||||||
|
from Auth.models import Profile
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def serialize_for_websocket(data):
|
||||||
|
"""
|
||||||
|
Convertit récursivement les objets non-sérialisables en JSON en types sérialisables
|
||||||
|
"""
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return {key: serialize_for_websocket(value) for key, value in data.items()}
|
||||||
|
elif isinstance(data, list):
|
||||||
|
return [serialize_for_websocket(item) for item in data]
|
||||||
|
elif isinstance(data, UUID):
|
||||||
|
return str(data)
|
||||||
|
elif isinstance(data, Decimal):
|
||||||
|
return float(data)
|
||||||
|
elif isinstance(data, datetime):
|
||||||
|
return data.isoformat()
|
||||||
|
else:
|
||||||
|
return data
|
||||||
|
|
||||||
|
class ChatConsumer(AsyncWebsocketConsumer):
|
||||||
|
"""Consumer WebSocket pour la messagerie instantanée"""
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
self.user_id = self.scope['url_route']['kwargs']['user_id']
|
||||||
|
self.user_group_name = f'user_{self.user_id}'
|
||||||
|
|
||||||
|
# Vérifier si l'utilisateur est authentifié
|
||||||
|
user = self.scope.get('user')
|
||||||
|
if not user or user.is_anonymous:
|
||||||
|
logger.warning(f"Tentative de connexion WebSocket non authentifiée pour user_id: {self.user_id}")
|
||||||
|
await self.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Vérifier que l'utilisateur connecté correspond à l'user_id de l'URL
|
||||||
|
if str(user.id) != str(self.user_id):
|
||||||
|
logger.warning(f"Tentative d'accès WebSocket avec user_id incorrect: {self.user_id} vs {user.id}")
|
||||||
|
await self.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
self.user = user
|
||||||
|
|
||||||
|
# Rejoindre le groupe utilisateur
|
||||||
|
await self.channel_layer.group_add(
|
||||||
|
self.user_group_name,
|
||||||
|
self.channel_name
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rejoindre les groupes des conversations de l'utilisateur
|
||||||
|
conversations = await self.get_user_conversations(self.user_id)
|
||||||
|
for conversation in conversations:
|
||||||
|
await self.channel_layer.group_add(
|
||||||
|
f'conversation_{conversation.id}',
|
||||||
|
self.channel_name
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mettre à jour le statut de présence
|
||||||
|
presence = await self.update_user_presence(self.user_id, 'online')
|
||||||
|
|
||||||
|
# Notifier les autres utilisateurs du changement de statut
|
||||||
|
if presence:
|
||||||
|
await self.broadcast_presence_update(self.user_id, 'online')
|
||||||
|
|
||||||
|
# Envoyer les statuts de présence existants des autres utilisateurs connectés
|
||||||
|
await self.send_existing_user_presences()
|
||||||
|
|
||||||
|
await self.accept()
|
||||||
|
|
||||||
|
logger.info(f"User {self.user_id} connected to chat")
|
||||||
|
|
||||||
|
async def send_existing_user_presences(self):
|
||||||
|
"""Envoyer les statuts de présence existants des autres utilisateurs connectés"""
|
||||||
|
try:
|
||||||
|
# Obtenir toutes les conversations de cet utilisateur
|
||||||
|
conversations = await self.get_user_conversations(self.user_id)
|
||||||
|
|
||||||
|
# Créer un set pour éviter les doublons d'utilisateurs
|
||||||
|
other_users = set()
|
||||||
|
|
||||||
|
# Pour chaque conversation, récupérer les participants
|
||||||
|
for conversation in conversations:
|
||||||
|
participants = await self.get_conversation_participants(conversation.id)
|
||||||
|
for participant in participants:
|
||||||
|
if participant.id != self.user_id:
|
||||||
|
other_users.add(participant.id)
|
||||||
|
|
||||||
|
# Envoyer le statut de présence pour chaque utilisateur
|
||||||
|
for user_id in other_users:
|
||||||
|
presence = await self.get_user_presence(user_id)
|
||||||
|
if presence:
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'presence_update',
|
||||||
|
'user_id': str(user_id),
|
||||||
|
'status': presence.status
|
||||||
|
}))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending existing user presences: {str(e)}")
|
||||||
|
|
||||||
|
async def disconnect(self, close_code):
|
||||||
|
# Quitter tous les groupes
|
||||||
|
await self.channel_layer.group_discard(
|
||||||
|
self.user_group_name,
|
||||||
|
self.channel_name
|
||||||
|
)
|
||||||
|
|
||||||
|
if hasattr(self, 'user'):
|
||||||
|
conversations = await self.get_user_conversations(self.user_id)
|
||||||
|
for conversation in conversations:
|
||||||
|
await self.channel_layer.group_discard(
|
||||||
|
f'conversation_{conversation.id}',
|
||||||
|
self.channel_name
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mettre à jour le statut de présence
|
||||||
|
presence = await self.update_user_presence(self.user_id, 'offline')
|
||||||
|
|
||||||
|
# Notifier les autres utilisateurs du changement de statut
|
||||||
|
if presence:
|
||||||
|
await self.broadcast_presence_update(self.user_id, 'offline')
|
||||||
|
|
||||||
|
logger.info(f"User {self.user_id} disconnected from chat")
|
||||||
|
|
||||||
|
async def receive(self, text_data):
|
||||||
|
"""Recevoir et traiter les messages du client"""
|
||||||
|
try:
|
||||||
|
text_data_json = json.loads(text_data)
|
||||||
|
message_type = text_data_json.get('type')
|
||||||
|
|
||||||
|
if message_type == 'send_message':
|
||||||
|
await self.handle_send_message(text_data_json)
|
||||||
|
elif message_type == 'typing_start':
|
||||||
|
await self.handle_typing_start(text_data_json)
|
||||||
|
elif message_type == 'typing_stop':
|
||||||
|
await self.handle_typing_stop(text_data_json)
|
||||||
|
elif message_type == 'mark_as_read':
|
||||||
|
await self.handle_mark_as_read(text_data_json)
|
||||||
|
elif message_type == 'join_conversation':
|
||||||
|
await self.handle_join_conversation(text_data_json)
|
||||||
|
elif message_type == 'leave_conversation':
|
||||||
|
await self.handle_leave_conversation(text_data_json)
|
||||||
|
elif message_type == 'presence_update':
|
||||||
|
await self.handle_presence_update(text_data_json)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Unknown message type: {message_type}")
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'error',
|
||||||
|
'message': f'Unknown message type: {message_type}'
|
||||||
|
}))
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'error',
|
||||||
|
'message': 'Invalid JSON format'
|
||||||
|
}))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in receive: {str(e)}")
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'error',
|
||||||
|
'message': 'Internal server error'
|
||||||
|
}))
|
||||||
|
|
||||||
|
async def handle_send_message(self, data):
|
||||||
|
"""Gérer l'envoi d'un nouveau message"""
|
||||||
|
conversation_id = data.get('conversation_id')
|
||||||
|
content = data.get('content', '').strip()
|
||||||
|
message_type = data.get('message_type', 'text')
|
||||||
|
attachment = data.get('attachment')
|
||||||
|
|
||||||
|
# Vérifier qu'on a soit du contenu, soit un fichier
|
||||||
|
if not conversation_id or (not content and not attachment):
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'error',
|
||||||
|
'message': 'Conversation ID and content or attachment are required'
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Vérifier que l'utilisateur peut envoyer dans cette conversation
|
||||||
|
can_send = await self.can_user_send_message(self.user_id, conversation_id)
|
||||||
|
if not can_send:
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'error',
|
||||||
|
'message': 'You cannot send messages to this conversation'
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Créer le message avec ou sans fichier
|
||||||
|
message = await self.create_message(conversation_id, self.user_id, content, message_type, attachment)
|
||||||
|
if not message:
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'error',
|
||||||
|
'message': 'Failed to create message'
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Sérialiser le message
|
||||||
|
message_data = await self.serialize_message(message)
|
||||||
|
|
||||||
|
# Auto-marquer comme lu pour les utilisateurs connectés (présents dans la conversation)
|
||||||
|
await self.auto_mark_read_for_online_users(message, conversation_id)
|
||||||
|
|
||||||
|
# Envoyer le message à tous les participants de la conversation
|
||||||
|
await self.channel_layer.group_send(
|
||||||
|
f'conversation_{conversation_id}',
|
||||||
|
{
|
||||||
|
'type': 'chat_message',
|
||||||
|
'message': message_data
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_typing_start(self, data):
|
||||||
|
"""Gérer le début de frappe"""
|
||||||
|
conversation_id = data.get('conversation_id')
|
||||||
|
if conversation_id:
|
||||||
|
await self.update_typing_status(self.user_id, conversation_id, True)
|
||||||
|
|
||||||
|
# Récupérer le nom de l'utilisateur
|
||||||
|
user_name = await self.get_user_display_name(self.user_id)
|
||||||
|
|
||||||
|
await self.channel_layer.group_send(
|
||||||
|
f'conversation_{conversation_id}',
|
||||||
|
{
|
||||||
|
'type': 'typing_status',
|
||||||
|
'user_id': str(self.user_id),
|
||||||
|
'user_name': user_name,
|
||||||
|
'is_typing': True,
|
||||||
|
'conversation_id': str(conversation_id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_typing_stop(self, data):
|
||||||
|
"""Gérer l'arrêt de frappe"""
|
||||||
|
conversation_id = data.get('conversation_id')
|
||||||
|
if conversation_id:
|
||||||
|
await self.update_typing_status(self.user_id, conversation_id, False)
|
||||||
|
|
||||||
|
# Récupérer le nom de l'utilisateur
|
||||||
|
user_name = await self.get_user_display_name(self.user_id)
|
||||||
|
|
||||||
|
await self.channel_layer.group_send(
|
||||||
|
f'conversation_{conversation_id}',
|
||||||
|
{
|
||||||
|
'type': 'typing_status',
|
||||||
|
'user_id': str(self.user_id),
|
||||||
|
'user_name': user_name,
|
||||||
|
'is_typing': False,
|
||||||
|
'conversation_id': str(conversation_id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_mark_as_read(self, data):
|
||||||
|
"""Marquer les messages comme lus"""
|
||||||
|
conversation_id = data.get('conversation_id')
|
||||||
|
if conversation_id:
|
||||||
|
await self.mark_conversation_as_read(self.user_id, conversation_id)
|
||||||
|
await self.channel_layer.group_send(
|
||||||
|
f'conversation_{conversation_id}',
|
||||||
|
{
|
||||||
|
'type': 'messages_read',
|
||||||
|
'user_id': str(self.user_id),
|
||||||
|
'conversation_id': str(conversation_id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_join_conversation(self, data):
|
||||||
|
"""Rejoindre une conversation"""
|
||||||
|
conversation_id = data.get('conversation_id')
|
||||||
|
if conversation_id:
|
||||||
|
await self.channel_layer.group_add(
|
||||||
|
f'conversation_{conversation_id}',
|
||||||
|
self.channel_name
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_leave_conversation(self, data):
|
||||||
|
"""Quitter une conversation"""
|
||||||
|
conversation_id = data.get('conversation_id')
|
||||||
|
if conversation_id:
|
||||||
|
await self.channel_layer.group_discard(
|
||||||
|
f'conversation_{conversation_id}',
|
||||||
|
self.channel_name
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_presence_update(self, data):
|
||||||
|
"""Gérer les mises à jour de présence"""
|
||||||
|
status = data.get('status', 'online')
|
||||||
|
if status in ['online', 'offline', 'away']:
|
||||||
|
await self.update_user_presence(self.user_id, status)
|
||||||
|
await self.broadcast_presence_update(self.user_id, status)
|
||||||
|
|
||||||
|
# Méthodes pour recevoir les messages des groupes
|
||||||
|
async def chat_message(self, event):
|
||||||
|
"""Envoyer un message de chat au WebSocket"""
|
||||||
|
message_data = serialize_for_websocket(event['message'])
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'new_message',
|
||||||
|
'message': message_data
|
||||||
|
}))
|
||||||
|
|
||||||
|
async def typing_status(self, event):
|
||||||
|
"""Envoyer le statut de frappe"""
|
||||||
|
# Ne pas envoyer à l'expéditeur
|
||||||
|
if str(event['user_id']) != str(self.user_id):
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'typing_status',
|
||||||
|
'user_id': str(event['user_id']),
|
||||||
|
'user_name': event.get('user_name', ''),
|
||||||
|
'is_typing': event['is_typing'],
|
||||||
|
'conversation_id': str(event['conversation_id'])
|
||||||
|
}))
|
||||||
|
|
||||||
|
async def messages_read(self, event):
|
||||||
|
"""Notifier que des messages ont été lus"""
|
||||||
|
if str(event['user_id']) != str(self.user_id):
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'messages_read',
|
||||||
|
'user_id': str(event['user_id']),
|
||||||
|
'conversation_id': str(event['conversation_id'])
|
||||||
|
}))
|
||||||
|
|
||||||
|
async def user_presence_update(self, event):
|
||||||
|
"""Notifier d'un changement de présence"""
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'presence_update',
|
||||||
|
'user_id': str(event['user_id']),
|
||||||
|
'status': event['status']
|
||||||
|
}))
|
||||||
|
|
||||||
|
async def new_conversation_notification(self, event):
|
||||||
|
"""Notifier d'une nouvelle conversation"""
|
||||||
|
conversation = serialize_for_websocket(event['conversation'])
|
||||||
|
conversation_id = conversation['id']
|
||||||
|
|
||||||
|
# Rejoindre automatiquement le groupe de la nouvelle conversation
|
||||||
|
await self.channel_layer.group_add(
|
||||||
|
f'conversation_{conversation_id}',
|
||||||
|
self.channel_name
|
||||||
|
)
|
||||||
|
|
||||||
|
# Envoyer la notification au client
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'new_conversation',
|
||||||
|
'conversation': conversation
|
||||||
|
}))
|
||||||
|
|
||||||
|
# Diffuser les présences des participants de cette nouvelle conversation
|
||||||
|
try:
|
||||||
|
participants = await self.get_conversation_participants(conversation_id)
|
||||||
|
for participant in participants:
|
||||||
|
# Ne pas diffuser sa propre présence à soi-même
|
||||||
|
if participant.id != self.user_id:
|
||||||
|
presence = await self.get_user_presence(participant.id)
|
||||||
|
if presence:
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'presence_update',
|
||||||
|
'user_id': str(participant.id),
|
||||||
|
'status': presence.status
|
||||||
|
}))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending presence updates for new conversation: {str(e)}")
|
||||||
|
|
||||||
|
async def broadcast_presence_update(self, user_id, status):
|
||||||
|
"""Diffuser un changement de statut de présence à tous les utilisateurs connectés"""
|
||||||
|
try:
|
||||||
|
# Obtenir tous les utilisateurs qui ont des conversations avec cet utilisateur
|
||||||
|
user_conversations = await self.get_user_conversations(user_id)
|
||||||
|
|
||||||
|
# Créer un set pour éviter les doublons d'utilisateurs
|
||||||
|
notified_users = set()
|
||||||
|
|
||||||
|
# Pour chaque conversation, notifier tous les participants
|
||||||
|
for conversation in user_conversations:
|
||||||
|
participants = await self.get_conversation_participants(conversation.id)
|
||||||
|
for participant in participants:
|
||||||
|
if participant.id != user_id and participant.id not in notified_users:
|
||||||
|
notified_users.add(participant.id)
|
||||||
|
# Envoyer la notification au groupe utilisateur
|
||||||
|
await self.channel_layer.group_send(
|
||||||
|
f'user_{participant.id}',
|
||||||
|
{
|
||||||
|
'type': 'user_presence_update',
|
||||||
|
'user_id': user_id,
|
||||||
|
'status': status
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Broadcasted presence update for user {user_id} ({status}) to {len(notified_users)} users")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error broadcasting presence update: {str(e)}")
|
||||||
|
|
||||||
|
# Méthodes d'accès aux données (database_sync_to_async)
|
||||||
|
@database_sync_to_async
|
||||||
|
def get_user(self, user_id):
|
||||||
|
try:
|
||||||
|
return Profile.objects.get(id=user_id)
|
||||||
|
except Profile.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def get_user_display_name(self, user_id):
|
||||||
|
"""Obtenir le nom d'affichage d'un utilisateur"""
|
||||||
|
try:
|
||||||
|
user = Profile.objects.get(id=user_id)
|
||||||
|
if user.first_name and user.last_name:
|
||||||
|
return f"{user.first_name} {user.last_name}"
|
||||||
|
elif user.first_name:
|
||||||
|
return user.first_name
|
||||||
|
elif user.last_name:
|
||||||
|
return user.last_name
|
||||||
|
else:
|
||||||
|
return user.email or f"Utilisateur {user_id}"
|
||||||
|
except Profile.DoesNotExist:
|
||||||
|
return f"Utilisateur {user_id}"
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def get_user_conversations(self, user_id):
|
||||||
|
return list(Conversation.objects.filter(
|
||||||
|
participants__participant_id=user_id,
|
||||||
|
participants__is_active=True,
|
||||||
|
is_active=True
|
||||||
|
).distinct())
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def get_conversation_participants(self, conversation_id):
|
||||||
|
"""Obtenir tous les participants d'une conversation"""
|
||||||
|
return list(Profile.objects.filter(
|
||||||
|
conversation_participants__conversation_id=conversation_id,
|
||||||
|
conversation_participants__is_active=True
|
||||||
|
))
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def get_conversations_data(self, user_id):
|
||||||
|
try:
|
||||||
|
user = Profile.objects.get(id=user_id)
|
||||||
|
conversations = Conversation.objects.filter(
|
||||||
|
participants__participant=user,
|
||||||
|
participants__is_active=True,
|
||||||
|
is_active=True
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
serializer = ConversationSerializer(conversations, many=True, context={'user': user})
|
||||||
|
return serializer.data
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting conversations data: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def can_user_send_message(self, user_id, conversation_id):
|
||||||
|
return ConversationParticipant.objects.filter(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
participant_id=user_id,
|
||||||
|
is_active=True
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def create_message(self, conversation_id, sender_id, content, message_type, attachment=None):
|
||||||
|
try:
|
||||||
|
conversation = Conversation.objects.get(id=conversation_id)
|
||||||
|
sender = Profile.objects.get(id=sender_id)
|
||||||
|
|
||||||
|
message_data = {
|
||||||
|
'conversation': conversation,
|
||||||
|
'sender': sender,
|
||||||
|
'content': content,
|
||||||
|
'message_type': message_type
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ajouter les informations du fichier si présent
|
||||||
|
if attachment:
|
||||||
|
message_data.update({
|
||||||
|
'file_url': attachment.get('fileUrl'),
|
||||||
|
'file_name': attachment.get('fileName'),
|
||||||
|
'file_size': attachment.get('fileSize'),
|
||||||
|
'file_type': attachment.get('fileType'),
|
||||||
|
})
|
||||||
|
# Si c'est un fichier, s'assurer que le type de message est correct
|
||||||
|
if attachment.get('fileType', '').startswith('image/'):
|
||||||
|
message_data['message_type'] = 'image'
|
||||||
|
else:
|
||||||
|
message_data['message_type'] = 'file'
|
||||||
|
|
||||||
|
message = Message.objects.create(**message_data)
|
||||||
|
|
||||||
|
# Mettre à jour l'activité de la conversation
|
||||||
|
conversation.last_activity = message.created_at
|
||||||
|
conversation.save(update_fields=['last_activity'])
|
||||||
|
|
||||||
|
return message
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating message: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def serialize_message(self, message):
|
||||||
|
serializer = MessageSerializer(message)
|
||||||
|
return serialize_for_websocket(serializer.data)
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def get_user_presence(self, user_id):
|
||||||
|
"""Récupérer la présence d'un utilisateur"""
|
||||||
|
try:
|
||||||
|
return UserPresence.objects.get(user_id=user_id)
|
||||||
|
except UserPresence.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def update_user_presence(self, user_id, status):
|
||||||
|
try:
|
||||||
|
user = Profile.objects.get(id=user_id)
|
||||||
|
presence, created = UserPresence.objects.get_or_create(user=user)
|
||||||
|
old_status = presence.status
|
||||||
|
presence.status = status
|
||||||
|
presence.save()
|
||||||
|
|
||||||
|
# Si le statut a changé, notifier les autres utilisateurs
|
||||||
|
if old_status != status or created:
|
||||||
|
logger.info(f"User {user_id} presence changed from {old_status} to {status}")
|
||||||
|
|
||||||
|
return presence
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating user presence: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def update_typing_status(self, user_id, conversation_id, is_typing):
|
||||||
|
try:
|
||||||
|
user = Profile.objects.get(id=user_id)
|
||||||
|
presence, created = UserPresence.objects.get_or_create(user=user)
|
||||||
|
if is_typing:
|
||||||
|
conversation = Conversation.objects.get(id=conversation_id)
|
||||||
|
presence.is_typing_in = conversation
|
||||||
|
else:
|
||||||
|
presence.is_typing_in = None
|
||||||
|
presence.save()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating typing status: {str(e)}")
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def mark_conversation_as_read(self, user_id, conversation_id):
|
||||||
|
"""Marquer tous les messages non lus d'une conversation comme lus"""
|
||||||
|
try:
|
||||||
|
# Mettre à jour le last_read_at du participant
|
||||||
|
participant = ConversationParticipant.objects.get(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
participant_id=user_id
|
||||||
|
)
|
||||||
|
current_time = timezone.now()
|
||||||
|
participant.last_read_at = current_time
|
||||||
|
participant.save(update_fields=['last_read_at'])
|
||||||
|
|
||||||
|
# Créer des enregistrements MessageRead pour tous les messages non lus
|
||||||
|
# que l'utilisateur n'a pas encore explicitement lus
|
||||||
|
unread_messages = Message.objects.filter(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
created_at__lte=current_time,
|
||||||
|
is_deleted=False
|
||||||
|
).exclude(
|
||||||
|
sender_id=user_id # Exclure ses propres messages
|
||||||
|
).exclude(
|
||||||
|
read_by__participant_id=user_id # Exclure les messages déjà marqués comme lus
|
||||||
|
)
|
||||||
|
|
||||||
|
# Créer les enregistrements MessageRead en batch
|
||||||
|
message_reads = [
|
||||||
|
MessageRead(message=message, participant_id=user_id, read_at=current_time)
|
||||||
|
for message in unread_messages
|
||||||
|
]
|
||||||
|
|
||||||
|
if message_reads:
|
||||||
|
MessageRead.objects.bulk_create(message_reads, ignore_conflicts=True)
|
||||||
|
logger.info(f"Marked {len(message_reads)} messages as read for user {user_id} in conversation {conversation_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error marking conversation as read: {str(e)}")
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def auto_mark_read_for_online_users(self, message, conversation_id):
|
||||||
|
"""Auto-marquer comme lu pour les utilisateurs en ligne dans la conversation"""
|
||||||
|
try:
|
||||||
|
# Obtenir tous les participants de la conversation (synchrone)
|
||||||
|
participants = ConversationParticipant.objects.filter(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
is_active=True
|
||||||
|
).exclude(participant_id=message.sender.id)
|
||||||
|
|
||||||
|
# Obtenir l'heure de création du message
|
||||||
|
message_time = message.created_at
|
||||||
|
|
||||||
|
# Préparer les enregistrements MessageRead à créer
|
||||||
|
message_reads = []
|
||||||
|
|
||||||
|
for participant_obj in participants:
|
||||||
|
participant = participant_obj.participant
|
||||||
|
|
||||||
|
# Vérifier si l'utilisateur est en ligne (synchrone)
|
||||||
|
try:
|
||||||
|
presence = UserPresence.objects.filter(user=participant).first()
|
||||||
|
if presence and presence.status == 'online':
|
||||||
|
# Vérifier qu'il n'existe pas déjà un enregistrement MessageRead
|
||||||
|
if not MessageRead.objects.filter(message=message, participant=participant).exists():
|
||||||
|
message_reads.append(MessageRead(
|
||||||
|
message=message,
|
||||||
|
participant=participant,
|
||||||
|
read_at=message_time
|
||||||
|
))
|
||||||
|
except:
|
||||||
|
# En cas d'erreur de présence, ne pas marquer comme lu
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Créer les enregistrements MessageRead en batch
|
||||||
|
if message_reads:
|
||||||
|
MessageRead.objects.bulk_create(message_reads, ignore_conflicts=True)
|
||||||
|
logger.info(f"Auto-marked {len(message_reads)} messages as read for online users in conversation {conversation_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in auto_mark_read_for_online_users: {str(e)}")
|
||||||
108
Back-End/GestionMessagerie/middleware.py
Normal file
108
Back-End/GestionMessagerie/middleware.py
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import jwt
|
||||||
|
import logging
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from channels.middleware import BaseMiddleware
|
||||||
|
from channels.db import database_sync_to_async
|
||||||
|
from Auth.models import Profile
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def get_user(user_id):
|
||||||
|
"""Récupérer l'utilisateur de manière asynchrone"""
|
||||||
|
try:
|
||||||
|
return Profile.objects.get(id=user_id)
|
||||||
|
except Profile.DoesNotExist:
|
||||||
|
return AnonymousUser()
|
||||||
|
|
||||||
|
class JWTAuthMiddleware(BaseMiddleware):
|
||||||
|
"""Middleware pour l'authentification JWT dans les WebSockets"""
|
||||||
|
|
||||||
|
def __init__(self, inner):
|
||||||
|
super().__init__(inner)
|
||||||
|
|
||||||
|
def _check_cors_origin(self, scope):
|
||||||
|
"""Vérifier si l'origine est autorisée pour les WebSockets"""
|
||||||
|
origin = None
|
||||||
|
|
||||||
|
# Récupérer l'origine depuis les headers
|
||||||
|
for name, value in scope.get('headers', []):
|
||||||
|
if name == b'origin':
|
||||||
|
origin = value.decode('latin1')
|
||||||
|
break
|
||||||
|
|
||||||
|
if not origin:
|
||||||
|
logger.warning("Aucune origine trouvée dans les headers WebSocket")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Récupérer les origines autorisées depuis la configuration CORS
|
||||||
|
allowed_origins = getattr(settings, 'CORS_ALLOWED_ORIGINS', [])
|
||||||
|
|
||||||
|
# Si CORS_ORIGIN_ALLOW_ALL est True, autoriser toutes les origines
|
||||||
|
if getattr(settings, 'CORS_ORIGIN_ALLOW_ALL', False):
|
||||||
|
logger.info(f"Origine WebSocket autorisée (CORS_ORIGIN_ALLOW_ALL): {origin}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Vérifier si l'origine est dans la liste des origines autorisées
|
||||||
|
if origin in allowed_origins:
|
||||||
|
logger.info(f"Origine WebSocket autorisée: {origin}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.warning(f"Origine WebSocket non autorisée: {origin}. Origines autorisées: {allowed_origins}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
# Vérifier les CORS pour les WebSockets
|
||||||
|
if not self._check_cors_origin(scope):
|
||||||
|
logger.error("Connexion WebSocket refusée: origine non autorisée")
|
||||||
|
# Fermer la connexion WebSocket avec un code d'erreur
|
||||||
|
await send({
|
||||||
|
'type': 'websocket.close',
|
||||||
|
'code': 1008 # Policy Violation
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Extraire le token de l'URL
|
||||||
|
query_string = parse_qs(scope['query_string'].decode())
|
||||||
|
token = query_string.get('token')
|
||||||
|
|
||||||
|
if token:
|
||||||
|
token = token[0]
|
||||||
|
try:
|
||||||
|
# Décoder le token JWT
|
||||||
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.SIMPLE_JWT['SIGNING_KEY'],
|
||||||
|
algorithms=[settings.SIMPLE_JWT['ALGORITHM']]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Vérifier que c'est un token d'accès
|
||||||
|
if payload.get('type') != 'access':
|
||||||
|
logger.warning(f"Token type invalide: {payload.get('type')}")
|
||||||
|
scope['user'] = AnonymousUser()
|
||||||
|
else:
|
||||||
|
# Récupérer l'utilisateur
|
||||||
|
user_id = payload.get('user_id')
|
||||||
|
user = await get_user(user_id)
|
||||||
|
scope['user'] = user
|
||||||
|
logger.info(f"Utilisateur authentifié via JWT: {user.email if hasattr(user, 'email') else 'Unknown'}")
|
||||||
|
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
logger.warning("Token JWT expiré")
|
||||||
|
scope['user'] = AnonymousUser()
|
||||||
|
except jwt.InvalidTokenError as e:
|
||||||
|
logger.warning(f"Token JWT invalide: {str(e)}")
|
||||||
|
scope['user'] = AnonymousUser()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de l'authentification JWT: {str(e)}")
|
||||||
|
scope['user'] = AnonymousUser()
|
||||||
|
else:
|
||||||
|
scope['user'] = AnonymousUser()
|
||||||
|
|
||||||
|
return await super().__call__(scope, receive, send)
|
||||||
|
|
||||||
|
def JWTAuthMiddlewareStack(inner):
|
||||||
|
"""Stack middleware pour l'authentification JWT"""
|
||||||
|
return JWTAuthMiddleware(inner)
|
||||||
@ -1,7 +1,104 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from Auth.models import Profile
|
from Auth.models import Profile
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
class Conversation(models.Model):
|
||||||
|
"""Modèle pour gérer les conversations entre utilisateurs"""
|
||||||
|
CONVERSATION_TYPES = [
|
||||||
|
('private', 'Privée'),
|
||||||
|
('group', 'Groupe'),
|
||||||
|
]
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
name = models.CharField(max_length=255, blank=True, null=True) # Nom pour les groupes
|
||||||
|
conversation_type = models.CharField(max_length=10, choices=CONVERSATION_TYPES, default='private')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
last_activity = models.DateTimeField(default=timezone.now)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.name:
|
||||||
|
return f'Conversation: {self.name}'
|
||||||
|
return f'Conversation {self.id}'
|
||||||
|
|
||||||
|
def get_participants(self):
|
||||||
|
return Profile.objects.filter(conversation_participants__conversation=self)
|
||||||
|
|
||||||
|
class ConversationParticipant(models.Model):
|
||||||
|
"""Modèle pour gérer les participants d'une conversation"""
|
||||||
|
conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='participants')
|
||||||
|
participant = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name='conversation_participants')
|
||||||
|
joined_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
last_read_at = models.DateTimeField(default=timezone.now)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('conversation', 'participant')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.participant.email} in {self.conversation.id}'
|
||||||
|
|
||||||
|
class Message(models.Model):
|
||||||
|
"""Modèle pour les messages instantanés"""
|
||||||
|
MESSAGE_TYPES = [
|
||||||
|
('text', 'Texte'),
|
||||||
|
('file', 'Fichier'),
|
||||||
|
('image', 'Image'),
|
||||||
|
('system', 'Système'),
|
||||||
|
]
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='messages')
|
||||||
|
sender = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name='sent_messages')
|
||||||
|
content = models.TextField()
|
||||||
|
message_type = models.CharField(max_length=10, choices=MESSAGE_TYPES, default='text')
|
||||||
|
file_url = models.URLField(blank=True, null=True) # Pour les fichiers/images
|
||||||
|
file_name = models.CharField(max_length=255, blank=True, null=True) # Nom original du fichier
|
||||||
|
file_size = models.BigIntegerField(blank=True, null=True) # Taille en bytes
|
||||||
|
file_type = models.CharField(max_length=100, blank=True, null=True) # MIME type
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
is_edited = models.BooleanField(default=False)
|
||||||
|
is_deleted = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'Message from {self.sender.email} at {self.created_at}'
|
||||||
|
|
||||||
|
class MessageRead(models.Model):
|
||||||
|
"""Modèle pour tracker les messages lus par chaque participant"""
|
||||||
|
message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='read_by')
|
||||||
|
participant = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name='read_messages')
|
||||||
|
read_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('message', 'participant')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.participant.email} read {self.message.id}'
|
||||||
|
|
||||||
|
class UserPresence(models.Model):
|
||||||
|
"""Modèle pour gérer la présence des utilisateurs"""
|
||||||
|
PRESENCE_STATUS = [
|
||||||
|
('online', 'En ligne'),
|
||||||
|
('away', 'Absent'),
|
||||||
|
('busy', 'Occupé'),
|
||||||
|
('offline', 'Hors ligne'),
|
||||||
|
]
|
||||||
|
|
||||||
|
user = models.OneToOneField(Profile, on_delete=models.CASCADE, related_name='presence')
|
||||||
|
status = models.CharField(max_length=10, choices=PRESENCE_STATUS, default='offline')
|
||||||
|
last_seen = models.DateTimeField(default=timezone.now)
|
||||||
|
is_typing_in = models.ForeignKey(Conversation, on_delete=models.SET_NULL, null=True, blank=True, related_name='typing_users')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.user.email} - {self.status}'
|
||||||
|
|
||||||
|
# Ancien modèle conservé pour compatibilité
|
||||||
class Messagerie(models.Model):
|
class Messagerie(models.Model):
|
||||||
id = models.AutoField(primary_key=True)
|
id = models.AutoField(primary_key=True)
|
||||||
objet = models.CharField(max_length=200, default="", blank=True)
|
objet = models.CharField(max_length=200, default="", blank=True)
|
||||||
|
|||||||
7
Back-End/GestionMessagerie/routing.py
Normal file
7
Back-End/GestionMessagerie/routing.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
from django.urls import re_path
|
||||||
|
from . import consumers
|
||||||
|
|
||||||
|
websocket_urlpatterns = [
|
||||||
|
re_path(r'ws/chat/(?P<user_id>\w+)/$', consumers.ChatConsumer.as_asgi()),
|
||||||
|
re_path(r'ws/chat/conversation/(?P<conversation_id>[\w-]+)/$', consumers.ChatConsumer.as_asgi()),
|
||||||
|
]
|
||||||
@ -1,15 +1,266 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from Auth.models import Profile
|
from Auth.models import Profile
|
||||||
from GestionMessagerie.models import Messagerie
|
from GestionMessagerie.models import Messagerie, Conversation, ConversationParticipant, Message, MessageRead, UserPresence
|
||||||
|
from channels.layers import get_channel_layer
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
|
class ProfileSimpleSerializer(serializers.ModelSerializer):
|
||||||
|
"""Sérialiseur simple pour les profils utilisateur"""
|
||||||
|
class Meta:
|
||||||
|
model = Profile
|
||||||
|
fields = ['id', 'first_name', 'last_name', 'email']
|
||||||
|
|
||||||
|
class UserPresenceSerializer(serializers.ModelSerializer):
|
||||||
|
"""Sérialiseur pour la présence utilisateur"""
|
||||||
|
user = ProfileSimpleSerializer(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = UserPresence
|
||||||
|
fields = ['user', 'status', 'last_seen', 'is_typing_in']
|
||||||
|
|
||||||
|
class MessageReadSerializer(serializers.ModelSerializer):
|
||||||
|
"""Sérialiseur pour les messages lus"""
|
||||||
|
participant = ProfileSimpleSerializer(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = MessageRead
|
||||||
|
fields = ['participant', 'read_at']
|
||||||
|
|
||||||
class MessageSerializer(serializers.ModelSerializer):
|
class MessageSerializer(serializers.ModelSerializer):
|
||||||
|
"""Sérialiseur pour les messages instantanés"""
|
||||||
|
sender = ProfileSimpleSerializer(read_only=True)
|
||||||
|
read_by = MessageReadSerializer(many=True, read_only=True)
|
||||||
|
attachment = serializers.SerializerMethodField()
|
||||||
|
is_read = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Message
|
||||||
|
fields = ['id', 'conversation', 'sender', 'content', 'message_type', 'file_url',
|
||||||
|
'file_name', 'file_size', 'file_type', 'attachment',
|
||||||
|
'created_at', 'updated_at', 'is_edited', 'is_deleted', 'read_by', 'is_read']
|
||||||
|
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
def get_attachment(self, obj):
|
||||||
|
"""Retourne les informations du fichier attaché sous forme d'objet"""
|
||||||
|
if obj.file_url:
|
||||||
|
return {
|
||||||
|
'fileName': obj.file_name,
|
||||||
|
'fileSize': obj.file_size,
|
||||||
|
'fileType': obj.file_type,
|
||||||
|
'fileUrl': obj.file_url,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_is_read(self, obj):
|
||||||
|
"""Détermine si le message est lu par l'utilisateur actuel"""
|
||||||
|
user = self.context.get('user')
|
||||||
|
if not user or not user.is_authenticated:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Si c'est le message de l'utilisateur lui-même, vérifier si quelqu'un d'autre l'a lu
|
||||||
|
if obj.sender == user:
|
||||||
|
# Pour les messages envoyés par l'utilisateur, vérifier si au moins un autre participant l'a explicitement lu
|
||||||
|
# Utiliser le modèle MessageRead pour une vérification précise
|
||||||
|
from .models import MessageRead
|
||||||
|
other_participants = obj.conversation.participants.exclude(participant=user).filter(is_active=True)
|
||||||
|
|
||||||
|
for participant in other_participants:
|
||||||
|
# Vérifier si ce participant a explicitement lu ce message
|
||||||
|
if MessageRead.objects.filter(message=obj, participant=participant.participant).exists():
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Fallback: vérifier last_read_at seulement si l'utilisateur était en ligne récemment
|
||||||
|
# ou si last_read_at est postérieur à created_at (lecture explicite après réception)
|
||||||
|
if (participant.last_read_at and
|
||||||
|
participant.last_read_at > obj.created_at):
|
||||||
|
|
||||||
|
# Vérifier la présence de l'utilisateur pour s'assurer qu'il était en ligne
|
||||||
|
try:
|
||||||
|
from .models import UserPresence
|
||||||
|
user_presence = UserPresence.objects.filter(user=participant.participant).first()
|
||||||
|
|
||||||
|
# Si l'utilisateur était en ligne récemment (dans les 5 minutes suivant le message)
|
||||||
|
# ou si last_read_at est bien après created_at (lecture délibérée)
|
||||||
|
time_diff = participant.last_read_at - obj.created_at
|
||||||
|
if (user_presence and user_presence.last_seen and
|
||||||
|
user_presence.last_seen >= obj.created_at) or time_diff.total_seconds() > 10:
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
# En cas d'erreur, continuer avec la logique conservative
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# Pour les messages reçus, vérifier si l'utilisateur actuel l'a lu
|
||||||
|
# D'abord vérifier dans MessageRead pour une lecture explicite
|
||||||
|
from .models import MessageRead
|
||||||
|
if MessageRead.objects.filter(message=obj, participant=user).exists():
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Fallback: vérifier last_read_at du participant
|
||||||
|
participant = obj.conversation.participants.filter(
|
||||||
|
participant=user,
|
||||||
|
is_active=True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if participant and participant.last_read_at:
|
||||||
|
# Seulement considérer comme lu si last_read_at est postérieur à created_at
|
||||||
|
return participant.last_read_at > obj.created_at
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
class ConversationParticipantSerializer(serializers.ModelSerializer):
|
||||||
|
"""Sérialiseur pour les participants d'une conversation"""
|
||||||
|
participant = ProfileSimpleSerializer(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ConversationParticipant
|
||||||
|
fields = ['participant', 'joined_at', 'last_read_at', 'is_active']
|
||||||
|
|
||||||
|
class ConversationSerializer(serializers.ModelSerializer):
|
||||||
|
"""Sérialiseur pour les conversations"""
|
||||||
|
participants = ConversationParticipantSerializer(many=True, read_only=True)
|
||||||
|
last_message = serializers.SerializerMethodField()
|
||||||
|
unread_count = serializers.SerializerMethodField()
|
||||||
|
interlocuteur = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Conversation
|
||||||
|
fields = ['id', 'name', 'conversation_type', 'created_at', 'updated_at',
|
||||||
|
'last_activity', 'is_active', 'participants', 'last_message', 'unread_count', 'interlocuteur']
|
||||||
|
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
def get_last_message(self, obj):
|
||||||
|
last_message = obj.messages.filter(is_deleted=False).last()
|
||||||
|
if last_message:
|
||||||
|
return MessageSerializer(last_message).data
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_unread_count(self, obj):
|
||||||
|
user = self.context.get('user')
|
||||||
|
if not user or not user.is_authenticated:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
participant = obj.participants.filter(participant=user).first()
|
||||||
|
if not participant:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Nouvelle logique : compter les messages qui ne sont PAS dans MessageRead
|
||||||
|
# et qui ont été créés après last_read_at (ou tous si last_read_at est None)
|
||||||
|
|
||||||
|
# Base query : messages de la conversation, excluant les propres messages et les supprimés
|
||||||
|
# ET ne comptant que les messages textuels
|
||||||
|
base_query = obj.messages.filter(
|
||||||
|
is_deleted=False,
|
||||||
|
message_type='text' # Ne compter que les messages textuels
|
||||||
|
).exclude(sender=user)
|
||||||
|
|
||||||
|
# Si l'utilisateur n'a pas de last_read_at, tous les messages sont non lus
|
||||||
|
if not participant.last_read_at:
|
||||||
|
unread_from_timestamp = base_query
|
||||||
|
else:
|
||||||
|
# Messages créés après le dernier moment de lecture
|
||||||
|
unread_from_timestamp = base_query.filter(
|
||||||
|
created_at__gt=participant.last_read_at
|
||||||
|
)
|
||||||
|
|
||||||
|
# Soustraire les messages explicitement marqués comme lus dans MessageRead
|
||||||
|
from .models import MessageRead
|
||||||
|
read_message_ids = MessageRead.objects.filter(
|
||||||
|
participant=user,
|
||||||
|
message__conversation=obj
|
||||||
|
).values_list('message_id', flat=True)
|
||||||
|
|
||||||
|
# Compter les messages non lus = messages après last_read_at MOINS ceux explicitement lus
|
||||||
|
unread_count = unread_from_timestamp.exclude(
|
||||||
|
id__in=read_message_ids
|
||||||
|
).count()
|
||||||
|
|
||||||
|
return unread_count
|
||||||
|
|
||||||
|
def get_interlocuteur(self, obj):
|
||||||
|
"""Pour les conversations privées, retourne l'autre participant"""
|
||||||
|
user = self.context.get('user')
|
||||||
|
if not user or not user.is_authenticated or obj.conversation_type != 'private':
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Trouver l'autre participant (pas l'utilisateur actuel)
|
||||||
|
other_participant = obj.participants.filter(is_active=True).exclude(participant=user).first()
|
||||||
|
if other_participant:
|
||||||
|
return ProfileSimpleSerializer(other_participant.participant).data
|
||||||
|
return None
|
||||||
|
|
||||||
|
class ConversationCreateSerializer(serializers.ModelSerializer):
|
||||||
|
"""Sérialiseur pour créer une conversation"""
|
||||||
|
participant_ids = serializers.ListField(
|
||||||
|
child=serializers.IntegerField(),
|
||||||
|
write_only=True
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Conversation
|
||||||
|
fields = ['name', 'conversation_type', 'participant_ids']
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
participant_ids = validated_data.pop('participant_ids')
|
||||||
|
conversation_type = validated_data.get('conversation_type', 'private')
|
||||||
|
|
||||||
|
# Pour les conversations privées, ne pas utiliser de nom spécifique
|
||||||
|
# Le nom sera géré côté frontend en affichant le nom de l'interlocuteur
|
||||||
|
if conversation_type == 'private':
|
||||||
|
validated_data['name'] = None
|
||||||
|
|
||||||
|
conversation = super().create(validated_data)
|
||||||
|
|
||||||
|
# Ajouter les participants
|
||||||
|
participants = []
|
||||||
|
for participant_id in participant_ids:
|
||||||
|
try:
|
||||||
|
participant = Profile.objects.get(id=participant_id)
|
||||||
|
ConversationParticipant.objects.create(
|
||||||
|
conversation=conversation,
|
||||||
|
participant=participant
|
||||||
|
)
|
||||||
|
participants.append(participant)
|
||||||
|
except Profile.DoesNotExist:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Notifier les participants via WebSocket de la nouvelle conversation
|
||||||
|
try:
|
||||||
|
from channels.layers import get_channel_layer
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
|
channel_layer = get_channel_layer()
|
||||||
|
if channel_layer:
|
||||||
|
# Envoyer à chaque participant avec le bon contexte
|
||||||
|
for participant in participants:
|
||||||
|
# Sérialiser la conversation avec le contexte de ce participant
|
||||||
|
conversation_data = ConversationSerializer(conversation, context={'user': participant}).data
|
||||||
|
|
||||||
|
async_to_sync(channel_layer.group_send)(
|
||||||
|
f'user_{participant.id}',
|
||||||
|
{
|
||||||
|
'type': 'new_conversation_notification',
|
||||||
|
'conversation': conversation_data
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Log l'erreur mais ne pas interrompre la création de la conversation
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.error(f"Erreur lors de la notification WebSocket de nouvelle conversation: {str(e)}")
|
||||||
|
|
||||||
|
return conversation
|
||||||
|
|
||||||
|
# Ancien sérialiseur conservé pour compatibilité
|
||||||
|
class MessageLegacySerializer(serializers.ModelSerializer):
|
||||||
destinataire_profil = serializers.SerializerMethodField()
|
destinataire_profil = serializers.SerializerMethodField()
|
||||||
emetteur_profil = serializers.SerializerMethodField()
|
emetteur_profil = serializers.SerializerMethodField()
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Messagerie
|
model = Messagerie
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
read_only_fields = ['date_envoi']
|
read_only_fields = ['date_envoi']
|
||||||
|
|
||||||
def get_destinataire_profil(self, obj):
|
def get_destinataire_profil(self, obj):
|
||||||
return obj.destinataire.email
|
return obj.destinataire.email
|
||||||
|
|
||||||
|
|||||||
@ -1,15 +1,22 @@
|
|||||||
from django.urls import path, re_path
|
from django.urls import path
|
||||||
from .views import SendEmailView, search_recipients, ConversationListView, ConversationMessagesView, MarkAsReadView
|
from .views import (
|
||||||
from GestionMessagerie.views import MessagerieView, MessageView, MessageSimpleView
|
InstantConversationListView, InstantConversationCreateView, InstantConversationDeleteView,
|
||||||
|
InstantMessageListView, InstantMessageCreateView,
|
||||||
|
InstantMarkAsReadView, FileUploadView,
|
||||||
|
InstantRecipientSearchView
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
re_path(r'^messagerie/(?P<profile_id>[0-9]+)$', MessagerieView.as_view(), name="messagerie"),
|
# URLs pour messagerie instantanée
|
||||||
re_path(r'^messages$', MessageView.as_view(), name="messages"),
|
path('conversations/', InstantConversationListView.as_view(), name='conversations'),
|
||||||
re_path(r'^messages/(?P<id>[0-9]+)$', MessageSimpleView.as_view(), name="messages"),
|
path('create-conversation/', InstantConversationCreateView.as_view(), name='create_conversation'),
|
||||||
path('send-email/', SendEmailView.as_view(), name='send_email'),
|
path('send-message/', InstantMessageCreateView.as_view(), name='send_message'),
|
||||||
path('search-recipients/', search_recipients, name='search_recipients'),
|
path('conversations/mark-as-read/', InstantMarkAsReadView.as_view(), name='mark_as_read'),
|
||||||
# Endpoints pour le chat instantané
|
path('search-recipients/', InstantRecipientSearchView.as_view(), name='search_recipients'),
|
||||||
path('conversations/<int:profile_id>/', ConversationListView.as_view(), name='conversations'),
|
path('upload-file/', FileUploadView.as_view(), name='upload_file'),
|
||||||
path('conversations/messages/<str:conversation_id>/', ConversationMessagesView.as_view(), name='conversation_messages'),
|
|
||||||
path('conversations/mark-as-read/<str:conversation_id>/', MarkAsReadView.as_view(), name='mark_as_read'),
|
# URLs avec paramètres - doivent être après les URLs statiques
|
||||||
|
path('conversations/user/<int:user_id>/', InstantConversationListView.as_view(), name='conversations_by_user'),
|
||||||
|
path('conversations/<uuid:conversation_id>/', InstantConversationDeleteView.as_view(), name='delete_conversation'),
|
||||||
|
path('conversations/<uuid:conversation_id>/messages/', InstantMessageListView.as_view(), name='conversation_messages'),
|
||||||
]
|
]
|
||||||
@ -1,211 +1,455 @@
|
|||||||
from django.http.response import JsonResponse
|
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from rest_framework.parsers import JSONParser
|
|
||||||
from django.conf import settings
|
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from django.db.models import Q
|
from rest_framework.parsers import MultiPartParser, FormParser
|
||||||
from .models import Messagerie
|
from django.db import models
|
||||||
|
from .models import Conversation, ConversationParticipant, Message, UserPresence
|
||||||
from Auth.models import Profile, ProfileRole
|
from Auth.models import Profile, ProfileRole
|
||||||
from GestionMessagerie.serializers import MessageSerializer
|
from GestionMessagerie.serializers import (
|
||||||
from School.models import Teacher
|
ConversationSerializer, MessageSerializer,
|
||||||
|
ConversationCreateSerializer, UserPresenceSerializer,
|
||||||
from School.serializers import TeacherSerializer
|
ProfileSimpleSerializer
|
||||||
|
)
|
||||||
import N3wtSchool.mailManager as mailer
|
|
||||||
from N3wtSchool import bdd
|
|
||||||
from drf_yasg.utils import swagger_auto_schema
|
from drf_yasg.utils import swagger_auto_schema
|
||||||
from drf_yasg import openapi
|
from drf_yasg import openapi
|
||||||
from rest_framework.exceptions import NotFound
|
from django.utils import timezone
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
class MessagerieView(APIView):
|
logger = logging.getLogger(__name__)
|
||||||
def get(self, request, profile_id):
|
|
||||||
messagesList = bdd.getObjects(_objectName=Messagerie, _columnName='destinataire__id', _value=profile_id)
|
|
||||||
messages_serializer = MessageSerializer(messagesList, many=True)
|
|
||||||
return JsonResponse(messages_serializer.data, safe=False)
|
|
||||||
|
|
||||||
class MessageView(APIView):
|
# ====================== MESSAGERIE INSTANTANÉE ======================
|
||||||
def post(self, request):
|
|
||||||
message_data=JSONParser().parse(request)
|
|
||||||
message_serializer = MessageSerializer(data=message_data)
|
|
||||||
|
|
||||||
if message_serializer.is_valid():
|
class InstantConversationListView(APIView):
|
||||||
message_serializer.save()
|
|
||||||
|
|
||||||
return JsonResponse('Nouveau Message ajouté', safe=False)
|
|
||||||
|
|
||||||
return JsonResponse(message_serializer.errors, safe=False)
|
|
||||||
|
|
||||||
class MessageSimpleView(APIView):
|
|
||||||
def get(self, request, id):
|
|
||||||
message=bdd.getObject(Messagerie, "id", id)
|
|
||||||
message_serializer=MessageSerializer(message)
|
|
||||||
return JsonResponse(message_serializer.data, safe=False)
|
|
||||||
|
|
||||||
class SendEmailView(APIView):
|
|
||||||
"""
|
"""
|
||||||
API pour envoyer des emails aux parents et professeurs.
|
API pour lister les conversations instantanées d'un utilisateur
|
||||||
"""
|
"""
|
||||||
def post(self, request):
|
@swagger_auto_schema(
|
||||||
data = request.data
|
operation_description="Liste les conversations instantanées d'un utilisateur",
|
||||||
recipients = data.get('recipients', [])
|
responses={200: ConversationSerializer(many=True)}
|
||||||
cc = data.get('cc', [])
|
)
|
||||||
bcc = data.get('bcc', [])
|
def get(self, request, user_id=None):
|
||||||
subject = data.get('subject', 'Notification')
|
|
||||||
message = data.get('message', '')
|
|
||||||
establishment_id = data.get('establishment_id', '')
|
|
||||||
|
|
||||||
if not recipients or not message:
|
|
||||||
return Response({'error': 'Les destinataires et le message sont requis.'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Récupérer la connexion SMTP
|
user = Profile.objects.get(id=user_id)
|
||||||
connection = mailer.getConnection(establishment_id)
|
|
||||||
|
|
||||||
# Envoyer l'email
|
conversations = Conversation.objects.filter(
|
||||||
return mailer.sendMail(
|
participants__participant=user,
|
||||||
subject=subject,
|
participants__is_active=True,
|
||||||
message=message,
|
is_active=True
|
||||||
recipients=recipients,
|
).distinct().order_by('-last_activity')
|
||||||
cc=cc,
|
|
||||||
bcc=bcc,
|
serializer = ConversationSerializer(conversations, many=True, context={'user': user})
|
||||||
attachments=[],
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
connection=connection
|
except Profile.DoesNotExist:
|
||||||
)
|
return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
except NotFound as e:
|
|
||||||
return Response({'error': str(e)}, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
class ContactsView(APIView):
|
class InstantConversationCreateView(APIView):
|
||||||
"""
|
"""
|
||||||
API pour récupérer les contacts associés à un établissement.
|
API pour créer une nouvelle conversation instantanée
|
||||||
"""
|
|
||||||
def get(self, request, establishment_id):
|
|
||||||
try:
|
|
||||||
# Récupérer les enseignants associés à l'établissement
|
|
||||||
teachers = Teacher.objects.filter(profile_role__establishment_id=establishment_id)
|
|
||||||
teachers_serializer = TeacherSerializer(teachers, many=True)
|
|
||||||
|
|
||||||
# Ajouter un contact pour l'administration
|
|
||||||
admin_contact = {
|
|
||||||
"id": "admin",
|
|
||||||
"name": "Administration",
|
|
||||||
"email": "admin@etablissement.com",
|
|
||||||
"profilePic": "https://www.gravatar.com/avatar/admin"
|
|
||||||
}
|
|
||||||
|
|
||||||
contacts = [admin_contact] + teachers_serializer.data
|
|
||||||
return Response(contacts, status=status.HTTP_200_OK)
|
|
||||||
except Exception as e:
|
|
||||||
return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
||||||
|
|
||||||
def search_recipients(request):
|
|
||||||
"""
|
|
||||||
API pour rechercher des destinataires en fonction d'un terme de recherche et d'un établissement.
|
|
||||||
"""
|
|
||||||
query = request.GET.get('q', '').strip() # Récupérer le terme de recherche depuis les paramètres GET
|
|
||||||
establishment_id = request.GET.get('establishment_id', None) # Récupérer l'ID de l'établissement
|
|
||||||
|
|
||||||
if not query:
|
|
||||||
return JsonResponse([], safe=False) # Retourner une liste vide si aucun terme n'est fourni
|
|
||||||
|
|
||||||
if not establishment_id:
|
|
||||||
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
# Rechercher dans les champs pertinents (nom, prénom, email) et filtrer par establishment_id
|
|
||||||
profiles = Profile.objects.filter(
|
|
||||||
Q(first_name__icontains=query) |
|
|
||||||
Q(last_name__icontains=query) |
|
|
||||||
Q(email__icontains=query),
|
|
||||||
roles__establishment_id=establishment_id, # Utiliser 'roles' au lieu de 'profilerole'
|
|
||||||
roles__is_active=True # Filtrer uniquement les ProfileRole actifs
|
|
||||||
).distinct()
|
|
||||||
|
|
||||||
# Construire la réponse avec les rôles associés
|
|
||||||
results = []
|
|
||||||
for profile in profiles:
|
|
||||||
profile_roles = ProfileRole.objects.filter(
|
|
||||||
profile=profile,
|
|
||||||
establishment_id=establishment_id,
|
|
||||||
is_active=True # Inclure uniquement les ProfileRole actifs
|
|
||||||
).values(
|
|
||||||
'id', 'role_type', 'establishment__name', 'is_active'
|
|
||||||
)
|
|
||||||
results.append({
|
|
||||||
'id': profile.id,
|
|
||||||
'first_name': profile.first_name,
|
|
||||||
'last_name': profile.last_name,
|
|
||||||
'email': profile.email,
|
|
||||||
'roles': list(profile_roles) # Inclure tous les rôles actifs associés pour cet établissement
|
|
||||||
})
|
|
||||||
|
|
||||||
return JsonResponse(results, safe=False)
|
|
||||||
|
|
||||||
class ConversationListView(APIView):
|
|
||||||
"""
|
|
||||||
Liste les conversations d'un utilisateur (parent ou enseignant).
|
|
||||||
Retourne la liste des interlocuteurs et le dernier message échangé.
|
|
||||||
"""
|
"""
|
||||||
@swagger_auto_schema(
|
@swagger_auto_schema(
|
||||||
operation_description="Liste les conversations d'un utilisateur (parent ou enseignant).",
|
operation_description="Crée une nouvelle conversation instantanée",
|
||||||
responses={200: openapi.Response('Liste des conversations')}
|
request_body=ConversationCreateSerializer,
|
||||||
|
responses={201: ConversationSerializer}
|
||||||
)
|
)
|
||||||
def get(self, request, profile_id):
|
def post(self, request):
|
||||||
# Récupérer toutes les conversations où l'utilisateur est émetteur ou destinataire
|
serializer = ConversationCreateSerializer(data=request.data)
|
||||||
messages = Messagerie.objects.filter(Q(emetteur_id=profile_id) | Q(destinataire_id=profile_id))
|
if serializer.is_valid():
|
||||||
# Grouper par conversation_id
|
conversation = serializer.save()
|
||||||
conversations = {}
|
response_serializer = ConversationSerializer(conversation, context={'user': request.user})
|
||||||
for msg in messages.order_by('-date_envoi'):
|
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
||||||
conv_id = msg.conversation_id or f"{min(msg.emetteur_id, msg.destinataire_id)}_{max(msg.emetteur_id, msg.destinataire_id)}"
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
if conv_id not in conversations:
|
|
||||||
conversations[conv_id] = msg
|
|
||||||
# Préparer la réponse
|
|
||||||
data = []
|
|
||||||
for conv_id, last_msg in conversations.items():
|
|
||||||
interlocuteur = last_msg.emetteur if last_msg.destinataire_id == int(profile_id) else last_msg.destinataire
|
|
||||||
data.append({
|
|
||||||
'conversation_id': conv_id,
|
|
||||||
'last_message': MessageSerializer(last_msg).data,
|
|
||||||
'interlocuteur': {
|
|
||||||
'id': interlocuteur.id,
|
|
||||||
'first_name': interlocuteur.first_name,
|
|
||||||
'last_name': interlocuteur.last_name,
|
|
||||||
'email': interlocuteur.email,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return Response(data, status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
class ConversationMessagesView(APIView):
|
class InstantMessageListView(APIView):
|
||||||
"""
|
"""
|
||||||
Récupère tous les messages d'une conversation donnée.
|
API pour lister les messages d'une conversation
|
||||||
"""
|
"""
|
||||||
@swagger_auto_schema(
|
@swagger_auto_schema(
|
||||||
operation_description="Récupère tous les messages d'une conversation donnée.",
|
operation_description="Liste les messages d'une conversation",
|
||||||
responses={200: openapi.Response('Liste des messages')}
|
responses={200: MessageSerializer(many=True)}
|
||||||
)
|
)
|
||||||
def get(self, request, conversation_id):
|
def get(self, request, conversation_id):
|
||||||
messages = Messagerie.objects.filter(conversation_id=conversation_id).order_by('date_envoi')
|
try:
|
||||||
serializer = MessageSerializer(messages, many=True)
|
conversation = Conversation.objects.get(id=conversation_id)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
messages = conversation.messages.filter(is_deleted=False).order_by('created_at')
|
||||||
|
|
||||||
class MarkAsReadView(APIView):
|
# Récupérer l'utilisateur actuel depuis les paramètres de requête
|
||||||
|
user_id = request.GET.get('user_id')
|
||||||
|
user = None
|
||||||
|
if user_id:
|
||||||
|
try:
|
||||||
|
user = Profile.objects.get(id=user_id)
|
||||||
|
except Profile.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
serializer = MessageSerializer(messages, many=True, context={'user': user})
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
except Conversation.DoesNotExist:
|
||||||
|
return Response({'error': 'Conversation not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
class InstantMessageCreateView(APIView):
|
||||||
"""
|
"""
|
||||||
Marque tous les messages reçus dans une conversation comme lus pour l'utilisateur connecté.
|
API pour envoyer un nouveau message instantané
|
||||||
"""
|
"""
|
||||||
@swagger_auto_schema(
|
@swagger_auto_schema(
|
||||||
operation_description="Marque tous les messages reçus dans une conversation comme lus pour l'utilisateur connecté.",
|
operation_description="Envoie un nouveau message instantané",
|
||||||
request_body=openapi.Schema(
|
request_body=openapi.Schema(
|
||||||
type=openapi.TYPE_OBJECT,
|
type=openapi.TYPE_OBJECT,
|
||||||
properties={
|
properties={
|
||||||
'profile_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='ID du profil utilisateur')
|
'conversation_id': openapi.Schema(type=openapi.TYPE_STRING, description='ID de la conversation'),
|
||||||
|
'sender_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='ID de l\'expéditeur'),
|
||||||
|
'content': openapi.Schema(type=openapi.TYPE_STRING, description='Contenu du message'),
|
||||||
|
'message_type': openapi.Schema(type=openapi.TYPE_STRING, description='Type de message', default='text')
|
||||||
},
|
},
|
||||||
required=['profile_id']
|
required=['conversation_id', 'sender_id', 'content']
|
||||||
),
|
),
|
||||||
responses={200: openapi.Response('Statut OK')}
|
responses={201: MessageSerializer}
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
try:
|
||||||
|
conversation_id = request.data.get('conversation_id')
|
||||||
|
sender_id = request.data.get('sender_id')
|
||||||
|
content = request.data.get('content', '').strip()
|
||||||
|
message_type = request.data.get('message_type', 'text')
|
||||||
|
|
||||||
|
if not all([conversation_id, sender_id, content]):
|
||||||
|
return Response(
|
||||||
|
{'error': 'conversation_id, sender_id, and content are required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Vérifier que la conversation existe
|
||||||
|
conversation = Conversation.objects.get(id=conversation_id)
|
||||||
|
|
||||||
|
# Vérifier que l'expéditeur existe et peut envoyer dans cette conversation
|
||||||
|
sender = Profile.objects.get(id=sender_id)
|
||||||
|
participant = ConversationParticipant.objects.filter(
|
||||||
|
conversation=conversation,
|
||||||
|
participant=sender,
|
||||||
|
is_active=True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not participant:
|
||||||
|
return Response(
|
||||||
|
{'error': 'You are not a participant in this conversation'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
# Récupérer les données de fichier si disponibles
|
||||||
|
file_url = request.data.get('file_url')
|
||||||
|
file_name = request.data.get('file_name')
|
||||||
|
file_type = request.data.get('file_type')
|
||||||
|
file_size = request.data.get('file_size')
|
||||||
|
|
||||||
|
# Créer le message
|
||||||
|
message = Message.objects.create(
|
||||||
|
conversation=conversation,
|
||||||
|
sender=sender,
|
||||||
|
content=content,
|
||||||
|
message_type=message_type,
|
||||||
|
file_url=file_url,
|
||||||
|
file_name=file_name,
|
||||||
|
file_type=file_type,
|
||||||
|
file_size=file_size
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mettre à jour l'activité de la conversation
|
||||||
|
conversation.last_activity = message.created_at
|
||||||
|
conversation.save(update_fields=['last_activity'])
|
||||||
|
|
||||||
|
serializer = MessageSerializer(message)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
except Conversation.DoesNotExist:
|
||||||
|
return Response({'error': 'Conversation not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
except Profile.DoesNotExist:
|
||||||
|
return Response({'error': 'Sender not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
class InstantMarkAsReadView(APIView):
|
||||||
|
"""
|
||||||
|
API pour marquer une conversation comme lue
|
||||||
|
"""
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Marque une conversation comme lue pour un utilisateur",
|
||||||
|
request_body=openapi.Schema(
|
||||||
|
type=openapi.TYPE_OBJECT,
|
||||||
|
properties={
|
||||||
|
'user_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='ID de l\'utilisateur')
|
||||||
|
},
|
||||||
|
required=['user_id']
|
||||||
|
),
|
||||||
|
responses={200: openapi.Response('Success')}
|
||||||
)
|
)
|
||||||
def post(self, request, conversation_id):
|
def post(self, request, conversation_id):
|
||||||
profile_id = request.data.get('profile_id')
|
try:
|
||||||
Messagerie.objects.filter(conversation_id=conversation_id, destinataire_id=profile_id, is_read=False).update(is_read=True)
|
user_id = request.data.get('user_id')
|
||||||
return Response({'status': 'ok'}, status=status.HTTP_200_OK)
|
if not user_id:
|
||||||
|
return Response({'error': 'user_id is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
participant = ConversationParticipant.objects.get(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
participant_id=user_id,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
participant.last_read_at = timezone.now()
|
||||||
|
participant.save(update_fields=['last_read_at'])
|
||||||
|
|
||||||
|
return Response({'status': 'success'}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
except ConversationParticipant.DoesNotExist:
|
||||||
|
return Response({'error': 'Participant not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
class UserPresenceView(APIView):
|
||||||
|
"""
|
||||||
|
API pour gérer la présence des utilisateurs
|
||||||
|
"""
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Met à jour le statut de présence d'un utilisateur",
|
||||||
|
request_body=openapi.Schema(
|
||||||
|
type=openapi.TYPE_OBJECT,
|
||||||
|
properties={
|
||||||
|
'status': openapi.Schema(type=openapi.TYPE_STRING, description='Statut de présence')
|
||||||
|
},
|
||||||
|
required=['status']
|
||||||
|
),
|
||||||
|
responses={200: UserPresenceSerializer}
|
||||||
|
)
|
||||||
|
def post(self, request, user_id):
|
||||||
|
try:
|
||||||
|
user = Profile.objects.get(id=user_id)
|
||||||
|
status_value = request.data.get('status')
|
||||||
|
|
||||||
|
if status_value not in ['online', 'away', 'busy', 'offline']:
|
||||||
|
return Response({'error': 'Invalid status'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
presence, created = UserPresence.objects.get_or_create(user=user)
|
||||||
|
presence.status = status_value
|
||||||
|
presence.last_seen = timezone.now()
|
||||||
|
presence.save()
|
||||||
|
|
||||||
|
serializer = UserPresenceSerializer(presence)
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
except Profile.DoesNotExist:
|
||||||
|
return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Récupère le statut de présence d'un utilisateur",
|
||||||
|
responses={200: UserPresenceSerializer}
|
||||||
|
)
|
||||||
|
def get(self, request, user_id):
|
||||||
|
try:
|
||||||
|
user = Profile.objects.get(id=user_id)
|
||||||
|
presence, created = UserPresence.objects.get_or_create(user=user)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
presence.status = 'offline'
|
||||||
|
presence.save()
|
||||||
|
|
||||||
|
serializer = UserPresenceSerializer(presence)
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
except Profile.DoesNotExist:
|
||||||
|
return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
class FileUploadView(APIView):
|
||||||
|
"""
|
||||||
|
API pour l'upload de fichiers dans la messagerie instantanée
|
||||||
|
"""
|
||||||
|
parser_classes = (MultiPartParser, FormParser)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Upload un fichier pour la messagerie",
|
||||||
|
manual_parameters=[
|
||||||
|
openapi.Parameter('file', openapi.IN_FORM, description="Fichier à uploader", type=openapi.TYPE_FILE, required=True),
|
||||||
|
openapi.Parameter('conversation_id', openapi.IN_FORM, description="ID de la conversation", type=openapi.TYPE_INTEGER, required=True),
|
||||||
|
openapi.Parameter('sender_id', openapi.IN_FORM, description="ID de l'expéditeur", type=openapi.TYPE_INTEGER, required=True),
|
||||||
|
],
|
||||||
|
responses={
|
||||||
|
200: openapi.Response('Success', openapi.Schema(
|
||||||
|
type=openapi.TYPE_OBJECT,
|
||||||
|
properties={
|
||||||
|
'fileUrl': openapi.Schema(type=openapi.TYPE_STRING),
|
||||||
|
'fileName': openapi.Schema(type=openapi.TYPE_STRING),
|
||||||
|
'fileSize': openapi.Schema(type=openapi.TYPE_INTEGER),
|
||||||
|
'fileType': openapi.Schema(type=openapi.TYPE_STRING),
|
||||||
|
}
|
||||||
|
)),
|
||||||
|
400: 'Bad Request',
|
||||||
|
413: 'File too large',
|
||||||
|
415: 'Unsupported file type'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
try:
|
||||||
|
file = request.FILES.get('file')
|
||||||
|
conversation_id = request.data.get('conversation_id')
|
||||||
|
sender_id = request.data.get('sender_id')
|
||||||
|
|
||||||
|
if not file:
|
||||||
|
return Response({'error': 'Aucun fichier fourni'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
if not conversation_id or not sender_id:
|
||||||
|
return Response({'error': 'conversation_id et sender_id requis'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Vérifier que la conversation existe et que l'utilisateur y participe
|
||||||
|
try:
|
||||||
|
conversation = Conversation.objects.get(id=conversation_id)
|
||||||
|
sender = Profile.objects.get(id=sender_id)
|
||||||
|
|
||||||
|
# Vérifier que l'expéditeur participe à la conversation
|
||||||
|
if not ConversationParticipant.objects.filter(
|
||||||
|
conversation=conversation,
|
||||||
|
participant=sender,
|
||||||
|
is_active=True
|
||||||
|
).exists():
|
||||||
|
return Response({'error': 'Accès non autorisé à cette conversation'}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
except (Conversation.DoesNotExist, Profile.DoesNotExist):
|
||||||
|
return Response({'error': 'Conversation ou utilisateur introuvable'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
# Valider le type de fichier
|
||||||
|
allowed_types = [
|
||||||
|
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp',
|
||||||
|
'application/pdf',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'text/plain'
|
||||||
|
]
|
||||||
|
|
||||||
|
if file.content_type not in allowed_types:
|
||||||
|
return Response({'error': 'Type de fichier non autorisé'}, status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE)
|
||||||
|
|
||||||
|
# Valider la taille du fichier (10MB max)
|
||||||
|
max_size = 10 * 1024 * 1024 # 10MB
|
||||||
|
if file.size > max_size:
|
||||||
|
return Response({'error': 'Fichier trop volumineux (max 10MB)'}, status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE)
|
||||||
|
|
||||||
|
# Générer un nom de fichier unique
|
||||||
|
file_extension = os.path.splitext(file.name)[1]
|
||||||
|
unique_filename = f"{uuid.uuid4()}{file_extension}"
|
||||||
|
|
||||||
|
# Chemin de stockage : messagerie/conversation_id/
|
||||||
|
storage_path = f"messagerie/{conversation_id}/{unique_filename}"
|
||||||
|
|
||||||
|
# Sauvegarder le fichier
|
||||||
|
file_path = default_storage.save(storage_path, ContentFile(file.read()))
|
||||||
|
|
||||||
|
# Générer l'URL du fichier
|
||||||
|
file_url = default_storage.url(file_path)
|
||||||
|
if not file_url.startswith('http'):
|
||||||
|
# Construire l'URL complète si nécessaire
|
||||||
|
file_url = request.build_absolute_uri(file_url)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'fileUrl': file_url,
|
||||||
|
'fileName': file.name,
|
||||||
|
'fileSize': file.size,
|
||||||
|
'fileType': file.content_type,
|
||||||
|
'filePath': file_path
|
||||||
|
}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': f'Erreur lors de l\'upload: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
class InstantRecipientSearchView(APIView):
|
||||||
|
"""
|
||||||
|
API pour rechercher des destinataires pour la messagerie instantanée
|
||||||
|
"""
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Recherche des destinataires pour la messagerie instantanée",
|
||||||
|
manual_parameters=[
|
||||||
|
openapi.Parameter('establishment_id', openapi.IN_QUERY, description="ID de l'établissement", type=openapi.TYPE_INTEGER, required=True),
|
||||||
|
openapi.Parameter('q', openapi.IN_QUERY, description="Terme de recherche", type=openapi.TYPE_STRING, required=True)
|
||||||
|
],
|
||||||
|
responses={200: ProfileSimpleSerializer(many=True)}
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
try:
|
||||||
|
establishment_id = request.query_params.get('establishment_id')
|
||||||
|
search_query = request.query_params.get('q', '').strip()
|
||||||
|
|
||||||
|
if not establishment_id:
|
||||||
|
return Response({'error': 'establishment_id is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Récupérer les IDs des profils actifs dans l'établissement
|
||||||
|
profile_roles = ProfileRole.objects.filter(
|
||||||
|
establishment_id=establishment_id,
|
||||||
|
is_active=True
|
||||||
|
).values_list('profile_id', flat=True)
|
||||||
|
|
||||||
|
# Rechercher les profils correspondants
|
||||||
|
users = Profile.objects.filter(id__in=profile_roles)
|
||||||
|
|
||||||
|
# Appliquer le filtre de recherche si un terme est fourni
|
||||||
|
if search_query:
|
||||||
|
users = users.filter(
|
||||||
|
Q(first_name__icontains=search_query) |
|
||||||
|
Q(last_name__icontains=search_query) |
|
||||||
|
Q(email__icontains=search_query)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Exclure l'utilisateur actuel des résultats
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
users = users.exclude(id=request.user.id)
|
||||||
|
|
||||||
|
serializer = ProfileSimpleSerializer(users[:10], many=True) # Limiter à 10 résultats
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
class InstantConversationDeleteView(APIView):
|
||||||
|
"""
|
||||||
|
API pour supprimer (désactiver) une conversation instantanée
|
||||||
|
"""
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Supprime une conversation instantanée (désactivation soft)",
|
||||||
|
responses={200: openapi.Schema(
|
||||||
|
type=openapi.TYPE_OBJECT,
|
||||||
|
properties={
|
||||||
|
'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
|
||||||
|
'message': openapi.Schema(type=openapi.TYPE_STRING)
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
)
|
||||||
|
def delete(self, request, conversation_id):
|
||||||
|
try:
|
||||||
|
# Récupérer la conversation par son ID UUID
|
||||||
|
conversation = Conversation.objects.filter(id=conversation_id).first()
|
||||||
|
|
||||||
|
if not conversation:
|
||||||
|
return Response({'error': 'Conversation not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
# Suppression simple : désactiver la conversation
|
||||||
|
conversation.is_active = False
|
||||||
|
conversation.save()
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Conversation deleted successfully'
|
||||||
|
}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting conversation: {str(e)}")
|
||||||
|
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
|||||||
@ -8,9 +8,40 @@ https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
from django.core.asgi import get_asgi_application
|
||||||
|
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||||
|
from channels.security.websocket import AllowedHostsOriginValidator
|
||||||
|
from django.urls import re_path
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.staticfiles.handlers import ASGIStaticFilesHandler
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'N3wtSchool.settings')
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'N3wtSchool.settings')
|
||||||
|
|
||||||
application = get_asgi_application()
|
# Initialize Django ASGI application early to ensure the AppRegistry
|
||||||
|
# is populated before importing code that may import ORM models.
|
||||||
|
django_asgi_app = get_asgi_application()
|
||||||
|
|
||||||
|
# Import consumers after Django is initialized
|
||||||
|
from GestionMessagerie.consumers import ChatConsumer
|
||||||
|
from GestionMessagerie.middleware import JWTAuthMiddlewareStack
|
||||||
|
|
||||||
|
# WebSocket URL patterns
|
||||||
|
websocket_urlpatterns = [
|
||||||
|
re_path(r'ws/chat/(?P<user_id>\w+)/$', ChatConsumer.as_asgi()),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Créer l'application ASGI avec gestion des fichiers statiques
|
||||||
|
if settings.DEBUG:
|
||||||
|
# En mode DEBUG, utiliser ASGIStaticFilesHandler pour servir les fichiers statiques
|
||||||
|
http_application = ASGIStaticFilesHandler(django_asgi_app)
|
||||||
|
else:
|
||||||
|
http_application = django_asgi_app
|
||||||
|
|
||||||
|
application = ProtocolTypeRouter({
|
||||||
|
"http": http_application,
|
||||||
|
"websocket": AllowedHostsOriginValidator(
|
||||||
|
JWTAuthMiddlewareStack(
|
||||||
|
URLRouter(websocket_urlpatterns)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|||||||
@ -8,6 +8,10 @@ from rest_framework import status
|
|||||||
from rest_framework.exceptions import NotFound
|
from rest_framework.exceptions import NotFound
|
||||||
from Settings.models import SMTPSettings
|
from Settings.models import SMTPSettings
|
||||||
from Establishment.models import Establishment # Importer le modèle Establishment
|
from Establishment.models import Establishment # Importer le modèle Establishment
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Ajouter un logger pour debug
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def getConnection(id_establishement):
|
def getConnection(id_establishement):
|
||||||
try:
|
try:
|
||||||
@ -53,6 +57,8 @@ def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connec
|
|||||||
from_email = settings.EMAIL_HOST_USER
|
from_email = settings.EMAIL_HOST_USER
|
||||||
|
|
||||||
|
|
||||||
|
logger.info(f"From email: {from_email}")
|
||||||
|
|
||||||
email = EmailMultiAlternatives(
|
email = EmailMultiAlternatives(
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body=plain_message,
|
body=plain_message,
|
||||||
@ -67,10 +73,15 @@ def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connec
|
|||||||
for attachment in attachments:
|
for attachment in attachments:
|
||||||
email.attach(*attachment)
|
email.attach(*attachment)
|
||||||
|
|
||||||
|
logger.info("Tentative d'envoi de l'email...")
|
||||||
email.send(fail_silently=False)
|
email.send(fail_silently=False)
|
||||||
|
logger.info("Email envoyé avec succès !")
|
||||||
return Response({'message': 'Email envoyé avec succès.'}, status=status.HTTP_200_OK)
|
return Response({'message': 'Email envoyé avec succès.'}, status=status.HTTP_200_OK)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[DEBUG] Erreur lors de l'envoi de l'email : {e}")
|
logger.error(f"Erreur lors de l'envoi de l'email: {str(e)}")
|
||||||
|
logger.error(f"Type d'erreur: {type(e)}")
|
||||||
|
import traceback
|
||||||
|
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
def envoieReinitMotDePasse(recipients, code):
|
def envoieReinitMotDePasse(recipients, code):
|
||||||
|
|||||||
@ -46,6 +46,7 @@ INSTALLED_APPS = [
|
|||||||
'Subscriptions.apps.GestioninscriptionsConfig',
|
'Subscriptions.apps.GestioninscriptionsConfig',
|
||||||
'Auth.apps.GestionloginConfig',
|
'Auth.apps.GestionloginConfig',
|
||||||
'GestionMessagerie.apps.GestionMessagerieConfig',
|
'GestionMessagerie.apps.GestionMessagerieConfig',
|
||||||
|
'GestionEmail.apps.GestionEmailConfig',
|
||||||
'GestionNotification.apps.GestionNotificationConfig',
|
'GestionNotification.apps.GestionNotificationConfig',
|
||||||
'School.apps.SchoolConfig',
|
'School.apps.SchoolConfig',
|
||||||
'Planning.apps.PlanningConfig',
|
'Planning.apps.PlanningConfig',
|
||||||
@ -62,14 +63,15 @@ INSTALLED_APPS = [
|
|||||||
'django_celery_beat',
|
'django_celery_beat',
|
||||||
'N3wtSchool',
|
'N3wtSchool',
|
||||||
'drf_yasg',
|
'drf_yasg',
|
||||||
'rest_framework_simplejwt'
|
'rest_framework_simplejwt',
|
||||||
|
'channels',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
'corsheaders.middleware.CorsMiddleware',
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
'django.middleware.common.CommonMiddleware', # Déplacez ici, avant CorsMiddleware
|
'django.middleware.common.CommonMiddleware',
|
||||||
'corsheaders.middleware.CorsMiddleware',
|
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
@ -161,6 +163,11 @@ LOGGING = {
|
|||||||
"level": os.getenv("GESTION_MESSAGERIE_LOG_LEVEL", "INFO"),
|
"level": os.getenv("GESTION_MESSAGERIE_LOG_LEVEL", "INFO"),
|
||||||
"propagate": False,
|
"propagate": False,
|
||||||
},
|
},
|
||||||
|
"GestionEmail": {
|
||||||
|
"handlers": ["console"],
|
||||||
|
"level": os.getenv("GESTION_EMAIL_LOG_LEVEL", "INFO"),
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
"School": {
|
"School": {
|
||||||
"handlers": ["console"],
|
"handlers": ["console"],
|
||||||
"level": os.getenv("GESTION_ENSEIGNANTS_LOG_LEVEL", "INFO"),
|
"level": os.getenv("GESTION_ENSEIGNANTS_LOG_LEVEL", "INFO"),
|
||||||
@ -250,18 +257,35 @@ else:
|
|||||||
|
|
||||||
DOCUMENT_DIR = 'documents'
|
DOCUMENT_DIR = 'documents'
|
||||||
|
|
||||||
CORS_ORIGIN_ALLOW_ALL = True
|
# Configuration CORS temporaire pour debug
|
||||||
CORS_ALLOW_ALL_HEADERS = True
|
CORS_ALLOW_ALL_HEADERS = True
|
||||||
CORS_ALLOW_CREDENTIALS = True
|
CORS_ALLOW_CREDENTIALS = True
|
||||||
|
|
||||||
|
# Configuration CORS spécifique pour la production
|
||||||
|
CORS_ALLOWED_ORIGINS = os.getenv('CORS_ALLOWED_ORIGINS', 'http://localhost:3000,http://localhost:8080,http://127.0.0.1:3000,http://127.0.0.1:8080').split(',')
|
||||||
|
|
||||||
|
|
||||||
CORS_ALLOW_HEADERS = [
|
CORS_ALLOW_HEADERS = [
|
||||||
'content-type',
|
'accept',
|
||||||
|
'accept-encoding',
|
||||||
'authorization',
|
'authorization',
|
||||||
|
'content-type',
|
||||||
|
'dnt',
|
||||||
|
'origin',
|
||||||
|
'user-agent',
|
||||||
|
'x-csrftoken',
|
||||||
|
'x-requested-with',
|
||||||
'X-Auth-Token',
|
'X-Auth-Token',
|
||||||
'x-csrftoken'
|
|
||||||
]
|
]
|
||||||
|
|
||||||
CORS_ALLOWED_ORIGINS = [
|
# Méthodes HTTP autorisées
|
||||||
os.getenv('CORS_ALLOWED_ORIGINS', 'http://localhost:3000')
|
CORS_ALLOWED_METHODS = [
|
||||||
|
'DELETE',
|
||||||
|
'GET',
|
||||||
|
'OPTIONS',
|
||||||
|
'PATCH',
|
||||||
|
'POST',
|
||||||
|
'PUT',
|
||||||
]
|
]
|
||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost:3000,http://localhost:8080').split(',')
|
CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost:3000,http://localhost:8080').split(',')
|
||||||
@ -303,6 +327,7 @@ REST_FRAMEWORK = {
|
|||||||
'PAGE_SIZE': NB_RESULT_SUBSCRIPTIONS_PER_PAGE,
|
'PAGE_SIZE': NB_RESULT_SUBSCRIPTIONS_PER_PAGE,
|
||||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
|
'rest_framework.authentication.SessionAuthentication',
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -344,4 +369,16 @@ DOCUSEAL_JWT = {
|
|||||||
'SIGNING_KEY': SECRET_KEY,
|
'SIGNING_KEY': SECRET_KEY,
|
||||||
'EXPIRATION_DELTA': timedelta(hours=1),
|
'EXPIRATION_DELTA': timedelta(hours=1),
|
||||||
'API_KEY': DOCUSEAL_API_KEY
|
'API_KEY': DOCUSEAL_API_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
# Django Channels Configuration
|
||||||
|
ASGI_APPLICATION = 'N3wtSchool.asgi.application'
|
||||||
|
|
||||||
|
CHANNEL_LAYERS = {
|
||||||
|
'default': {
|
||||||
|
'BACKEND': 'channels_redis.core.RedisChannelLayer',
|
||||||
|
'CONFIG': {
|
||||||
|
"hosts": [('redis', 6379)],
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
@ -43,6 +43,7 @@ urlpatterns = [
|
|||||||
path("Subscriptions/", include(("Subscriptions.urls", 'Subscriptions'), namespace='Subscriptions')),
|
path("Subscriptions/", include(("Subscriptions.urls", 'Subscriptions'), namespace='Subscriptions')),
|
||||||
path("Auth/", include(("Auth.urls", 'Auth'), namespace='Auth')),
|
path("Auth/", include(("Auth.urls", 'Auth'), namespace='Auth')),
|
||||||
path("GestionMessagerie/", include(("GestionMessagerie.urls", 'GestionMessagerie'), namespace='GestionMessagerie')),
|
path("GestionMessagerie/", include(("GestionMessagerie.urls", 'GestionMessagerie'), namespace='GestionMessagerie')),
|
||||||
|
path("GestionEmail/", include(("GestionEmail.urls", 'GestionEmail'), namespace='GestionEmail')),
|
||||||
path("GestionNotification/", include(("GestionNotification.urls", 'GestionNotification'), namespace='GestionNotification')),
|
path("GestionNotification/", include(("GestionNotification.urls", 'GestionNotification'), namespace='GestionNotification')),
|
||||||
path("School/", include(("School.urls", 'School'), namespace='School')),
|
path("School/", include(("School.urls", 'School'), namespace='School')),
|
||||||
path("DocuSeal/", include(("DocuSeal.urls", 'DocuSeal'), namespace='DocuSeal')),
|
path("DocuSeal/", include(("DocuSeal.urls", 'DocuSeal'), namespace='DocuSeal')),
|
||||||
|
|||||||
@ -305,7 +305,7 @@ class RegistrationSchoolFileMaster(models.Model):
|
|||||||
class RegistrationParentFileMaster(models.Model):
|
class RegistrationParentFileMaster(models.Model):
|
||||||
groups = models.ManyToManyField(RegistrationFileGroup, related_name='parent_file_masters', blank=True)
|
groups = models.ManyToManyField(RegistrationFileGroup, related_name='parent_file_masters', blank=True)
|
||||||
name = models.CharField(max_length=255, default="")
|
name = models.CharField(max_length=255, default="")
|
||||||
description = models.CharField(blank=True, null=True)
|
description = models.CharField(max_length=500, blank=True, null=True)
|
||||||
is_required = models.BooleanField(default=False)
|
is_required = models.BooleanField(default=False)
|
||||||
|
|
||||||
############################################################
|
############################################################
|
||||||
@ -355,7 +355,7 @@ class StudentCompetency(models.Model):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ('student', 'establishment_competency', 'period')
|
unique_together = ('student', 'establishment_competency', 'period')
|
||||||
|
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['student', 'establishment_competency', 'period']),
|
models.Index(fields=['student', 'establishment_competency', 'period']),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -8,6 +8,7 @@ APPS = [
|
|||||||
"Planning",
|
"Planning",
|
||||||
"GestionNotification",
|
"GestionNotification",
|
||||||
"GestionMessagerie",
|
"GestionMessagerie",
|
||||||
|
"GestionEmail",
|
||||||
"Auth",
|
"Auth",
|
||||||
"School",
|
"School",
|
||||||
"Common"
|
"Common"
|
||||||
|
|||||||
Binary file not shown.
@ -67,3 +67,6 @@ vine==5.1.0
|
|||||||
wcwidth==0.2.13
|
wcwidth==0.2.13
|
||||||
webencodings==0.5.1
|
webencodings==0.5.1
|
||||||
xhtml2pdf==0.2.16
|
xhtml2pdf==0.2.16
|
||||||
|
channels==4.0.0
|
||||||
|
channels-redis==4.1.0
|
||||||
|
daphne==4.1.0
|
||||||
|
|||||||
@ -14,13 +14,14 @@ test_mode = os.getenv('TEST_MODE', 'False') == 'True'
|
|||||||
|
|
||||||
commands = [
|
commands = [
|
||||||
["python", "manage.py", "collectstatic", "--noinput"],
|
["python", "manage.py", "collectstatic", "--noinput"],
|
||||||
["python", "manage.py", "flush", "--noinput"],
|
#["python", "manage.py", "flush", "--noinput"],
|
||||||
["python", "manage.py", "makemigrations", "Common", "--noinput"],
|
["python", "manage.py", "makemigrations", "Common", "--noinput"],
|
||||||
["python", "manage.py", "makemigrations", "Establishment", "--noinput"],
|
["python", "manage.py", "makemigrations", "Establishment", "--noinput"],
|
||||||
["python", "manage.py", "makemigrations", "Settings", "--noinput"],
|
["python", "manage.py", "makemigrations", "Settings", "--noinput"],
|
||||||
["python", "manage.py", "makemigrations", "Subscriptions", "--noinput"],
|
["python", "manage.py", "makemigrations", "Subscriptions", "--noinput"],
|
||||||
["python", "manage.py", "makemigrations", "Planning", "--noinput"],
|
["python", "manage.py", "makemigrations", "Planning", "--noinput"],
|
||||||
["python", "manage.py", "makemigrations", "GestionNotification", "--noinput"],
|
["python", "manage.py", "makemigrations", "GestionNotification", "--noinput"],
|
||||||
|
["python", "manage.py", "makemigrations", "GestionEmail", "--noinput"],
|
||||||
["python", "manage.py", "makemigrations", "GestionMessagerie", "--noinput"],
|
["python", "manage.py", "makemigrations", "GestionMessagerie", "--noinput"],
|
||||||
["python", "manage.py", "makemigrations", "Auth", "--noinput"],
|
["python", "manage.py", "makemigrations", "Auth", "--noinput"],
|
||||||
["python", "manage.py", "makemigrations", "School", "--noinput"],
|
["python", "manage.py", "makemigrations", "School", "--noinput"],
|
||||||
@ -35,14 +36,15 @@ for command in commands:
|
|||||||
if run_command(command) != 0:
|
if run_command(command) != 0:
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
if test_mode:
|
#if test_mode:
|
||||||
for test_command in test_commands:
|
# for test_command in test_commands:
|
||||||
if run_command(test_command) != 0:
|
# if run_command(test_command) != 0:
|
||||||
exit(1)
|
# exit(1)
|
||||||
|
|
||||||
# Lancer les processus en parallèle
|
# Lancer les processus en parallèle
|
||||||
|
|
||||||
processes = [
|
processes = [
|
||||||
subprocess.Popen(["python", "manage.py", "runserver", "0.0.0.0:8080"]),
|
subprocess.Popen(["daphne", "-b", "0.0.0.0", "-p", "8080", "N3wtSchool.asgi:application"]),
|
||||||
subprocess.Popen(["celery", "-A", "N3wtSchool", "worker", "--loglevel=info"]),
|
subprocess.Popen(["celery", "-A", "N3wtSchool", "worker", "--loglevel=info"]),
|
||||||
subprocess.Popen(["celery", "-A", "N3wtSchool", "beat", "--loglevel=info", "--scheduler", "django_celery_beat.schedulers:DatabaseScheduler"])
|
subprocess.Popen(["celery", "-A", "N3wtSchool", "beat", "--loglevel=info", "--scheduler", "django_celery_beat.schedulers:DatabaseScheduler"])
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
NEXT_PUBLIC_API_URL=http://localhost:8080
|
|
||||||
NEXT_PUBLIC_USE_FAKE_DATA='false'
|
|
||||||
AUTH_SECRET='false'
|
|
||||||
NEXTAUTH_URL=http://localhost:3000
|
|
||||||
DOCUSEAL_API_KEY="LRvUTQCbMSSpManYKshdQk9Do6rBQgjHyPrbGfxU3Jg"
|
|
||||||
340
Front-End/docs/api-messagerie-technique.md
Normal file
340
Front-End/docs/api-messagerie-technique.md
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
# API Messagerie Instantanée - Guide Développeur
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
Cette documentation technique présente l'implémentation du système de messagerie instantanée, incluant les APIs WebSocket et REST, l'architecture des composants React et les fonctions utilitaires.
|
||||||
|
|
||||||
|
## API WebSocket
|
||||||
|
|
||||||
|
### Connexion
|
||||||
|
|
||||||
|
**URL de connexion :**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Développement
|
||||||
|
ws://localhost:8000/ws/chat/{userId}/
|
||||||
|
|
||||||
|
// Production
|
||||||
|
wss://[domaine]/ws/chat/{userId}/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Messages WebSocket
|
||||||
|
|
||||||
|
#### Messages entrants (serveur → client)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Liste des conversations
|
||||||
|
{
|
||||||
|
"type": "conversations_list",
|
||||||
|
"conversations": [...]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nouveau message reçu
|
||||||
|
{
|
||||||
|
"type": "new_message",
|
||||||
|
"message": {
|
||||||
|
"id": 123,
|
||||||
|
"conversation_id": 456,
|
||||||
|
"sender_id": 789,
|
||||||
|
"content": "Contenu du message",
|
||||||
|
"timestamp": "2024-01-01T12:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utilisateur en train d'écrire
|
||||||
|
{
|
||||||
|
"type": "typing_start",
|
||||||
|
"conversation_id": 456,
|
||||||
|
"user_id": 789
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utilisateur a arrêté d'écrire
|
||||||
|
{
|
||||||
|
"type": "typing_stop",
|
||||||
|
"conversation_id": 456,
|
||||||
|
"user_id": 789
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Messages sortants (client → serveur)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Envoyer un message
|
||||||
|
{
|
||||||
|
"type": "chat_message",
|
||||||
|
"conversation_id": 456,
|
||||||
|
"message": "Contenu du message"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signaler début de frappe
|
||||||
|
{
|
||||||
|
"type": "typing_start",
|
||||||
|
"conversation_id": 456
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signaler fin de frappe
|
||||||
|
{
|
||||||
|
"type": "typing_stop",
|
||||||
|
"conversation_id": 456
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marquer comme lu
|
||||||
|
{
|
||||||
|
"type": "mark_as_read",
|
||||||
|
"conversation_id": 456
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rejoindre une conversation
|
||||||
|
{
|
||||||
|
"type": "join_conversation",
|
||||||
|
"conversation_id": 456
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API REST
|
||||||
|
|
||||||
|
### Endpoints disponibles
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Récupérer les conversations
|
||||||
|
GET /api/messagerie/conversations/{userId}/
|
||||||
|
Response: Array<Conversation>
|
||||||
|
|
||||||
|
// Récupérer les messages d'une conversation
|
||||||
|
GET /api/messagerie/messages/{conversationId}/
|
||||||
|
Response: Array<Message>
|
||||||
|
|
||||||
|
// Rechercher des destinataires
|
||||||
|
GET /api/messagerie/search/{establishmentId}/?q={query}
|
||||||
|
Response: Array<User>
|
||||||
|
|
||||||
|
// Créer une conversation
|
||||||
|
POST /api/messagerie/conversations/create/
|
||||||
|
Body: { "participants": [userId1, userId2] }
|
||||||
|
Response: Conversation
|
||||||
|
|
||||||
|
// Envoyer un email (séparé de la messagerie instantanée)
|
||||||
|
POST /api/email/send/
|
||||||
|
Body: { "recipients": [...], "subject": "...", "content": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Composants React
|
||||||
|
|
||||||
|
### InstantChat
|
||||||
|
|
||||||
|
**Props :**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
userProfileId: number, // ID de l'utilisateur connecté
|
||||||
|
establishmentId: number // ID de l'établissement
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**États principaux :**
|
||||||
|
|
||||||
|
- `conversations` : Liste des conversations
|
||||||
|
- `selectedConversation` : Conversation active
|
||||||
|
- `messages` : Messages de la conversation active
|
||||||
|
- `searchQuery` : Terme de recherche
|
||||||
|
- `searchResults` : Résultats de recherche de contacts
|
||||||
|
|
||||||
|
### useWebSocket Hook
|
||||||
|
|
||||||
|
**Paramètres :**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
useWebSocket(
|
||||||
|
userProfileId, // ID utilisateur
|
||||||
|
onMessage, // Callback pour messages reçus
|
||||||
|
onConnectionChange // Callback changement de connexion
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Valeurs retournées :**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
isConnected: boolean,
|
||||||
|
connectionStatus: string,
|
||||||
|
sendChatMessage: (conversationId, content) => boolean,
|
||||||
|
sendTypingStart: (conversationId) => void,
|
||||||
|
sendTypingStop: (conversationId) => void,
|
||||||
|
markAsRead: (conversationId) => void,
|
||||||
|
joinConversation: (conversationId) => void,
|
||||||
|
reconnect: () => void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Actions Redux/State
|
||||||
|
|
||||||
|
### messagerieAction.js
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Récupérer les conversations
|
||||||
|
fetchConversations(userId): Promise<Array<Conversation>>
|
||||||
|
|
||||||
|
// Récupérer les messages
|
||||||
|
fetchMessages(conversationId): Promise<Array<Message>>
|
||||||
|
|
||||||
|
// Rechercher des destinataires
|
||||||
|
searchMessagerieRecipients(establishmentId, query): Promise<Array<User>>
|
||||||
|
|
||||||
|
// Créer une conversation
|
||||||
|
createConversation(participants): Promise<Conversation>
|
||||||
|
```
|
||||||
|
|
||||||
|
### emailAction.js
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Envoyer un email
|
||||||
|
sendEmail(recipients, subject, content, csrfToken): Promise<Response>
|
||||||
|
|
||||||
|
// Rechercher des destinataires email
|
||||||
|
searchEmailRecipients(establishmentId, query): Promise<Array<User>>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modèles de Données
|
||||||
|
|
||||||
|
### Conversation
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
conversation_id: number,
|
||||||
|
participants: Array<User>,
|
||||||
|
last_message: Message,
|
||||||
|
created_at: string,
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Message
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
id: number,
|
||||||
|
conversation_id: number,
|
||||||
|
sender_id: number,
|
||||||
|
content: string,
|
||||||
|
timestamp: string,
|
||||||
|
is_read: boolean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### User
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
id: number,
|
||||||
|
first_name: string,
|
||||||
|
last_name: string,
|
||||||
|
email: string,
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gestion des Erreurs
|
||||||
|
|
||||||
|
### WebSocket
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Reconnexion automatique
|
||||||
|
const reconnectWebSocket = () => {
|
||||||
|
setConnectionStatus('reconnecting');
|
||||||
|
// Logique de reconnexion avec backoff exponentiel
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gestion des erreurs de connexion
|
||||||
|
wsRef.current.onerror = (error) => {
|
||||||
|
logger.error('Erreur WebSocket:', error);
|
||||||
|
setIsConnected(false);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### API REST
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Wrapper avec gestion d'erreur
|
||||||
|
const apiCall = async (url, options) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Erreur ${response.status}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Erreur API:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration des Tests
|
||||||
|
|
||||||
|
### Jest Setup
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// jest.setup.js
|
||||||
|
global.WebSocket = class MockWebSocket {
|
||||||
|
// Mock complet du WebSocket pour les tests
|
||||||
|
};
|
||||||
|
|
||||||
|
global.fetch = jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve([]),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests des Composants
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Exemple de test
|
||||||
|
test('renders InstantChat component', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
render(<InstantChat userProfileId={1} establishmentId={123} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('Messages')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Intégration Backend
|
||||||
|
|
||||||
|
### Consumer Django
|
||||||
|
|
||||||
|
```python
|
||||||
|
# consumers.py
|
||||||
|
class ChatConsumer(AsyncWebsocketConsumer):
|
||||||
|
async def connect(self):
|
||||||
|
# Logique de connexion
|
||||||
|
|
||||||
|
async def chat_message(self, event):
|
||||||
|
# Traitement des messages
|
||||||
|
```
|
||||||
|
|
||||||
|
### URLs Configuration
|
||||||
|
|
||||||
|
```python
|
||||||
|
# routing.py
|
||||||
|
websocket_urlpatterns = [
|
||||||
|
re_path(r'ws/chat/(?P<user_id>\w+)/$', ChatConsumer.as_asgi()),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Optimisations
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Pagination des messages anciens (load on scroll)
|
||||||
|
- Debounce pour la recherche de contacts (300ms)
|
||||||
|
- Memoization des composants avec React.memo
|
||||||
|
- Lazy loading des conversations
|
||||||
|
|
||||||
|
### UX
|
||||||
|
|
||||||
|
- Reconnexion automatique avec feedback visuel
|
||||||
|
- Sauvegarde locale des messages en cours de frappe
|
||||||
|
- Indicateurs de livraison des messages
|
||||||
|
- Scrolling automatique vers les nouveaux messages
|
||||||
126
Front-End/docs/messagerie-instantanee.md
Normal file
126
Front-End/docs/messagerie-instantanee.md
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
# Système de Messagerie Instantanée
|
||||||
|
|
||||||
|
## Présentation
|
||||||
|
|
||||||
|
Le système de messagerie instantanée de N3WT-SCHOOL permet aux utilisateurs de l'établissement (administrateurs, professeurs, parents, étudiants) de communiquer en temps réel via une interface chat moderne et intuitive.
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
### Chat en Temps Réel
|
||||||
|
|
||||||
|
- Envoi et réception de messages instantanés
|
||||||
|
- Notification de statut de frappe (utilisateur en train d'écrire)
|
||||||
|
- Indicateur de statut de connexion WebSocket
|
||||||
|
- Reconnexion automatique en cas de perte de connexion
|
||||||
|
|
||||||
|
### Gestion des Conversations
|
||||||
|
|
||||||
|
- Liste des conversations existantes
|
||||||
|
- Création de nouvelles conversations
|
||||||
|
- Recherche de destinataires par nom ou email
|
||||||
|
- Compteur de messages non lus
|
||||||
|
|
||||||
|
### Interface Utilisateur
|
||||||
|
|
||||||
|
- Interface moderne en deux panneaux (conversations + chat)
|
||||||
|
- Bulles de messages différenciées (expéditeur/destinataire)
|
||||||
|
- Indicateurs visuels de statut de connexion
|
||||||
|
- Recherche temps réel de contacts
|
||||||
|
|
||||||
|
## Utilisation
|
||||||
|
|
||||||
|
### Accès au Chat
|
||||||
|
|
||||||
|
Le système de messagerie est accessible via les pages suivantes :
|
||||||
|
|
||||||
|
- **Parents** : `/[locale]/parents/messagerie`
|
||||||
|
- **Administrateurs** : Intégré dans le panneau d'administration
|
||||||
|
|
||||||
|
### Créer une Conversation
|
||||||
|
|
||||||
|
1. Cliquer sur le bouton "+" en haut à droite de la liste des conversations
|
||||||
|
2. Rechercher un contact en tapant son nom ou email
|
||||||
|
3. Sélectionner le destinataire dans les résultats
|
||||||
|
4. La conversation se crée automatiquement
|
||||||
|
|
||||||
|
### Envoyer un Message
|
||||||
|
|
||||||
|
1. Sélectionner une conversation dans la liste de gauche
|
||||||
|
2. Taper le message dans le champ de saisie en bas
|
||||||
|
3. Appuyer sur Entrée ou cliquer sur le bouton d'envoi
|
||||||
|
|
||||||
|
## Architecture Technique
|
||||||
|
|
||||||
|
### Frontend (React/Next.js)
|
||||||
|
|
||||||
|
**Composants principaux :**
|
||||||
|
|
||||||
|
- `InstantChat` : Composant principal du chat
|
||||||
|
- `ConnectionStatus` : Affichage du statut de connexion
|
||||||
|
- `ConversationItem` : Élément de liste de conversation
|
||||||
|
- `MessageBubble` : Bulle de message individuelle
|
||||||
|
- `MessageInput` : Zone de saisie de message
|
||||||
|
- `TypingIndicator` : Indicateur de frappe
|
||||||
|
|
||||||
|
**Hook personnalisé :**
|
||||||
|
|
||||||
|
- `useWebSocket` : Gestion de la connexion WebSocket et des événements
|
||||||
|
|
||||||
|
### Backend (Django)
|
||||||
|
|
||||||
|
**Module GestionMessagerie :**
|
||||||
|
|
||||||
|
- `consumers.py` : Consumer WebSocket pour la messagerie temps réel
|
||||||
|
- `routing.py` : Configuration des routes WebSocket
|
||||||
|
- `urls.py` : URLs API REST pour les conversations et messages
|
||||||
|
|
||||||
|
**Module GestionEmail :**
|
||||||
|
|
||||||
|
- `views.py` : Vues pour l'envoi d'emails classiques
|
||||||
|
- `urls.py` : URLs pour les fonctions email
|
||||||
|
|
||||||
|
### Communication
|
||||||
|
|
||||||
|
- **WebSocket** : Communication bidirectionnelle temps réel
|
||||||
|
- **REST API** : Chargement initial des données et recherche
|
||||||
|
- **Channels** : Gestion des groupes de conversation Django
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### URLs WebSocket
|
||||||
|
|
||||||
|
Les URLs sont configurées automatiquement selon l'environnement :
|
||||||
|
|
||||||
|
- **Développement** : `ws://localhost:8000/ws/chat/`
|
||||||
|
- **Production** : `wss://[domaine]/ws/chat/`
|
||||||
|
|
||||||
|
### Variables d'Environnement
|
||||||
|
|
||||||
|
Le système utilise les configurations standard de l'application pour :
|
||||||
|
|
||||||
|
- Base de données (conversations, messages, utilisateurs)
|
||||||
|
- Authentification (sessions Django)
|
||||||
|
- Établissements (filtrage par établissement)
|
||||||
|
|
||||||
|
## Sécurité
|
||||||
|
|
||||||
|
- Authentification requise pour accéder au chat
|
||||||
|
- Filtrage des conversations par établissement
|
||||||
|
- Validation côté serveur de tous les messages
|
||||||
|
- Gestion des permissions selon le rôle utilisateur
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Le système dispose de tests unitaires Jest couvrant :
|
||||||
|
|
||||||
|
- Rendu des composants
|
||||||
|
- Gestion des connexions WebSocket
|
||||||
|
- Recherche de contacts
|
||||||
|
- Envoi de messages
|
||||||
|
- Indicateurs de frappe
|
||||||
|
|
||||||
|
Exécution des tests :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
33
Front-End/jest.config.js
Normal file
33
Front-End/jest.config.js
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
const nextJest = require('next/jest');
|
||||||
|
|
||||||
|
const createJestConfig = nextJest({
|
||||||
|
// Provide the path to your Next.js app to load next.config.js and .env files
|
||||||
|
dir: './',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add any custom config to be passed to Jest
|
||||||
|
const customJestConfig = {
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||||
|
testEnvironment: 'jest-environment-jsdom',
|
||||||
|
testPathIgnorePatterns: ['<rootDir>/.next/', '<rootDir>/node_modules/'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
|
},
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'src/**/*.{js,jsx}',
|
||||||
|
'!src/**/*.stories.{js,jsx}',
|
||||||
|
'!src/pages/_app.js',
|
||||||
|
'!src/pages/_document.js',
|
||||||
|
],
|
||||||
|
coverageThreshold: {
|
||||||
|
global: {
|
||||||
|
branches: 70,
|
||||||
|
functions: 70,
|
||||||
|
lines: 70,
|
||||||
|
statements: 70,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
|
||||||
|
module.exports = createJestConfig(customJestConfig);
|
||||||
95
Front-End/jest.setup.js
Normal file
95
Front-End/jest.setup.js
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
// Supprimer les avertissements React act() en environnement de test
|
||||||
|
global.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
// Mock window.matchMedia
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: jest.fn().mockImplementation((query) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: jest.fn(), // deprecated
|
||||||
|
removeListener: jest.fn(), // deprecated
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
removeEventListener: jest.fn(),
|
||||||
|
dispatchEvent: jest.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock IntersectionObserver
|
||||||
|
global.IntersectionObserver = class IntersectionObserver {
|
||||||
|
constructor() {}
|
||||||
|
observe() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
disconnect() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
unobserve() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock WebSocket
|
||||||
|
global.WebSocket = class WebSocket {
|
||||||
|
constructor(url) {
|
||||||
|
this.url = url;
|
||||||
|
this.readyState = WebSocket.CONNECTING;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.readyState = WebSocket.OPEN;
|
||||||
|
if (this.onopen) this.onopen();
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
send(data) {
|
||||||
|
// Mock send
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.readyState = WebSocket.CLOSED;
|
||||||
|
if (this.onclose) {
|
||||||
|
this.onclose({
|
||||||
|
code: 1000,
|
||||||
|
reason: 'Normal closure',
|
||||||
|
wasClean: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static get CONNECTING() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
static get OPEN() {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
static get CLOSING() {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
static get CLOSED() {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock global pour fetch
|
||||||
|
global.fetch = jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve([]),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock ResizeObserver
|
||||||
|
global.ResizeObserver = class ResizeObserver {
|
||||||
|
constructor() {}
|
||||||
|
observe() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
disconnect() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
unobserve() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -24,6 +24,8 @@ const nextConfig = {
|
|||||||
NEXT_PUBLIC_APP_VERSION: pkg.version,
|
NEXT_PUBLIC_APP_VERSION: pkg.version,
|
||||||
NEXT_PUBLIC_API_URL:
|
NEXT_PUBLIC_API_URL:
|
||||||
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080',
|
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080',
|
||||||
|
NEXT_PUBLIC_WSAPI_URL:
|
||||||
|
process.env.NEXT_PUBLIC_WSAPI_URL || 'ws://localhost:8080',
|
||||||
NEXT_PUBLIC_USE_FAKE_DATA: process.env.NEXT_PUBLIC_USE_FAKE_DATA || 'false',
|
NEXT_PUBLIC_USE_FAKE_DATA: process.env.NEXT_PUBLIC_USE_FAKE_DATA || 'false',
|
||||||
AUTH_SECRET: process.env.AUTH_SECRET || 'false',
|
AUTH_SECRET: process.env.AUTH_SECRET || 'false',
|
||||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL || 'http://localhost:3000',
|
NEXTAUTH_URL: process.env.NEXTAUTH_URL || 'http://localhost:3000',
|
||||||
|
|||||||
7445
Front-End/package-lock.json
generated
7445
Front-End/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -8,7 +8,10 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"lint-light": "next lint --quiet",
|
"lint-light": "next lint --quiet",
|
||||||
"check-strings": "node scripts/check-hardcoded-strings.js"
|
"check-strings": "node scripts/check-hardcoded-strings.js",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:coverage": "jest --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@docuseal/react": "^1.0.56",
|
"@docuseal/react": "^1.0.56",
|
||||||
@ -37,10 +40,15 @@
|
|||||||
"react-tooltip": "^5.28.0"
|
"react-tooltip": "^5.28.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/react": "^13.4.0",
|
||||||
|
"@testing-library/jest-dom": "^5.16.5",
|
||||||
|
"@testing-library/user-event": "^14.4.3",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "14.2.11",
|
"eslint-config-next": "14.2.11",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"tailwindcss": "^3.4.14"
|
"tailwindcss": "^3.4.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
NEXT_PUBLIC_API_URL=_NEXT_PUBLIC_API_URL_
|
NEXT_PUBLIC_API_URL=_NEXT_PUBLIC_API_URL_
|
||||||
|
NEXT_PUBLIC_WSAPI_URL=_NEXT_PUBLIC_WSAPI_URL_
|
||||||
NEXT_PUBLIC_USE_FAKE_DATA=_NEXT_PUBLIC_USE_FAKE_DATA_
|
NEXT_PUBLIC_USE_FAKE_DATA=_NEXT_PUBLIC_USE_FAKE_DATA_
|
||||||
AUTH_SECRET=_AUTH_SECRET_
|
AUTH_SECRET=_AUTH_SECRET_
|
||||||
NEXTAUTH_URL=_NEXTAUTH_URL_
|
NEXTAUTH_URL=_NEXTAUTH_URL_
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import Sidebar from '@/components/Sidebar';
|
import Sidebar from '@/components/Sidebar';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import Image from 'next/image';
|
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
FileText,
|
FileText,
|
||||||
@ -13,11 +12,8 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
Settings,
|
Settings,
|
||||||
LogOut,
|
LogOut,
|
||||||
Menu,
|
MessageSquare,
|
||||||
X,
|
|
||||||
Mail,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import DropdownMenu from '@/components/DropdownMenu';
|
|
||||||
|
|
||||||
import Popup from '@/components/Popup';
|
import Popup from '@/components/Popup';
|
||||||
import {
|
import {
|
||||||
@ -86,7 +82,7 @@ export default function Layout({ children }) {
|
|||||||
id: 'messagerie',
|
id: 'messagerie',
|
||||||
name: t('messagerie'),
|
name: t('messagerie'),
|
||||||
url: FE_ADMIN_MESSAGERIE_URL,
|
url: FE_ADMIN_MESSAGERIE_URL,
|
||||||
icon: Mail,
|
icon: MessageSquare,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
id: 'settings',
|
id: 'settings',
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import SidebarTabs from '@/components/SidebarTabs';
|
import SidebarTabs from '@/components/SidebarTabs';
|
||||||
import EmailSender from '@/components/Admin/EmailSender';
|
import EmailSender from '@/components/Admin/EmailSender';
|
||||||
import InstantMessaging from '@/components/Admin/InstantMessaging';
|
import InstantMessaging from '@/components/Admin/InstantMessaging';
|
||||||
@ -26,11 +26,8 @@ export default function MessageriePage({ csrfToken }) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full">
|
<div className="h-full flex flex-col p-0 m-0">
|
||||||
<SidebarTabs
|
<SidebarTabs tabs={tabs} />
|
||||||
tabs={tabs}
|
|
||||||
onTabChange={(tabId) => logger.debug(`Onglet actif : ${tabId}`)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,31 +1,64 @@
|
|||||||
'use client';
|
'use client';
|
||||||
// src/components/Layout.js
|
// src/components/Layout.js
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import ProfileSelector from '@/components/ProfileSelector';
|
import Sidebar from '@/components/Sidebar';
|
||||||
import { useRouter } from 'next/navigation'; // Ajout de l'importation
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
import { MessageSquare, LogOut, Settings, Home } from 'lucide-react'; // Ajout de l'importation de l'icône Home
|
import { MessageSquare, Settings, Home, Menu } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
FE_PARENTS_HOME_URL,
|
FE_PARENTS_HOME_URL,
|
||||||
FE_PARENTS_MESSAGERIE_URL,
|
FE_PARENTS_MESSAGERIE_URL,
|
||||||
FE_PARENTS_SETTINGS_URL,
|
FE_PARENTS_SETTINGS_URL,
|
||||||
} from '@/utils/Url'; // Ajout de l'importation de l'URL de la page d'accueil parent
|
} 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';
|
||||||
import logger from '@/utils/logger';
|
import { RIGHTS } from '@/utils/rights';
|
||||||
import { getRightStr, RIGHTS } from '@/utils/rights';
|
|
||||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
|
|
||||||
import Footer from '@/components/Footer';
|
import Footer from '@/components/Footer';
|
||||||
|
|
||||||
export default function Layout({ children }) {
|
export default function Layout({ children }) {
|
||||||
const router = useRouter(); // Définition de router
|
const router = useRouter();
|
||||||
const [messages, setMessages] = useState([]);
|
const pathname = usePathname();
|
||||||
const [isPopupVisible, setIsPopupVisible] = useState(false);
|
const [isPopupVisible, setIsPopupVisible] = useState(false);
|
||||||
const { profileRole, user, clearContext } = useEstablishment();
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
|
const { clearContext } = useEstablishment();
|
||||||
const softwareName = 'N3WT School';
|
const softwareName = 'N3WT School';
|
||||||
const softwareVersion = `${process.env.NEXT_PUBLIC_APP_VERSION}`;
|
const softwareVersion = `${process.env.NEXT_PUBLIC_APP_VERSION}`;
|
||||||
|
|
||||||
|
// Vérifier si on est sur la page messagerie
|
||||||
|
const isMessagingPage = pathname?.includes('/messagerie');
|
||||||
|
|
||||||
|
// Configuration des éléments de la sidebar pour les parents
|
||||||
|
const sidebarItems = [
|
||||||
|
{
|
||||||
|
id: 'home',
|
||||||
|
name: 'Accueil',
|
||||||
|
url: FE_PARENTS_HOME_URL,
|
||||||
|
icon: Home,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'messagerie',
|
||||||
|
name: 'Messagerie',
|
||||||
|
url: FE_PARENTS_MESSAGERIE_URL,
|
||||||
|
icon: MessageSquare,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'settings',
|
||||||
|
name: 'Paramètres',
|
||||||
|
url: FE_PARENTS_SETTINGS_URL,
|
||||||
|
icon: Settings,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Déterminer la page actuelle pour la sidebar
|
||||||
|
const getCurrentPage = () => {
|
||||||
|
if (pathname?.includes('/messagerie')) return 'messagerie';
|
||||||
|
if (pathname?.includes('/settings')) return 'settings';
|
||||||
|
return 'home';
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentPage = getCurrentPage();
|
||||||
|
|
||||||
const handleDisconnect = () => {
|
const handleDisconnect = () => {
|
||||||
setIsPopupVisible(true);
|
setIsPopupVisible(true);
|
||||||
};
|
};
|
||||||
@ -35,52 +68,63 @@ export default function Layout({ children }) {
|
|||||||
disconnect();
|
disconnect();
|
||||||
clearContext();
|
clearContext();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
setIsSidebarOpen(!isSidebarOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fermer la sidebar quand on change de page sur mobile
|
||||||
|
setIsSidebarOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProtectedRoute requiredRight={RIGHTS.PARENT}>
|
<ProtectedRoute requiredRight={RIGHTS.PARENT}>
|
||||||
<div className="flex flex-col min-h-screen bg-gray-50">
|
{/* Bouton hamburger pour mobile */}
|
||||||
{/* Entête */}
|
<button
|
||||||
<header className="h-16 bg-white border-b border-gray-200 px-4 md:px-8 py-4 flex items-center justify-between fixed top-0 left-0 right-0 z-10">
|
onClick={toggleSidebar}
|
||||||
<div className="flex items-center space-x-2">
|
className="fixed top-4 left-4 z-40 p-2 rounded-md bg-white shadow-lg border border-gray-200 md:hidden"
|
||||||
{/* Suppression du menu profil parent */}
|
>
|
||||||
|
<Menu size={20} />
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="text-lg md:text-xl p-2 font-semibold">Accueil</div>
|
{/* Sidebar */}
|
||||||
</div>
|
<div
|
||||||
<div className="flex items-center space-x-2 md:space-x-4">
|
className={`absolute top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${
|
||||||
<button
|
isSidebarOpen ? 'block' : 'hidden md:block'
|
||||||
className="p-1 md:p-2 rounded-full hover:bg-gray-200"
|
}`}
|
||||||
onClick={() => {
|
>
|
||||||
router.push(FE_PARENTS_HOME_URL);
|
<Sidebar
|
||||||
}}
|
currentPage={currentPage}
|
||||||
>
|
items={sidebarItems}
|
||||||
<Home className="h-5 w-5 md:h-6 md:w-6" />
|
onCloseMobile={toggleSidebar}
|
||||||
</button>
|
/>
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
className="p-1 md:p-2 rounded-full hover:bg-gray-200"
|
|
||||||
onClick={() => {
|
|
||||||
router.push(FE_PARENTS_MESSAGERIE_URL);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MessageSquare className="h-5 w-5 md:h-6 md:w-6" />
|
|
||||||
</button>
|
|
||||||
{messages.length > 0 && (
|
|
||||||
<span className="absolute top-0 right-0 block h-2 w-2 rounded-full bg-emerald-600"></span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ProfileSelector className="w-64 border-b border-gray-200 " />
|
|
||||||
{/* Suppression du DropdownMenu profil parent */}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="pt-16 md:pt-20 p-4 md:p-8 flex-1">
|
|
||||||
{' '}
|
|
||||||
{/* Ajout de flex-1 pour utiliser toute la hauteur disponible */}
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
{/* Footer responsive */}
|
|
||||||
<Footer softwareName={softwareName} softwareVersion={softwareVersion} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Overlay for mobile */}
|
||||||
|
{isSidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-50 z-20 md:hidden"
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main container */}
|
||||||
|
<div
|
||||||
|
className={`absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-0 bottom-16 left-0 md:left-64 right-0 ${!isMessagingPage ? 'p-4 md:p-8' : ''}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Footer softwareName={softwareName} softwareVersion={softwareVersion} />
|
||||||
|
|
||||||
|
<Popup
|
||||||
|
isOpen={isPopupVisible}
|
||||||
|
message="Êtes-vous sûr(e) de vouloir vous déconnecter ?"
|
||||||
|
onConfirm={confirmDisconnect}
|
||||||
|
onCancel={() => setIsPopupVisible(false)}
|
||||||
|
/>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,28 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React from 'react';
|
||||||
import Chat from '@/components/Chat';
|
import InstantChat from '@/components/Chat/InstantChat';
|
||||||
|
|
||||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
|
|
||||||
export default function MessageriePage() {
|
export default function MessageriePage() {
|
||||||
const { user, selectedEstablishmentId } = useEstablishment();
|
const { user, selectedEstablishmentId } = useEstablishment();
|
||||||
|
|
||||||
if (!user) return <div>Chargement...</div>;
|
if (!user?.user_id || !selectedEstablishmentId) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full w-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-600">Chargement de la messagerie...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Chat userProfileId={user.id} establishmentId={selectedEstablishmentId} />
|
<div className="h-full flex flex-col">
|
||||||
|
<InstantChat
|
||||||
|
userProfileId={user.user_id}
|
||||||
|
establishmentId={selectedEstablishmentId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
34
Front-End/src/app/actions/emailAction.js
Normal file
34
Front-End/src/app/actions/emailAction.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
BE_GESTIONEMAIL_SEARCH_RECIPIENTS_URL,
|
||||||
|
BE_GESTIONEMAIL_SEND_EMAIL_URL,
|
||||||
|
} from '@/utils/Url';
|
||||||
|
import { errorHandler, requestResponseHandler } from './actionsHandlers';
|
||||||
|
import { getCsrfToken } from '@/utils/getCsrfToken';
|
||||||
|
|
||||||
|
// Recherche de destinataires pour email
|
||||||
|
export const searchRecipients = (establishmentId, query) => {
|
||||||
|
const url = `${BE_GESTIONEMAIL_SEARCH_RECIPIENTS_URL}/?establishment_id=${establishmentId}&q=${encodeURIComponent(query)}`;
|
||||||
|
return fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(requestResponseHandler)
|
||||||
|
.catch(errorHandler);
|
||||||
|
};
|
||||||
|
// Envoyer un email
|
||||||
|
export const sendEmail = async (messageData) => {
|
||||||
|
const csrfToken = getCsrfToken();
|
||||||
|
return fetch(BE_GESTIONEMAIL_SEND_EMAIL_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(messageData),
|
||||||
|
})
|
||||||
|
.then(requestResponseHandler)
|
||||||
|
.catch(errorHandler);
|
||||||
|
};
|
||||||
@ -1,67 +1,251 @@
|
|||||||
import {
|
import {
|
||||||
BE_GESTIONMESSAGERIE_CONVERSATIONS_URL,
|
BE_GESTIONMESSAGERIE_CONVERSATIONS_URL,
|
||||||
BE_GESTIONMESSAGERIE_CONVERSATION_MESSAGES_URL,
|
BE_GESTIONMESSAGERIE_SEND_MESSAGE_URL,
|
||||||
BE_GESTIONMESSAGERIE_MARK_AS_READ_URL,
|
BE_GESTIONMESSAGERIE_CREATE_CONVERSATION_URL,
|
||||||
BE_GESTIONMESSAGERIE_MESSAGES_URL,
|
|
||||||
BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL,
|
BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL,
|
||||||
|
BE_GESTIONMESSAGERIE_MARK_AS_READ_URL,
|
||||||
|
BE_GESTIONMESSAGERIE_UPLOAD_FILE_URL,
|
||||||
|
BE_GESTIONMESSAGERIE_DELETE_CONVERSATION_URL,
|
||||||
} from '@/utils/Url';
|
} from '@/utils/Url';
|
||||||
import { errorHandler, requestResponseHandler } from './actionsHandlers';
|
import { errorHandler, requestResponseHandler } from './actionsHandlers';
|
||||||
|
import logger from '@/utils/logger';
|
||||||
|
|
||||||
export const fetchConversations = (profileId) => {
|
// Helper pour construire les en-têtes avec CSRF
|
||||||
return fetch(`${BE_GESTIONMESSAGERIE_CONVERSATIONS_URL}/${profileId}/`, {
|
const buildHeaders = (csrfToken) => {
|
||||||
headers: {
|
const headers = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
};
|
||||||
})
|
|
||||||
.then(requestResponseHandler)
|
// Ajouter le token CSRF
|
||||||
.catch(errorHandler);
|
if (csrfToken) {
|
||||||
|
headers['X-CSRFToken'] = csrfToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchMessages = (conversationId) => {
|
/**
|
||||||
return fetch(
|
* Récupère les conversations d'un utilisateur
|
||||||
`${BE_GESTIONMESSAGERIE_CONVERSATION_MESSAGES_URL}/${conversationId}/`,
|
*/
|
||||||
{
|
export const fetchConversations = async (userId, csrfToken) => {
|
||||||
headers: {
|
try {
|
||||||
'Content-Type': 'application/json',
|
// Utiliser la nouvelle route avec user_id en paramètre d'URL
|
||||||
},
|
const url = `${BE_GESTIONMESSAGERIE_CONVERSATIONS_URL}/user/${userId}/`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: buildHeaders(csrfToken),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
return await requestResponseHandler(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Erreur lors de la récupération des conversations:', error);
|
||||||
|
return errorHandler(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les messages d'une conversation
|
||||||
|
*/
|
||||||
|
export const fetchMessages = async (
|
||||||
|
conversationId,
|
||||||
|
page = 1,
|
||||||
|
limit = 50,
|
||||||
|
csrfToken,
|
||||||
|
userId = null
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
// Utiliser la nouvelle URL avec conversation_id en paramètre d'URL
|
||||||
|
let url = `${BE_GESTIONMESSAGERIE_CONVERSATIONS_URL}/${conversationId}/messages/?page=${page}&limit=${limit}`;
|
||||||
|
|
||||||
|
// Ajouter user_id si fourni pour calculer correctement is_read
|
||||||
|
if (userId) {
|
||||||
|
url += `&user_id=${userId}`;
|
||||||
}
|
}
|
||||||
)
|
|
||||||
.then(requestResponseHandler)
|
const response = await fetch(url, {
|
||||||
.catch(errorHandler);
|
method: 'GET',
|
||||||
|
headers: buildHeaders(csrfToken),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
return await requestResponseHandler(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Erreur lors de la récupération des messages:', error);
|
||||||
|
return errorHandler(error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sendMessage = (data) => {
|
/**
|
||||||
return fetch(`${BE_GESTIONMESSAGERIE_MESSAGES_URL}`, {
|
* Envoie un message dans une conversation
|
||||||
method: 'POST',
|
*/
|
||||||
headers: {
|
export const sendMessage = async (messageData, csrfToken) => {
|
||||||
'Content-Type': 'application/json',
|
try {
|
||||||
},
|
const response = await fetch(BE_GESTIONMESSAGERIE_SEND_MESSAGE_URL, {
|
||||||
body: JSON.stringify(data),
|
method: 'POST',
|
||||||
})
|
headers: buildHeaders(csrfToken),
|
||||||
.then(requestResponseHandler)
|
credentials: 'include',
|
||||||
.catch(errorHandler);
|
body: JSON.stringify(messageData),
|
||||||
|
});
|
||||||
|
return await requestResponseHandler(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Erreur lors de l'envoi du message:", error);
|
||||||
|
return errorHandler(error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const markAsRead = (conversationId, profileId) => {
|
/**
|
||||||
return fetch(`${BE_GESTIONMESSAGERIE_MARK_AS_READ_URL}/${conversationId}/`, {
|
* Crée une nouvelle conversation
|
||||||
method: 'POST',
|
*/
|
||||||
headers: {
|
export const createConversation = async (participantIds, csrfToken) => {
|
||||||
'Content-Type': 'application/json',
|
try {
|
||||||
},
|
const requestBody = {
|
||||||
body: JSON.stringify({ profile_id: profileId }),
|
participant_ids: participantIds, // Le backend attend "participant_ids"
|
||||||
})
|
conversation_type: 'private', // Spécifier le type de conversation
|
||||||
.then(requestResponseHandler)
|
name: '', // Le nom sera généré côté backend
|
||||||
.catch(errorHandler);
|
};
|
||||||
|
|
||||||
|
const response = await fetch(BE_GESTIONMESSAGERIE_CREATE_CONVERSATION_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: buildHeaders(csrfToken),
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
return await requestResponseHandler(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Erreur lors de la création de la conversation:', error);
|
||||||
|
return errorHandler(error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const searchRecipients = (establishmentId, query) => {
|
/**
|
||||||
const url = `${BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL}/?establishment_id=${establishmentId}&q=${encodeURIComponent(query)}`;
|
* Recherche des destinataires pour la messagerie
|
||||||
return fetch(url, {
|
*/
|
||||||
method: 'GET',
|
export const searchMessagerieRecipients = async (
|
||||||
headers: {
|
establishmentId,
|
||||||
'Content-Type': 'application/json',
|
query,
|
||||||
},
|
csrfToken
|
||||||
})
|
) => {
|
||||||
.then(requestResponseHandler)
|
try {
|
||||||
.catch(errorHandler);
|
const baseUrl = BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL.endsWith('/')
|
||||||
|
? BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL
|
||||||
|
: BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL + '/';
|
||||||
|
|
||||||
|
const url = `${baseUrl}?establishment_id=${establishmentId}&q=${encodeURIComponent(query)}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: buildHeaders(csrfToken),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
return await requestResponseHandler(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Erreur lors de la recherche des destinataires:', error);
|
||||||
|
return errorHandler(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marque des messages comme lus
|
||||||
|
*/
|
||||||
|
export const markAsRead = async (conversationId, userId, csrfToken) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(BE_GESTIONMESSAGERIE_MARK_AS_READ_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: buildHeaders(csrfToken),
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
conversation_id: conversationId,
|
||||||
|
user_id: userId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return await requestResponseHandler(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Erreur lors du marquage des messages comme lus:', error);
|
||||||
|
return errorHandler(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload un fichier pour la messagerie
|
||||||
|
*/
|
||||||
|
export const uploadFile = async (
|
||||||
|
file,
|
||||||
|
conversationId,
|
||||||
|
senderId,
|
||||||
|
csrfToken,
|
||||||
|
onProgress = null
|
||||||
|
) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('conversation_id', conversationId);
|
||||||
|
formData.append('sender_id', senderId);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
if (onProgress) {
|
||||||
|
xhr.upload.addEventListener('progress', (event) => {
|
||||||
|
if (event.lengthComputable) {
|
||||||
|
const percentComplete = (event.loaded / event.total) * 100;
|
||||||
|
onProgress(percentComplete);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.addEventListener('load', () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
try {
|
||||||
|
const response = JSON.parse(xhr.responseText);
|
||||||
|
resolve(response);
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error('Réponse invalide du serveur'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const errorResponse = JSON.parse(xhr.responseText);
|
||||||
|
reject(new Error(errorResponse.message || "Erreur lors de l'upload"));
|
||||||
|
} catch {
|
||||||
|
reject(new Error(`Erreur HTTP: ${xhr.status}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('error', () => {
|
||||||
|
reject(new Error("Erreur réseau lors de l'upload"));
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('timeout', () => {
|
||||||
|
reject(new Error("Timeout lors de l'upload"));
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.open('POST', BE_GESTIONMESSAGERIE_UPLOAD_FILE_URL);
|
||||||
|
xhr.withCredentials = true;
|
||||||
|
xhr.timeout = 30000;
|
||||||
|
|
||||||
|
// Ajouter le header CSRF pour XMLHttpRequest
|
||||||
|
if (csrfToken) {
|
||||||
|
xhr.setRequestHeader('X-CSRFToken', csrfToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.send(formData);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime une conversation
|
||||||
|
*/
|
||||||
|
export const deleteConversation = async (conversationId, csrfToken) => {
|
||||||
|
try {
|
||||||
|
const url = `${BE_GESTIONMESSAGERIE_DELETE_CONVERSATION_URL}/${conversationId}/`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: buildHeaders(csrfToken),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
return await requestResponseHandler(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Erreur lors de la suppression de la conversation:', error);
|
||||||
|
return errorHandler(error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { sendMessage, searchRecipients } from '@/app/actions/messagerieAction';
|
import { sendEmail, searchRecipients } from '@/app/actions/emailAction';
|
||||||
import { fetchSmtpSettings } from '@/app/actions/settingsAction';
|
import { fetchSmtpSettings } from '@/app/actions/settingsAction';
|
||||||
import { useNotification } from '@/context/NotificationContext';
|
import { useNotification } from '@/context/NotificationContext';
|
||||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
@ -56,7 +56,7 @@ export default function EmailSender({ csrfToken }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendMessage(data);
|
await sendEmail(data);
|
||||||
showNotification('Email envoyé avec succès.', 'success', 'Succès');
|
showNotification('Email envoyé avec succès.', 'success', 'Succès');
|
||||||
// Réinitialiser les champs après succès
|
// Réinitialiser les champs après succès
|
||||||
setRecipients([]);
|
setRecipients([]);
|
||||||
|
|||||||
@ -1,88 +1,17 @@
|
|||||||
// filepath: d:\Dev\n3wt-innov\n3wt-school\Front-End\src\components\Admin\InstantMessaging.js
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import Chat from '@/components/Chat';
|
import InstantChat from '@/components/Chat/InstantChat';
|
||||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
import {
|
import logger from '@/utils/logger';
|
||||||
fetchConversations,
|
|
||||||
sendMessage,
|
|
||||||
searchRecipients,
|
|
||||||
} from '@/app/actions/messagerieAction';
|
|
||||||
import RecipientInput from '@/components/RecipientInput';
|
|
||||||
import Modal from '@/components/Modal';
|
|
||||||
|
|
||||||
export default function InstantMessaging({ csrfToken }) {
|
export default function InstantMessaging({ csrfToken }) {
|
||||||
const { user, selectedEstablishmentId } = useEstablishment();
|
const { user, selectedEstablishmentId } = useEstablishment();
|
||||||
const [discussions, setDiscussions] = useState([]);
|
|
||||||
const [recipients, setRecipients] = useState([]); // Liste des correspondants sélectionnés
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
const [firstMessage, setFirstMessage] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user) {
|
|
||||||
fetchConversations(user.id).then(setDiscussions);
|
|
||||||
}
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
// Fonction pour ajouter une nouvelle discussion avec plusieurs correspondants
|
|
||||||
const handleCreateDiscussion = async () => {
|
|
||||||
if (!user || recipients.length === 0 || !firstMessage.trim()) return;
|
|
||||||
for (const recipient of recipients) {
|
|
||||||
await sendMessage({
|
|
||||||
emetteur: user.id,
|
|
||||||
destinataire: recipient.id,
|
|
||||||
objet: '',
|
|
||||||
corpus: firstMessage,
|
|
||||||
conversation_id: undefined, // L'API générera un nouvel ID
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setRecipients([]);
|
|
||||||
setFirstMessage('');
|
|
||||||
setShowModal(false);
|
|
||||||
fetchConversations(user.id).then(setDiscussions);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!user) return <div>Chargement...</div>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full flex flex-col">
|
||||||
<Chat
|
<InstantChat
|
||||||
userProfileId={user.id}
|
userProfileId={user.user_id}
|
||||||
establishmentId={selectedEstablishmentId}
|
establishmentId={selectedEstablishmentId}
|
||||||
discussions={discussions}
|
|
||||||
setDiscussions={setDiscussions}
|
|
||||||
onShowCreateDiscussion={() => setShowModal(true)}
|
|
||||||
/>
|
/>
|
||||||
<Modal
|
|
||||||
isOpen={showModal}
|
|
||||||
setIsOpen={setShowModal}
|
|
||||||
title="Nouvelle discussion"
|
|
||||||
modalClassName="w-full max-w-xs sm:max-w-md"
|
|
||||||
>
|
|
||||||
<div className="p-2 sm:p-4">
|
|
||||||
<h3 className="text-lg font-bold mb-2">Nouvelle discussion</h3>
|
|
||||||
<RecipientInput
|
|
||||||
label="Rechercher un correspondant"
|
|
||||||
recipients={recipients}
|
|
||||||
setRecipients={setRecipients}
|
|
||||||
searchRecipients={searchRecipients}
|
|
||||||
establishmentId={selectedEstablishmentId}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={firstMessage}
|
|
||||||
onChange={(e) => setFirstMessage(e.target.value)}
|
|
||||||
placeholder="Premier message"
|
|
||||||
className="w-full p-2 mb-2 border rounded"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleCreateDiscussion}
|
|
||||||
className="w-full p-2 bg-green-500 text-white rounded-lg"
|
|
||||||
>
|
|
||||||
Démarrer la discussion
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,175 +0,0 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
|
||||||
import { SendHorizontal, Plus } from 'lucide-react';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import {
|
|
||||||
fetchConversations,
|
|
||||||
fetchMessages,
|
|
||||||
sendMessage,
|
|
||||||
markAsRead,
|
|
||||||
} from '@/app/actions/messagerieAction';
|
|
||||||
|
|
||||||
export default function Chat({
|
|
||||||
userProfileId,
|
|
||||||
establishmentId,
|
|
||||||
discussions: discussionsProp,
|
|
||||||
setDiscussions: setDiscussionsProp,
|
|
||||||
onCreateDiscussion,
|
|
||||||
onShowCreateDiscussion,
|
|
||||||
}) {
|
|
||||||
const [discussions, setDiscussions] = useState(discussionsProp || []);
|
|
||||||
const [selectedDiscussion, setSelectedDiscussion] = useState(null);
|
|
||||||
const [messages, setMessages] = useState([]);
|
|
||||||
const [newMessage, setNewMessage] = useState('');
|
|
||||||
const messagesEndRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (userProfileId) {
|
|
||||||
fetchConversations(userProfileId).then(setDiscussions);
|
|
||||||
}
|
|
||||||
}, [userProfileId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedDiscussion) {
|
|
||||||
fetchMessages(selectedDiscussion.conversation_id).then(setMessages);
|
|
||||||
// Marquer comme lu
|
|
||||||
markAsRead(selectedDiscussion.conversation_id, userProfileId);
|
|
||||||
}
|
|
||||||
}, [selectedDiscussion, userProfileId]);
|
|
||||||
|
|
||||||
const handleSendMessage = async () => {
|
|
||||||
if (newMessage.trim() && selectedDiscussion) {
|
|
||||||
await sendMessage({
|
|
||||||
conversation_id: selectedDiscussion.conversation_id,
|
|
||||||
emetteur: userProfileId,
|
|
||||||
destinataire: selectedDiscussion.interlocuteur.id,
|
|
||||||
corpus: newMessage,
|
|
||||||
objet: '',
|
|
||||||
});
|
|
||||||
setNewMessage('');
|
|
||||||
fetchMessages(selectedDiscussion.conversation_id).then(setMessages);
|
|
||||||
fetchConversations(userProfileId).then(setDiscussions);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyPress = (event) => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
handleSendMessage();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full">
|
|
||||||
{/* Bandeau droit : Liste des discussions */}
|
|
||||||
<div className="w-1/4 min-w-[280px] bg-gray-100 border-r border-gray-300 p-4 flex flex-col">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-lg font-bold">Discussions</h2>
|
|
||||||
<button
|
|
||||||
className="p-2 rounded-full bg-blue-500 hover:bg-blue-600 text-white shadow"
|
|
||||||
title="Nouvelle discussion"
|
|
||||||
onClick={onShowCreateDiscussion}
|
|
||||||
>
|
|
||||||
<Plus size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{discussions && discussions.length > 0 ? (
|
|
||||||
discussions.map((discussion) => (
|
|
||||||
<div
|
|
||||||
key={discussion.id}
|
|
||||||
className={`flex items-center p-2 mb-2 cursor-pointer rounded transition-colors ${
|
|
||||||
selectedDiscussion?.id === discussion.id
|
|
||||||
? 'bg-blue-100'
|
|
||||||
: 'hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
onClick={() => setSelectedDiscussion(discussion)}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={discussion.profilePic}
|
|
||||||
alt={`${discussion.name}'s profile`}
|
|
||||||
className="w-10 h-10 rounded-full mr-3"
|
|
||||||
width={40}
|
|
||||||
height={40}
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="font-medium">{discussion.name}</p>
|
|
||||||
<p className="text-sm text-gray-500 truncate">
|
|
||||||
{discussion.lastMessage}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-gray-400">
|
|
||||||
{discussion.lastMessageDate &&
|
|
||||||
new Date(discussion.lastMessageDate).toLocaleTimeString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="text-gray-500">Aucune discussion disponible.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Zone de chat */}
|
|
||||||
<div className="flex-1 flex flex-col bg-white">
|
|
||||||
{/* En-tête du chat */}
|
|
||||||
{selectedDiscussion && (
|
|
||||||
<div className="flex items-center p-4 border-b border-gray-300">
|
|
||||||
<Image
|
|
||||||
src={selectedDiscussion.profilePic}
|
|
||||||
alt={`${selectedDiscussion.name}'s profile`}
|
|
||||||
className="w-10 h-10 rounded-full mr-3"
|
|
||||||
width={40}
|
|
||||||
height={40}
|
|
||||||
/>
|
|
||||||
<h2 className="text-lg font-bold">{selectedDiscussion.name}</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Messages */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
|
||||||
{selectedDiscussion &&
|
|
||||||
messages.map((message) => (
|
|
||||||
<div
|
|
||||||
key={message.id}
|
|
||||||
className={`flex mb-4 ${
|
|
||||||
message.isResponse ? 'justify-start' : 'justify-end'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`p-3 rounded-lg max-w-xs ${
|
|
||||||
message.isResponse
|
|
||||||
? 'bg-gray-200 text-gray-800'
|
|
||||||
: 'bg-blue-500 text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<p>{message.corpus}</p>
|
|
||||||
<span className="text-xs text-gray-500 block mt-1">
|
|
||||||
{message.date &&
|
|
||||||
new Date(message.date).toLocaleTimeString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
|
||||||
{/* Champ de saisie */}
|
|
||||||
{selectedDiscussion && (
|
|
||||||
<div className="p-4 border-t border-gray-300 flex items-center">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newMessage}
|
|
||||||
onChange={(e) => setNewMessage(e.target.value)}
|
|
||||||
className="flex-1 p-2 border border-gray-300 rounded-lg mr-2"
|
|
||||||
placeholder="Écrire un message..."
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSendMessage}
|
|
||||||
className="p-2 bg-blue-500 text-white rounded-lg"
|
|
||||||
>
|
|
||||||
<SendHorizontal />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
79
Front-End/src/components/Chat/ConnectionStatus.js
Normal file
79
Front-End/src/components/Chat/ConnectionStatus.js
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Wifi, WifiOff, RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
|
const ConnectionStatus = ({ status, onReconnect }) => {
|
||||||
|
const getStatusInfo = () => {
|
||||||
|
switch (status) {
|
||||||
|
case 'connected':
|
||||||
|
return {
|
||||||
|
icon: <Wifi className="w-4 h-4" />,
|
||||||
|
text: 'Connecté',
|
||||||
|
className: 'text-green-600 bg-green-50 border-green-200',
|
||||||
|
};
|
||||||
|
case 'disconnected':
|
||||||
|
return {
|
||||||
|
icon: <WifiOff className="w-4 h-4" />,
|
||||||
|
text: 'Déconnecté',
|
||||||
|
className: 'text-red-600 bg-red-50 border-red-200',
|
||||||
|
};
|
||||||
|
case 'reconnecting':
|
||||||
|
return {
|
||||||
|
icon: <RotateCcw className="w-4 h-4 animate-spin" />,
|
||||||
|
text: 'Reconnexion...',
|
||||||
|
className: 'text-yellow-600 bg-yellow-50 border-yellow-200',
|
||||||
|
};
|
||||||
|
case 'error':
|
||||||
|
return {
|
||||||
|
icon: <WifiOff className="w-4 h-4" />,
|
||||||
|
text: 'Erreur de connexion',
|
||||||
|
className: 'text-red-600 bg-red-50 border-red-200',
|
||||||
|
};
|
||||||
|
case 'failed':
|
||||||
|
return {
|
||||||
|
icon: <WifiOff className="w-4 h-4" />,
|
||||||
|
text: 'Connexion échouée',
|
||||||
|
className: 'text-red-600 bg-red-50 border-red-200',
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
icon: <WifiOff className="w-4 h-4" />,
|
||||||
|
text: 'Inconnu',
|
||||||
|
className: 'text-gray-600 bg-gray-50 border-gray-200',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status === 'connected') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border rounded-lg text-green-600 bg-green-50 border-green-200">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Wifi className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium">Connecté</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { icon, text, className } = getStatusInfo();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between px-3 py-2 border rounded-lg ${className}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{icon}
|
||||||
|
<span className="text-sm font-medium">{text}</span>
|
||||||
|
</div>
|
||||||
|
{(status === 'failed' || status === 'error') && onReconnect && (
|
||||||
|
<button
|
||||||
|
onClick={onReconnect}
|
||||||
|
className="text-sm underline hover:no-underline"
|
||||||
|
>
|
||||||
|
Réessayer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConnectionStatus;
|
||||||
196
Front-End/src/components/Chat/ConversationItem.js
Normal file
196
Front-End/src/components/Chat/ConversationItem.js
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { User, Trash2 } from 'lucide-react';
|
||||||
|
import { getGravatarUrl } from '@/utils/gravatar';
|
||||||
|
|
||||||
|
const ConversationItem = ({
|
||||||
|
conversation,
|
||||||
|
isSelected,
|
||||||
|
onClick,
|
||||||
|
onDelete, // Nouvelle prop pour la suppression
|
||||||
|
unreadCount = 0,
|
||||||
|
lastMessage,
|
||||||
|
isTyping = false,
|
||||||
|
userPresences = {}, // Nouveau prop pour les statuts de présence
|
||||||
|
}) => {
|
||||||
|
const formatTime = (dateString) => {
|
||||||
|
if (!dateString) return '';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffInHours = (now - date) / (1000 * 60 * 60);
|
||||||
|
|
||||||
|
if (diffInHours < 24) {
|
||||||
|
return date.toLocaleTimeString('fr-FR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInterlocutorName = () => {
|
||||||
|
if (conversation.interlocuteur) {
|
||||||
|
// Si nous avons le nom et prénom, les utiliser
|
||||||
|
if (
|
||||||
|
conversation.interlocuteur.first_name &&
|
||||||
|
conversation.interlocuteur.last_name
|
||||||
|
) {
|
||||||
|
return `${conversation.interlocuteur.first_name} ${conversation.interlocuteur.last_name}`;
|
||||||
|
}
|
||||||
|
// Sinon, utiliser l'email comme fallback
|
||||||
|
if (conversation.interlocuteur.email) {
|
||||||
|
return conversation.interlocuteur.email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return conversation.name || 'Utilisateur inconnu';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLastMessageText = () => {
|
||||||
|
if (isTyping) {
|
||||||
|
return (
|
||||||
|
<span className="text-emerald-500 italic">Tape un message...</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastMessage) {
|
||||||
|
return lastMessage.content || lastMessage.corpus || 'Message...';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conversation.last_message) {
|
||||||
|
return (
|
||||||
|
conversation.last_message.content ||
|
||||||
|
conversation.last_message.corpus ||
|
||||||
|
'Message...'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Aucun message';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLastMessageTime = () => {
|
||||||
|
if (lastMessage) {
|
||||||
|
return formatTime(lastMessage.created_at || lastMessage.date_envoi);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conversation.last_message) {
|
||||||
|
return formatTime(
|
||||||
|
conversation.last_message.created_at ||
|
||||||
|
conversation.last_message.date_envoi
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserPresenceStatus = () => {
|
||||||
|
if (conversation.interlocuteur?.id) {
|
||||||
|
const presence = userPresences[conversation.interlocuteur.id];
|
||||||
|
return presence?.status || 'offline';
|
||||||
|
}
|
||||||
|
return 'offline';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPresenceColor = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'online':
|
||||||
|
return 'bg-emerald-400';
|
||||||
|
case 'away':
|
||||||
|
return 'bg-yellow-400';
|
||||||
|
case 'busy':
|
||||||
|
return 'bg-red-400';
|
||||||
|
case 'offline':
|
||||||
|
default:
|
||||||
|
return 'bg-gray-400';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPresenceLabel = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'online':
|
||||||
|
return 'En ligne';
|
||||||
|
case 'away':
|
||||||
|
return 'Absent';
|
||||||
|
case 'busy':
|
||||||
|
return 'Occupé';
|
||||||
|
case 'offline':
|
||||||
|
default:
|
||||||
|
return 'Hors ligne';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const presenceStatus = getUserPresenceStatus();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group flex items-center p-3 cursor-pointer rounded-lg transition-all duration-200 hover:bg-gray-50 ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-emerald-50 border-l-4 border-emerald-500'
|
||||||
|
: 'hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
src={getGravatarUrl(
|
||||||
|
conversation.interlocuteur?.email || 'default',
|
||||||
|
48
|
||||||
|
)}
|
||||||
|
alt={`Avatar de ${getInterlocutorName()}`}
|
||||||
|
className="w-12 h-12 rounded-full object-cover shadow-md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Indicateur de statut en ligne */}
|
||||||
|
<div
|
||||||
|
className={`absolute -bottom-0.5 -right-0.5 w-4 h-4 ${getPresenceColor(presenceStatus)} border-2 border-white rounded-full`}
|
||||||
|
title={getPresenceLabel(presenceStatus)}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contenu de la conversation */}
|
||||||
|
<div className="flex-1 ml-3 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3
|
||||||
|
className={`font-semibold truncate ${
|
||||||
|
isSelected ? 'text-emerald-700' : 'text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getInterlocutorName()}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="bg-red-500 text-white text-xs rounded-full w-4 h-4 text-center"></span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{getLastMessageTime()}
|
||||||
|
</span>
|
||||||
|
{/* Bouton de suppression */}
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation(); // Empêcher la sélection de la conversation
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
className="opacity-0 group-hover:opacity-100 hover:bg-red-100 p-1 rounded transition-all duration-200"
|
||||||
|
title="Supprimer la conversation"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 text-red-500 hover:text-red-700" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
className={`text-sm truncate mt-1 ${isTyping ? '' : 'text-gray-600'}`}
|
||||||
|
>
|
||||||
|
{getLastMessageText()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConversationItem;
|
||||||
115
Front-End/src/components/Chat/FileAttachment.js
Normal file
115
Front-End/src/components/Chat/FileAttachment.js
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
Image,
|
||||||
|
Film,
|
||||||
|
Music,
|
||||||
|
Archive,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const FileAttachment = ({
|
||||||
|
fileName,
|
||||||
|
fileSize,
|
||||||
|
fileType,
|
||||||
|
fileUrl,
|
||||||
|
onDownload = null,
|
||||||
|
}) => {
|
||||||
|
// Obtenir l'icône en fonction du type de fichier
|
||||||
|
const getFileIcon = (type) => {
|
||||||
|
if (type.startsWith('image/')) {
|
||||||
|
return <Image className="w-6 h-6 text-blue-500" />;
|
||||||
|
}
|
||||||
|
if (type.startsWith('video/')) {
|
||||||
|
return <Film className="w-6 h-6 text-purple-500" />;
|
||||||
|
}
|
||||||
|
if (type.startsWith('audio/')) {
|
||||||
|
return <Music className="w-6 h-6 text-green-500" />;
|
||||||
|
}
|
||||||
|
if (type.includes('pdf')) {
|
||||||
|
return <FileText className="w-6 h-6 text-red-500" />;
|
||||||
|
}
|
||||||
|
if (type.includes('zip') || type.includes('rar')) {
|
||||||
|
return <Archive className="w-6 h-6 text-yellow-500" />;
|
||||||
|
}
|
||||||
|
return <FileText className="w-6 h-6 text-gray-500" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Formater la taille du fichier
|
||||||
|
const formatFileSize = (bytes) => {
|
||||||
|
if (!bytes) return '';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gérer le téléchargement
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (onDownload) {
|
||||||
|
onDownload();
|
||||||
|
} else if (fileUrl) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = fileUrl;
|
||||||
|
link.download = fileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vérifier si c'est une image pour afficher un aperçu
|
||||||
|
const isImage = fileType && fileType.startsWith('image/');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-sm">
|
||||||
|
{isImage && fileUrl ? (
|
||||||
|
// Affichage pour les images
|
||||||
|
<div className="relative group">
|
||||||
|
<img
|
||||||
|
src={fileUrl}
|
||||||
|
alt={fileName}
|
||||||
|
className="max-w-full h-auto rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
|
onClick={() => window.open(fileUrl, '_blank')}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all rounded-lg flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="opacity-0 group-hover:opacity-100 bg-white bg-opacity-90 hover:bg-opacity-100 rounded-full p-2 transition-all"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 text-gray-700" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{fileName && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500 truncate">{fileName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Affichage pour les autres fichiers
|
||||||
|
<div className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg border hover:bg-gray-100 transition-colors">
|
||||||
|
<div className="flex-shrink-0">{getFileIcon(fileType)}</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 truncate">
|
||||||
|
{fileName || 'Fichier sans nom'}
|
||||||
|
</p>
|
||||||
|
{fileSize && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{formatFileSize(fileSize)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="flex-shrink-0 p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
title="Télécharger"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileAttachment;
|
||||||
179
Front-End/src/components/Chat/FileUpload.js
Normal file
179
Front-End/src/components/Chat/FileUpload.js
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { X, Upload, FileText, Image, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
const FileUpload = ({
|
||||||
|
file,
|
||||||
|
onUpload,
|
||||||
|
onCancel,
|
||||||
|
conversationId,
|
||||||
|
senderId,
|
||||||
|
maxSize = 10 * 1024 * 1024, // 10MB par défaut
|
||||||
|
}) => {
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// Vérifier le type de fichier et obtenir l'icône appropriée
|
||||||
|
const getFileIcon = (fileType) => {
|
||||||
|
if (fileType.startsWith('image/')) {
|
||||||
|
return <Image className="w-8 h-8 text-blue-500" alt="Icône image" />;
|
||||||
|
}
|
||||||
|
return <FileText className="w-8 h-8 text-gray-500" alt="Icône fichier" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Formater la taille du fichier
|
||||||
|
const formatFileSize = (bytes) => {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vérifier si le fichier est valide
|
||||||
|
const isValidFile = () => {
|
||||||
|
const allowedTypes = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/jpg',
|
||||||
|
'image/png',
|
||||||
|
'image/gif',
|
||||||
|
'image/webp',
|
||||||
|
'application/pdf',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'text/plain',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gérer l'upload
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (!isValidFile()) {
|
||||||
|
setError('Type de fichier non autorisé ou fichier trop volumineux');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await onUpload(
|
||||||
|
file,
|
||||||
|
conversationId,
|
||||||
|
senderId,
|
||||||
|
setUploadProgress
|
||||||
|
);
|
||||||
|
// L'upload s'est bien passé, le parent gère la suite
|
||||||
|
} catch (error) {
|
||||||
|
setError(error.message || "Erreur lors de l'upload");
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!file) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-lg">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{getFileIcon(file.type)}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900 truncate max-w-xs">
|
||||||
|
{file.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">{formatFileSize(file.size)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isUploading}
|
||||||
|
className="text-gray-400 hover:text-gray-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prévisualisation pour les images */}
|
||||||
|
{file.type.startsWith('image/') && (
|
||||||
|
<div className="mb-3">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={URL.createObjectURL(file)}
|
||||||
|
alt="Aperçu du fichier sélectionné"
|
||||||
|
className="max-w-full h-32 object-cover rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Validation du fichier */}
|
||||||
|
{!isValidFile() && (
|
||||||
|
<div className="mb-3 p-2 bg-red-50 border border-red-200 rounded-lg flex items-center space-x-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-500" />
|
||||||
|
<span className="text-sm text-red-700">
|
||||||
|
{file.size > maxSize
|
||||||
|
? `Fichier trop volumineux (max ${formatFileSize(maxSize)})`
|
||||||
|
: 'Type de fichier non autorisé'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Erreur d'upload */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-3 p-2 bg-red-50 border border-red-200 rounded-lg flex items-center space-x-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-500" />
|
||||||
|
<span className="text-sm text-red-700">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Barre de progression */}
|
||||||
|
{isUploading && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<span className="text-sm text-gray-600">Upload en cours...</span>
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{Math.round(uploadProgress)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${uploadProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Boutons d'action */}
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isUploading}
|
||||||
|
className="px-3 py-2 text-sm text-gray-600 hover:text-gray-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={isUploading || !isValidFile()}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
<span>{isUploading ? 'Upload...' : 'Envoyer'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileUpload;
|
||||||
1162
Front-End/src/components/Chat/InstantChat.js
Normal file
1162
Front-End/src/components/Chat/InstantChat.js
Normal file
File diff suppressed because it is too large
Load Diff
133
Front-End/src/components/Chat/MessageBubble.js
Normal file
133
Front-End/src/components/Chat/MessageBubble.js
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { format, isToday, isYesterday } from 'date-fns';
|
||||||
|
import { fr } from 'date-fns/locale';
|
||||||
|
import { Check, CheckCheck } from 'lucide-react';
|
||||||
|
import FileAttachment from './FileAttachment';
|
||||||
|
import { getGravatarUrl } from '@/utils/gravatar';
|
||||||
|
|
||||||
|
const MessageBubble = ({
|
||||||
|
message,
|
||||||
|
isOwnMessage,
|
||||||
|
showAvatar = true,
|
||||||
|
isRead = false,
|
||||||
|
senderName = '',
|
||||||
|
senderEmail = '', // Nouveau prop pour l'email du sender
|
||||||
|
isFirstInGroup = true,
|
||||||
|
isLastInGroup = true,
|
||||||
|
showTime = true,
|
||||||
|
}) => {
|
||||||
|
const formatMessageTime = (dateString) => {
|
||||||
|
if (!dateString) return '';
|
||||||
|
|
||||||
|
const date = new Date(dateString);
|
||||||
|
|
||||||
|
if (isToday(date)) {
|
||||||
|
return format(date, 'HH:mm', { locale: fr });
|
||||||
|
} else if (isYesterday(date)) {
|
||||||
|
return `Hier ${format(date, 'HH:mm', { locale: fr })}`;
|
||||||
|
} else {
|
||||||
|
return format(date, 'dd/MM HH:mm', { locale: fr });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMessageContent = () => {
|
||||||
|
return message.content || message.corpus || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMessageTime = () => {
|
||||||
|
return message.created_at || message.date_envoi;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasAttachment = () => {
|
||||||
|
return (
|
||||||
|
message.attachment &&
|
||||||
|
(message.attachment.fileName || message.attachment.fileUrl)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFileOnlyMessage = () => {
|
||||||
|
return hasAttachment() && !getMessageContent().trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group hover:bg-gray-50 px-4 py-1 ${isFirstInGroup ? 'mt-4' : 'mt-0.5'} message-appear`}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
/* Peut ajouter des actions au hover */
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex">
|
||||||
|
{/* Avatar - affiché seulement pour le premier message du groupe */}
|
||||||
|
{showAvatar && isFirstInGroup && (
|
||||||
|
<img
|
||||||
|
src={getGravatarUrl(senderEmail || senderName, 40)}
|
||||||
|
alt={`Avatar de ${senderName || 'Utilisateur'}`}
|
||||||
|
className="w-10 h-10 rounded-full object-cover shadow-sm mr-3 flex-shrink-0 mt-0.5"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Espace pour aligner avec l'avatar quand il n'est pas affiché */}
|
||||||
|
{(!showAvatar || !isFirstInGroup) && (
|
||||||
|
<div className="w-10 mr-3 flex-shrink-0"></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Contenu du message */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* En-tête du message (nom + heure) - seulement pour le premier message du groupe */}
|
||||||
|
{isFirstInGroup && (
|
||||||
|
<div className="flex items-baseline space-x-2 mb-1">
|
||||||
|
<span className="font-semibold text-gray-900 text-sm">
|
||||||
|
{senderName || (isOwnMessage ? 'Moi' : 'Utilisateur')}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{formatMessageTime(getMessageTime())}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fichier attaché */}
|
||||||
|
{hasAttachment() && (
|
||||||
|
<div className={`${getMessageContent().trim() ? 'mb-2' : ''}`}>
|
||||||
|
<FileAttachment
|
||||||
|
fileName={message.attachment.fileName}
|
||||||
|
fileSize={message.attachment.fileSize}
|
||||||
|
fileType={message.attachment.fileType}
|
||||||
|
fileUrl={message.attachment.fileUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Contenu du message */}
|
||||||
|
{getMessageContent().trim() && (
|
||||||
|
<div className="text-sm leading-relaxed whitespace-pre-wrap break-words text-gray-800">
|
||||||
|
{getMessageContent()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Indicateurs de lecture et heure pour les messages non-groupés */}
|
||||||
|
<div className="flex items-center space-x-2 mt-1">
|
||||||
|
{/* Heure pour les messages qui ne sont pas le premier du groupe */}
|
||||||
|
{!isFirstInGroup && (
|
||||||
|
<span className="text-xs text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{formatMessageTime(getMessageTime())}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Indicateurs de lecture (uniquement pour nos messages) */}
|
||||||
|
{isOwnMessage && (
|
||||||
|
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{isRead ? (
|
||||||
|
<CheckCheck className="w-3 h-3 text-green-500" title="Lu" />
|
||||||
|
) : (
|
||||||
|
<Check className="w-3 h-3 text-gray-400" title="Envoyé" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MessageBubble;
|
||||||
233
Front-End/src/components/Chat/MessageInput.js
Normal file
233
Front-End/src/components/Chat/MessageInput.js
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Send, Paperclip } from 'lucide-react';
|
||||||
|
import FileUpload from './FileUpload';
|
||||||
|
import { uploadFile } from '@/app/actions/messagerieAction';
|
||||||
|
import logger from '@/utils/logger';
|
||||||
|
|
||||||
|
const MessageInput = ({
|
||||||
|
onSendMessage,
|
||||||
|
onTypingStart,
|
||||||
|
onTypingStop,
|
||||||
|
disabled = false,
|
||||||
|
placeholder = 'Tapez votre message...',
|
||||||
|
conversationId = null,
|
||||||
|
senderId = null,
|
||||||
|
}) => {
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [isTyping, setIsTyping] = useState(false);
|
||||||
|
const [selectedFile, setSelectedFile] = useState(null);
|
||||||
|
const [showFileUpload, setShowFileUpload] = useState(false);
|
||||||
|
const textareaRef = useRef(null);
|
||||||
|
const typingTimeoutRef = useRef(null);
|
||||||
|
|
||||||
|
// Ajuster la hauteur du textarea automatiquement
|
||||||
|
useEffect(() => {
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.style.height = 'auto';
|
||||||
|
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`;
|
||||||
|
}
|
||||||
|
}, [message]);
|
||||||
|
|
||||||
|
const handleInputChange = (e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setMessage(value);
|
||||||
|
|
||||||
|
// Gestion du statut de frappe
|
||||||
|
if (value.trim() && !isTyping) {
|
||||||
|
setIsTyping(true);
|
||||||
|
onTypingStart?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Réinitialiser le timeout de frappe
|
||||||
|
if (typingTimeoutRef.current) {
|
||||||
|
clearTimeout(typingTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
typingTimeoutRef.current = setTimeout(() => {
|
||||||
|
if (isTyping) {
|
||||||
|
setIsTyping(false);
|
||||||
|
onTypingStop?.();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
const trimmedMessage = message.trim();
|
||||||
|
logger.debug('📝 MessageInput: handleSend appelé:', {
|
||||||
|
message,
|
||||||
|
trimmedMessage,
|
||||||
|
disabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!trimmedMessage || disabled) {
|
||||||
|
logger.debug('❌ MessageInput: Message vide ou désactivé');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
'📤 MessageInput: Appel de onSendMessage avec:',
|
||||||
|
trimmedMessage
|
||||||
|
);
|
||||||
|
onSendMessage(trimmedMessage);
|
||||||
|
setMessage('');
|
||||||
|
|
||||||
|
// Arrêter le statut de frappe
|
||||||
|
if (isTyping) {
|
||||||
|
setIsTyping(false);
|
||||||
|
onTypingStop?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effacer le timeout
|
||||||
|
if (typingTimeoutRef.current) {
|
||||||
|
clearTimeout(typingTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
setSelectedFile(file);
|
||||||
|
setShowFileUpload(true);
|
||||||
|
}
|
||||||
|
// Réinitialiser l'input file
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = async (
|
||||||
|
file,
|
||||||
|
conversationId,
|
||||||
|
senderId,
|
||||||
|
onProgress
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const result = await uploadFile(
|
||||||
|
file,
|
||||||
|
conversationId,
|
||||||
|
senderId,
|
||||||
|
onProgress
|
||||||
|
);
|
||||||
|
|
||||||
|
// Envoyer un message avec le fichier
|
||||||
|
onSendMessage('', {
|
||||||
|
type: 'file',
|
||||||
|
fileName: file.name,
|
||||||
|
fileSize: file.size,
|
||||||
|
fileType: file.type,
|
||||||
|
fileUrl: result.fileUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Réinitialiser l'état
|
||||||
|
setSelectedFile(null);
|
||||||
|
setShowFileUpload(false);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelFileUpload = () => {
|
||||||
|
setSelectedFile(null);
|
||||||
|
setShowFileUpload(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nettoyage du timeout lors du démontage du composant
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (typingTimeoutRef.current) {
|
||||||
|
clearTimeout(typingTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-gray-200 bg-white">
|
||||||
|
{/* Aperçu d'upload de fichier */}
|
||||||
|
{showFileUpload && selectedFile && (
|
||||||
|
<div className="p-4 border-b border-gray-200">
|
||||||
|
<FileUpload
|
||||||
|
file={selectedFile}
|
||||||
|
onUpload={handleFileUpload}
|
||||||
|
onCancel={handleCancelFileUpload}
|
||||||
|
conversationId={conversationId}
|
||||||
|
senderId={senderId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Zone de saisie */}
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-end space-x-3">
|
||||||
|
{/* Zone de saisie */}
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={message}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
|
style={{ minHeight: '44px', maxHeight: '120px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Boutons à droite (trombone au-dessus, envoi en dessous) */}
|
||||||
|
<div className="flex flex-col space-y-2 flex-shrink-0">
|
||||||
|
{/* Bouton d'ajout de fichier */}
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="file-upload"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
accept="image/*,application/pdf,.doc,.docx,.xls,.xlsx,.txt"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="file-upload"
|
||||||
|
className="flex items-center justify-center w-10 h-10 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<Paperclip className="w-5 h-5" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bouton d'envoi */}
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!message.trim() || disabled}
|
||||||
|
className={`flex items-center justify-center w-10 h-10 rounded-full transition-all ${
|
||||||
|
message.trim() && !disabled
|
||||||
|
? 'bg-blue-500 hover:bg-blue-600 text-white shadow-lg hover:shadow-xl'
|
||||||
|
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Send className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Indicateur de limite de caractères (optionnel) */}
|
||||||
|
{message.length > 900 && (
|
||||||
|
<div className="mt-2 text-right">
|
||||||
|
<span
|
||||||
|
className={`text-xs ${
|
||||||
|
message.length > 1000 ? 'text-red-500' : 'text-yellow-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message.length}/1000
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MessageInput;
|
||||||
30
Front-End/src/components/Chat/TypingIndicator.js
Normal file
30
Front-End/src/components/Chat/TypingIndicator.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const TypingIndicator = ({ typingUsers = [] }) => {
|
||||||
|
if (typingUsers.length === 0) return null;
|
||||||
|
|
||||||
|
const getTypingText = () => {
|
||||||
|
if (typingUsers.length === 1) {
|
||||||
|
return `${typingUsers[0]} tape un message...`;
|
||||||
|
} else if (typingUsers.length === 2) {
|
||||||
|
return `${typingUsers[0]} et ${typingUsers[1]} tapent un message...`;
|
||||||
|
} else {
|
||||||
|
return `${typingUsers[0]} et ${typingUsers.length - 1} autres tapent un message...`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center px-4 py-3 bg-gray-50 border-t border-gray-100 typing-indicator-enter">
|
||||||
|
<div className="flex space-x-1 mr-3">
|
||||||
|
<div className="w-2 h-2 bg-blue-400 rounded-full typing-dot"></div>
|
||||||
|
<div className="w-2 h-2 bg-blue-400 rounded-full typing-dot"></div>
|
||||||
|
<div className="w-2 h-2 bg-blue-400 rounded-full typing-dot"></div>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-600 italic animate-pulse">
|
||||||
|
{getTypingText()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TypingIndicator;
|
||||||
@ -3,6 +3,7 @@ import { LogOut } from 'lucide-react';
|
|||||||
import { disconnect } from '@/app/actions/authAction';
|
import { disconnect } from '@/app/actions/authAction';
|
||||||
import { getGravatarUrl } from '@/utils/gravatar';
|
import { getGravatarUrl } from '@/utils/gravatar';
|
||||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
|
import { useChatConnection } from '@/context/ChatConnectionContext';
|
||||||
import DropdownMenu from '@/components/DropdownMenu';
|
import DropdownMenu from '@/components/DropdownMenu';
|
||||||
import { usePopup } from '@/context/PopupContext';
|
import { usePopup } from '@/context/PopupContext';
|
||||||
import { getRightStr } from '@/utils/rights';
|
import { getRightStr } from '@/utils/rights';
|
||||||
@ -20,6 +21,7 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
|
|||||||
setSelectedEstablishmentEvaluationFrequency,
|
setSelectedEstablishmentEvaluationFrequency,
|
||||||
setSelectedEstablishmentTotalCapacity,
|
setSelectedEstablishmentTotalCapacity,
|
||||||
} = useEstablishment();
|
} = useEstablishment();
|
||||||
|
const { isConnected, connectionStatus } = useChatConnection();
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
|
|
||||||
@ -60,6 +62,36 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
|
|||||||
(est) => est.role_id === selectedRoleId
|
(est) => est.role_id === selectedRoleId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Fonction pour obtenir la couleur de la bulle de statut
|
||||||
|
const getStatusColor = () => {
|
||||||
|
switch (connectionStatus) {
|
||||||
|
case 'connected':
|
||||||
|
return 'bg-green-500';
|
||||||
|
case 'connecting':
|
||||||
|
return 'bg-yellow-500';
|
||||||
|
case 'error':
|
||||||
|
return 'bg-red-500';
|
||||||
|
case 'disconnected':
|
||||||
|
default:
|
||||||
|
return 'bg-gray-400';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction pour obtenir le titre de la bulle de statut
|
||||||
|
const getStatusTitle = () => {
|
||||||
|
switch (connectionStatus) {
|
||||||
|
case 'connected':
|
||||||
|
return 'Chat connecté';
|
||||||
|
case 'connecting':
|
||||||
|
return 'Connexion au chat...';
|
||||||
|
case 'error':
|
||||||
|
return 'Erreur de connexion au chat';
|
||||||
|
case 'disconnected':
|
||||||
|
default:
|
||||||
|
return 'Chat déconnecté';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Suppression du tronquage JS, on utilise uniquement CSS
|
// Suppression du tronquage JS, on utilise uniquement CSS
|
||||||
const isSingleRole = establishments && establishments.length === 1;
|
const isSingleRole = establishments && establishments.length === 1;
|
||||||
|
|
||||||
@ -68,13 +100,20 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
|
|||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
buttonContent={
|
buttonContent={
|
||||||
<div className="h-16 flex items-center gap-2 cursor-pointer px-4 bg-white">
|
<div className="h-16 flex items-center gap-2 cursor-pointer px-4 bg-white">
|
||||||
<Image
|
<div className="relative">
|
||||||
src={getGravatarUrl(user?.email)}
|
<Image
|
||||||
alt="Profile"
|
src={getGravatarUrl(user?.email)}
|
||||||
className="w-8 h-8 rounded-full mr-2"
|
alt="Profile"
|
||||||
width={32}
|
className="w-10 h-10 rounded-full object-cover shadow-md"
|
||||||
height={32}
|
width={32}
|
||||||
/>
|
height={32}
|
||||||
|
/>
|
||||||
|
{/* Bulle de statut de connexion au chat */}
|
||||||
|
<div
|
||||||
|
className={`absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-2 border-white ${getStatusColor()}`}
|
||||||
|
title={getStatusTitle()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div
|
<div
|
||||||
className="font-bold text-left truncate max-w-full"
|
className="font-bold text-left truncate max-w-full"
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { NextIntlClientProvider } from 'next-intl';
|
|||||||
import { EstablishmentProvider } from '@/context/EstablishmentContext';
|
import { EstablishmentProvider } from '@/context/EstablishmentContext';
|
||||||
import { NotificationProvider } from '@/context/NotificationContext';
|
import { NotificationProvider } from '@/context/NotificationContext';
|
||||||
import { ClassesProvider } from '@/context/ClassesContext';
|
import { ClassesProvider } from '@/context/ClassesContext';
|
||||||
|
import { ChatConnectionProvider } from '@/context/ChatConnectionContext';
|
||||||
import { DndProvider } from 'react-dnd';
|
import { DndProvider } from 'react-dnd';
|
||||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
@ -23,11 +24,13 @@ export default function Providers({ children, messages, locale, session }) {
|
|||||||
<CsrfProvider>
|
<CsrfProvider>
|
||||||
<EstablishmentProvider>
|
<EstablishmentProvider>
|
||||||
<ClassesProvider>
|
<ClassesProvider>
|
||||||
<PopupProvider>
|
<ChatConnectionProvider>
|
||||||
<NextIntlClientProvider messages={messages} locale={locale}>
|
<PopupProvider>
|
||||||
{children}
|
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||||
</NextIntlClientProvider>
|
{children}
|
||||||
</PopupProvider>
|
</NextIntlClientProvider>
|
||||||
|
</PopupProvider>
|
||||||
|
</ChatConnectionProvider>
|
||||||
</ClassesProvider>
|
</ClassesProvider>
|
||||||
</EstablishmentProvider>
|
</EstablishmentProvider>
|
||||||
</CsrfProvider>
|
</CsrfProvider>
|
||||||
|
|||||||
@ -31,7 +31,7 @@ const SidebarTabs = ({ tabs, onTabChange }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs Content */}
|
{/* Tabs Content */}
|
||||||
<div className="flex-1 overflow-y-auto rounded-b-lg shadow-inner relative">
|
<div className="flex-1 flex flex-col overflow-hidden rounded-b-lg shadow-inner">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{tabs.map(
|
{tabs.map(
|
||||||
(tab) =>
|
(tab) =>
|
||||||
@ -42,7 +42,7 @@ const SidebarTabs = ({ tabs, onTabChange }) => {
|
|||||||
animate={{ opacity: 1, x: 0 }} // Animation visible
|
animate={{ opacity: 1, x: 0 }} // Animation visible
|
||||||
exit={{ opacity: 0, x: -50 }} // Animation de sortie
|
exit={{ opacity: 0, x: -50 }} // Animation de sortie
|
||||||
transition={{ duration: 0.3 }} // Durée des animations
|
transition={{ duration: 0.3 }} // Durée des animations
|
||||||
className="absolute w-full h-full"
|
className="flex-1 flex flex-col h-full min-h-0"
|
||||||
>
|
>
|
||||||
{tab.content}
|
{tab.content}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
270
Front-End/src/context/ChatConnectionContext.js
Normal file
270
Front-End/src/context/ChatConnectionContext.js
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useCallback,
|
||||||
|
} from 'react';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
import logger from '@/utils/logger';
|
||||||
|
import { WS_CHAT_URL } from '@/utils/Url';
|
||||||
|
|
||||||
|
const ChatConnectionContext = createContext();
|
||||||
|
|
||||||
|
export const ChatConnectionProvider = ({ children }) => {
|
||||||
|
const { data: session, status } = useSession(); // Ajouter le hook useSession
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [connectionStatus, setConnectionStatus] = useState('disconnected'); // 'disconnected', 'connecting', 'connected', 'error'
|
||||||
|
const [userPresences, setUserPresences] = useState({}); // Nouvel état pour les présences
|
||||||
|
const websocketRef = useRef(null);
|
||||||
|
const reconnectTimeoutRef = useRef(null);
|
||||||
|
const [reconnectAttempts, setReconnectAttempts] = useState(0);
|
||||||
|
const [currentUserId, setCurrentUserId] = useState(null);
|
||||||
|
const maxReconnectAttempts = 5;
|
||||||
|
|
||||||
|
// Système de callbacks pour les messages
|
||||||
|
const messageCallbacksRef = useRef(new Set());
|
||||||
|
|
||||||
|
// Fonctions pour gérer les callbacks de messages
|
||||||
|
const addMessageCallback = useCallback((callback) => {
|
||||||
|
messageCallbacksRef.current.add(callback);
|
||||||
|
return () => {
|
||||||
|
messageCallbacksRef.current.delete(callback);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const notifyMessageCallbacks = useCallback((data) => {
|
||||||
|
messageCallbacksRef.current.forEach((callback) => {
|
||||||
|
try {
|
||||||
|
callback(data);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ChatConnection: Error in message callback', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Gestion des présences utilisateur
|
||||||
|
const handlePresenceUpdate = useCallback((data) => {
|
||||||
|
const { user_id, status } = data;
|
||||||
|
setUserPresences((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[user_id]: { status },
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Configuration WebSocket
|
||||||
|
const getWebSocketUrl = (userId) => {
|
||||||
|
if (!userId) {
|
||||||
|
logger.warn('ChatConnection: No user ID provided for WebSocket URL');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer le token d'authentification depuis NextAuth session
|
||||||
|
const token = session?.user?.token;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
logger.warn(
|
||||||
|
'ChatConnection: No access token found for WebSocket connection'
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construire l'URL WebSocket avec le token
|
||||||
|
const baseUrl = WS_CHAT_URL(userId);
|
||||||
|
const wsUrl = `${baseUrl}?token=${encodeURIComponent(token)}`;
|
||||||
|
|
||||||
|
return wsUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Connexion WebSocket
|
||||||
|
const connectToChat = (userId = null) => {
|
||||||
|
const userIdToUse = userId || currentUserId;
|
||||||
|
|
||||||
|
// Vérifier que la session est chargée
|
||||||
|
if (status === 'loading') {
|
||||||
|
setConnectionStatus('connecting');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'unauthenticated' || !session) {
|
||||||
|
logger.warn('ChatConnection: User not authenticated');
|
||||||
|
setConnectionStatus('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userIdToUse) {
|
||||||
|
logger.warn('ChatConnection: Cannot connect without user ID');
|
||||||
|
setConnectionStatus('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (websocketRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentUserId(userIdToUse);
|
||||||
|
setConnectionStatus('connecting');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const wsUrl = getWebSocketUrl(userIdToUse);
|
||||||
|
|
||||||
|
if (!wsUrl) {
|
||||||
|
throw new Error(
|
||||||
|
'Cannot generate WebSocket URL - missing token or user ID'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
websocketRef.current = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
websocketRef.current.onopen = () => {
|
||||||
|
logger.info(
|
||||||
|
'ChatConnection: Connected successfully for user:',
|
||||||
|
userIdToUse
|
||||||
|
);
|
||||||
|
setIsConnected(true);
|
||||||
|
setConnectionStatus('connected');
|
||||||
|
setReconnectAttempts(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
websocketRef.current.onclose = (event) => {
|
||||||
|
setIsConnected(false);
|
||||||
|
setConnectionStatus('disconnected');
|
||||||
|
|
||||||
|
// Tentative de reconnexion automatique
|
||||||
|
if (reconnectAttempts < maxReconnectAttempts && !event.wasClean) {
|
||||||
|
const timeout = Math.min(
|
||||||
|
1000 * Math.pow(2, reconnectAttempts),
|
||||||
|
30000
|
||||||
|
);
|
||||||
|
|
||||||
|
reconnectTimeoutRef.current = setTimeout(() => {
|
||||||
|
setReconnectAttempts((prev) => prev + 1);
|
||||||
|
connectToChat();
|
||||||
|
}, timeout);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
websocketRef.current.onerror = (error) => {
|
||||||
|
logger.error('ChatConnection: WebSocket error', error);
|
||||||
|
setConnectionStatus('error');
|
||||||
|
setIsConnected(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
websocketRef.current.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
// Gérer les messages de présence
|
||||||
|
if (data.type === 'presence_update') {
|
||||||
|
handlePresenceUpdate(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifier tous les callbacks enregistrés
|
||||||
|
notifyMessageCallbacks(data);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ChatConnection: Error parsing message', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ChatConnection: Error creating WebSocket', error);
|
||||||
|
setConnectionStatus('error');
|
||||||
|
setIsConnected(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Déconnexion WebSocket
|
||||||
|
const disconnectFromChat = () => {
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
reconnectTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (websocketRef.current) {
|
||||||
|
websocketRef.current.close(1000, 'User disconnected');
|
||||||
|
websocketRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConnected(false);
|
||||||
|
setConnectionStatus('disconnected');
|
||||||
|
setReconnectAttempts(0);
|
||||||
|
logger.info('ChatConnection: Disconnected by user');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Envoi de message
|
||||||
|
const sendMessage = (message) => {
|
||||||
|
if (websocketRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
const messageStr = JSON.stringify(message);
|
||||||
|
websocketRef.current.send(messageStr);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
logger.warn('ChatConnection: Cannot send message - not connected');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtenir la référence WebSocket pour les composants qui en ont besoin
|
||||||
|
const getWebSocket = () => websocketRef.current;
|
||||||
|
|
||||||
|
// Effet pour la gestion de la session et connexion automatique
|
||||||
|
useEffect(() => {
|
||||||
|
// Si la session change vers authenticated et qu'on a un user_id, essayer de se connecter
|
||||||
|
if (status === 'authenticated' && session?.user?.user_id && !isConnected) {
|
||||||
|
connectToChat(session.user.user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si la session devient unauthenticated, déconnecter
|
||||||
|
if (status === 'unauthenticated' && isConnected) {
|
||||||
|
disconnectFromChat();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
status,
|
||||||
|
session?.user?.user_id,
|
||||||
|
isConnected,
|
||||||
|
connectToChat,
|
||||||
|
disconnectFromChat,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Nettoyage à la destruction du composant
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
}
|
||||||
|
if (websocketRef.current) {
|
||||||
|
websocketRef.current.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
isConnected,
|
||||||
|
connectionStatus,
|
||||||
|
userPresences, // Ajouter les présences utilisateur
|
||||||
|
connectToChat,
|
||||||
|
disconnectFromChat,
|
||||||
|
sendMessage,
|
||||||
|
getWebSocket,
|
||||||
|
reconnectAttempts,
|
||||||
|
maxReconnectAttempts,
|
||||||
|
addMessageCallback, // Ajouter cette fonction
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChatConnectionContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</ChatConnectionContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useChatConnection = () => {
|
||||||
|
const context = useContext(ChatConnectionContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
'useChatConnection must be used within a ChatConnectionProvider'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatConnectionContext;
|
||||||
@ -1,3 +1,90 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* Animations pour le chat */
|
||||||
|
|
||||||
|
@keyframes typing-bounce {
|
||||||
|
0%,
|
||||||
|
60%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
transform: translateY(-8px);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-dot {
|
||||||
|
animation: typing-bounce 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-dot:nth-child(1) {
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-dot:nth-child(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-dot:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator-enter {
|
||||||
|
animation: fade-in 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Améliorations visuelles pour les accusés de lecture */
|
||||||
|
.read-indicator {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-indicator.read {
|
||||||
|
color: #60a5fa; /* Bleu plus visible pour les messages lus */
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-indicator.sent {
|
||||||
|
color: #93c5fd; /* Bleu plus clair pour les messages envoyés */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation pour l'apparition des nouveaux messages */
|
||||||
|
.message-appear {
|
||||||
|
animation: fade-in 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles Discord-like pour les messages */
|
||||||
|
.message-container:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-container:hover .message-timestamp {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-container:hover .message-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timestamp {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-actions {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|||||||
249
Front-End/src/hooks/useWebSocket.js
Normal file
249
Front-End/src/hooks/useWebSocket.js
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
import { WS_CHAT_URL } from '@/utils/Url';
|
||||||
|
import logger from '@/utils/logger';
|
||||||
|
|
||||||
|
const useWebSocket = (userId, onMessage, onConnectionChange) => {
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [connectionStatus, setConnectionStatus] = useState('disconnected');
|
||||||
|
const wsRef = useRef(null);
|
||||||
|
const reconnectTimeoutRef = useRef(null);
|
||||||
|
const reconnectAttemptsRef = useRef(0);
|
||||||
|
const isConnectingRef = useRef(false); // Empêcher les connexions multiples
|
||||||
|
const maxReconnectAttempts = 5;
|
||||||
|
|
||||||
|
// Récupération du token JWT
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const authToken = session?.user?.token;
|
||||||
|
|
||||||
|
// Références stables pour les callbacks
|
||||||
|
const onMessageRef = useRef(onMessage);
|
||||||
|
const onConnectionChangeRef = useRef(onConnectionChange);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onMessageRef.current = onMessage;
|
||||||
|
}, [onMessage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onConnectionChangeRef.current = onConnectionChange;
|
||||||
|
}, [onConnectionChange]);
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (!userId || !authToken) {
|
||||||
|
logger.warn('WebSocket: userId ou token manquant');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empêcher les connexions multiples simultanées
|
||||||
|
if (
|
||||||
|
isConnectingRef.current ||
|
||||||
|
(wsRef.current && wsRef.current.readyState === WebSocket.CONNECTING)
|
||||||
|
) {
|
||||||
|
logger.debug('WebSocket: connexion déjà en cours');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fermer la connexion existante si elle existe
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close();
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnectingRef.current = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ajouter le token à l'URL du WebSocket
|
||||||
|
const wsUrl = new URL(WS_CHAT_URL(userId));
|
||||||
|
wsUrl.searchParams.append('token', authToken);
|
||||||
|
|
||||||
|
wsRef.current = new WebSocket(wsUrl.toString());
|
||||||
|
|
||||||
|
wsRef.current.onopen = () => {
|
||||||
|
logger.debug('WebSocket connecté');
|
||||||
|
isConnectingRef.current = false;
|
||||||
|
setIsConnected(true);
|
||||||
|
setConnectionStatus('connected');
|
||||||
|
reconnectAttemptsRef.current = 0;
|
||||||
|
onConnectionChangeRef.current?.(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
wsRef.current.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
onMessageRef.current?.(data);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Erreur lors du parsing du message WebSocket:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
wsRef.current.onclose = (event) => {
|
||||||
|
logger.debug('WebSocket fermé:', event.code, event.reason);
|
||||||
|
isConnectingRef.current = false;
|
||||||
|
setIsConnected(false);
|
||||||
|
setConnectionStatus('disconnected');
|
||||||
|
onConnectionChangeRef.current?.(false);
|
||||||
|
|
||||||
|
// Tentative de reconnexion automatique seulement si la fermeture n'est pas intentionnelle
|
||||||
|
if (
|
||||||
|
event.code !== 1000 &&
|
||||||
|
reconnectAttemptsRef.current < maxReconnectAttempts
|
||||||
|
) {
|
||||||
|
reconnectAttemptsRef.current++;
|
||||||
|
setConnectionStatus('reconnecting');
|
||||||
|
|
||||||
|
const delay = Math.min(
|
||||||
|
1000 * Math.pow(2, reconnectAttemptsRef.current),
|
||||||
|
30000
|
||||||
|
);
|
||||||
|
reconnectTimeoutRef.current = setTimeout(() => {
|
||||||
|
logger.debug(
|
||||||
|
`Tentative de reconnexion ${reconnectAttemptsRef.current}/${maxReconnectAttempts}`
|
||||||
|
);
|
||||||
|
connect();
|
||||||
|
}, delay);
|
||||||
|
} else {
|
||||||
|
setConnectionStatus('failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
wsRef.current.onerror = (error) => {
|
||||||
|
logger.error('Erreur WebSocket:', error);
|
||||||
|
isConnectingRef.current = false;
|
||||||
|
setConnectionStatus('error');
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Erreur lors de la création du WebSocket:', error);
|
||||||
|
isConnectingRef.current = false;
|
||||||
|
setConnectionStatus('error');
|
||||||
|
}
|
||||||
|
}, [userId, authToken]);
|
||||||
|
|
||||||
|
const disconnect = useCallback(() => {
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
reconnectTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close();
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConnected(false);
|
||||||
|
setConnectionStatus('disconnected');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendMessage = useCallback(
|
||||||
|
(message) => {
|
||||||
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||||
|
// Ajouter le token à chaque message
|
||||||
|
const messageWithAuth = {
|
||||||
|
...message,
|
||||||
|
token: authToken,
|
||||||
|
};
|
||||||
|
wsRef.current.send(JSON.stringify(messageWithAuth));
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
logger.warn("WebSocket non connecté, impossible d'envoyer le message");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[authToken]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendTypingStart = useCallback(
|
||||||
|
(conversationId) => {
|
||||||
|
sendMessage({
|
||||||
|
type: 'typing_start',
|
||||||
|
conversation_id: conversationId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[sendMessage]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendTypingStop = useCallback(
|
||||||
|
(conversationId) => {
|
||||||
|
sendMessage({
|
||||||
|
type: 'typing_stop',
|
||||||
|
conversation_id: conversationId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[sendMessage]
|
||||||
|
);
|
||||||
|
|
||||||
|
const markAsRead = useCallback(
|
||||||
|
(conversationId) => {
|
||||||
|
sendMessage({
|
||||||
|
type: 'mark_as_read',
|
||||||
|
conversation_id: conversationId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[sendMessage]
|
||||||
|
);
|
||||||
|
|
||||||
|
const joinConversation = useCallback(
|
||||||
|
(conversationId) => {
|
||||||
|
sendMessage({
|
||||||
|
type: 'join_conversation',
|
||||||
|
conversation_id: conversationId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[sendMessage]
|
||||||
|
);
|
||||||
|
|
||||||
|
const leaveConversation = useCallback(
|
||||||
|
(conversationId) => {
|
||||||
|
sendMessage({
|
||||||
|
type: 'leave_conversation',
|
||||||
|
conversation_id: conversationId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[sendMessage]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendChatMessage = useCallback(
|
||||||
|
(conversationId, content, attachment = null) => {
|
||||||
|
const messageData = {
|
||||||
|
type: 'send_message',
|
||||||
|
conversation_id: conversationId,
|
||||||
|
content: content,
|
||||||
|
message_type: attachment ? 'file' : 'text',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ajouter les informations du fichier si présent
|
||||||
|
if (attachment) {
|
||||||
|
messageData.attachment = attachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendMessage(messageData);
|
||||||
|
},
|
||||||
|
[sendMessage]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Se connecter seulement si on a un userId et un token
|
||||||
|
if (userId && authToken) {
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disconnect();
|
||||||
|
};
|
||||||
|
}, [userId, authToken]); // Retirer connect et disconnect des dépendances
|
||||||
|
|
||||||
|
return {
|
||||||
|
isConnected,
|
||||||
|
connectionStatus,
|
||||||
|
sendMessage,
|
||||||
|
sendTypingStart,
|
||||||
|
sendTypingStop,
|
||||||
|
markAsRead,
|
||||||
|
joinConversation,
|
||||||
|
leaveConversation,
|
||||||
|
sendChatMessage,
|
||||||
|
reconnect: connect,
|
||||||
|
disconnect,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useWebSocket;
|
||||||
63
Front-End/src/styles/chat-animations.css
Normal file
63
Front-End/src/styles/chat-animations.css
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
/* Animations pour le chat */
|
||||||
|
|
||||||
|
@keyframes typing-bounce {
|
||||||
|
0%,
|
||||||
|
60%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
transform: translateY(-8px);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-dot {
|
||||||
|
animation: typing-bounce 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-dot:nth-child(1) {
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-dot:nth-child(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-dot:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator-enter {
|
||||||
|
animation: fade-in 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Améliorations visuelles pour les accusés de lecture */
|
||||||
|
.read-indicator {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-indicator.read {
|
||||||
|
color: #60a5fa; /* Bleu plus visible pour les messages lus */
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-indicator.sent {
|
||||||
|
color: #93c5fd; /* Bleu plus clair pour les messages envoyés */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation pour l'apparition des nouveaux messages */
|
||||||
|
.message-appear {
|
||||||
|
animation: fade-in 0.3s ease-out;
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { RIGHTS } from '@/utils/rights';
|
import { RIGHTS } from '@/utils/rights';
|
||||||
export const BASE_URL = process.env.NEXT_PUBLIC_API_URL;
|
export const BASE_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
export const WS_BASE_URL = process.env.NEXT_PUBLIC_WSAPI_URL;
|
||||||
|
|
||||||
//URL-Back-End
|
//URL-Back-End
|
||||||
|
|
||||||
@ -53,13 +54,25 @@ export const BE_SCHOOL_ESTABLISHMENT_URL = `${BASE_URL}/Establishment/establishm
|
|||||||
export const BE_PLANNING_PLANNINGS_URL = `${BASE_URL}/Planning/plannings`;
|
export const BE_PLANNING_PLANNINGS_URL = `${BASE_URL}/Planning/plannings`;
|
||||||
export const BE_PLANNING_EVENTS_URL = `${BASE_URL}/Planning/events`;
|
export const BE_PLANNING_EVENTS_URL = `${BASE_URL}/Planning/events`;
|
||||||
|
|
||||||
|
// GESTION EMAIL
|
||||||
|
export const BE_GESTIONEMAIL_SEND_EMAIL_URL = `${BASE_URL}/GestionEmail/send-email/`;
|
||||||
|
export const BE_GESTIONEMAIL_SEARCH_RECIPIENTS_URL = `${BASE_URL}/GestionEmail/search-recipients`;
|
||||||
|
|
||||||
// GESTION MESSAGERIE
|
// GESTION MESSAGERIE
|
||||||
export const BE_GESTIONMESSAGERIE_CONVERSATIONS_URL = `${BASE_URL}/GestionMessagerie/conversations`;
|
export const BE_GESTIONMESSAGERIE_CONVERSATIONS_URL = `${BASE_URL}/GestionMessagerie/conversations`;
|
||||||
export const BE_GESTIONMESSAGERIE_CONVERSATION_MESSAGES_URL = `${BASE_URL}/GestionMessagerie/conversations/messages`;
|
export const BE_GESTIONMESSAGERIE_CONVERSATION_MESSAGES_URL = `${BASE_URL}/GestionMessagerie/conversations/messages`;
|
||||||
export const BE_GESTIONMESSAGERIE_MARK_AS_READ_URL = `${BASE_URL}/GestionMessagerie/conversations/mark-as-read`;
|
export const BE_GESTIONMESSAGERIE_MARK_AS_READ_URL = `${BASE_URL}/GestionMessagerie/conversations/mark-as-read`;
|
||||||
|
export const BE_GESTIONMESSAGERIE_DELETE_CONVERSATION_URL = `${BASE_URL}/GestionMessagerie/conversations`;
|
||||||
export const BE_GESTIONMESSAGERIE_MESSAGES_URL = `${BASE_URL}/GestionMessagerie/messages`;
|
export const BE_GESTIONMESSAGERIE_MESSAGES_URL = `${BASE_URL}/GestionMessagerie/messages`;
|
||||||
export const BE_GESTIONMESSAGERIE_SEND_MESSAGE_URL = `${BASE_URL}/GestionMessagerie/send-email/`;
|
export const BE_GESTIONMESSAGERIE_SEND_MESSAGE_URL = `${BASE_URL}/GestionMessagerie/send-message`;
|
||||||
|
export const BE_GESTIONMESSAGERIE_CREATE_CONVERSATION_URL = `${BASE_URL}/GestionMessagerie/create-conversation/`;
|
||||||
export const BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL = `${BASE_URL}/GestionMessagerie/search-recipients`;
|
export const BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL = `${BASE_URL}/GestionMessagerie/search-recipients`;
|
||||||
|
export const BE_GESTIONMESSAGERIE_UPLOAD_FILE_URL = `${BASE_URL}/GestionMessagerie/upload-file/`;
|
||||||
|
|
||||||
|
// WEBSOCKET MESSAGERIE
|
||||||
|
export const WS_CHAT_URL = (userId) => {
|
||||||
|
return `${WS_BASE_URL}/ws/chat/${userId}/`;
|
||||||
|
};
|
||||||
|
|
||||||
// SETTINGS
|
// SETTINGS
|
||||||
export const BE_SETTINGS_SMTP_URL = `${BASE_URL}/Settings/smtp-settings`;
|
export const BE_SETTINGS_SMTP_URL = `${BASE_URL}/Settings/smtp-settings`;
|
||||||
|
|||||||
14
README.md
14
README.md
@ -1,6 +1,18 @@
|
|||||||
# N3wt School
|
# N3wt School
|
||||||
|
|
||||||
Logiciel de gestion d'école
|
Logiciel de gestion d'école avec système de messagerie instantanée intégré
|
||||||
|
|
||||||
|
## ✨ Nouvelles Fonctionnalités
|
||||||
|
|
||||||
|
### 📱 Messagerie Instantanée (Nouveau)
|
||||||
|
|
||||||
|
- **Chat temps réel** avec WebSocket
|
||||||
|
- **Upload de fichiers** avec prévisualisation
|
||||||
|
- **Indicateurs de présence** (en ligne/hors ligne)
|
||||||
|
- **Messages lus/non lus** avec compteurs
|
||||||
|
- **Interface moderne** avec composants React
|
||||||
|
|
||||||
|
Voir la [documentation complète](./docs/messagerie-instantanee.md) pour plus de détails.
|
||||||
|
|
||||||
## Maquette
|
## Maquette
|
||||||
|
|
||||||
|
|||||||
1
debug.log
Normal file
1
debug.log
Normal file
@ -0,0 +1 @@
|
|||||||
|
[0526/220602.137:ERROR:third_party\crashpad\crashpad\client\crashpad_client_win.cc:811] not connected
|
||||||
@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
redis:
|
redis:
|
||||||
image: 'redis:latest'
|
image: "redis:latest"
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
|
|
||||||
@ -8,7 +8,7 @@ services:
|
|||||||
- TZ=Europe/Paris
|
- TZ=Europe/Paris
|
||||||
|
|
||||||
database:
|
database:
|
||||||
image: 'postgres:latest'
|
image: "postgres:latest"
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
environment:
|
environment:
|
||||||
@ -16,7 +16,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_DB: school
|
POSTGRES_DB: school
|
||||||
TZ: Europe/Paris
|
TZ: Europe/Paris
|
||||||
|
|
||||||
docuseal:
|
docuseal:
|
||||||
image: docuseal/docuseal:latest
|
image: docuseal/docuseal:latest
|
||||||
depends_on:
|
depends_on:
|
||||||
@ -36,8 +36,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- TZ=Europe/Paris
|
- TZ=Europe/Paris
|
||||||
- TEST_MODE=True
|
- TEST_MODE=True
|
||||||
- CORS_ALLOWED_ORIGINS=http://localhost:3000
|
- CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:8080,http://127.0.0.1:8080
|
||||||
- CSRF_TRUSTED_ORIGINS=http://localhost:3000
|
- CSRF_TRUSTED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:8080,http://127.0.0.1:8080
|
||||||
links:
|
links:
|
||||||
- "database:database"
|
- "database:database"
|
||||||
- "redis:redis"
|
- "redis:redis"
|
||||||
@ -79,4 +79,3 @@ services:
|
|||||||
# - TZ=Europe/Paris
|
# - TZ=Europe/Paris
|
||||||
# depends_on:
|
# depends_on:
|
||||||
# - backend
|
# - backend
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user