Module Messagerie¶
Objectif¶
La Messagerie centralise les echanges applicatifs dans Solutravo :
- conversations liees aux candidatures de missions ;
- conversations internes entre une societe et ses collaborateurs ;
- conversations directes entre un administrateur et une societe ;
- diffusion administrateur vers une ou plusieurs societes ;
- widget de messagerie rapide sur les pages de l'application ;
- compteur de non-lus, temps reel optionnel et rappels email.
Le module est volontairement generique : la table principale ne depend pas seulement des missions. Les conversations sont qualifiees par conversation_type, source_type, source_id et par des liens fonctionnels dans messaging_conversation_links.
Carte technique¶
flowchart TD
Router["app/core/Router.php"] --> Controller["MessagingController"]
Controller --> Model["MessagingModel"]
Controller --> Internal["InternalMessagingService"]
Controller --> Admin["AdminMessagingService"]
Controller --> Realtime["MessagingRealtimeService"]
Mission["MessagingService"] --> Model
Mission --> Realtime
Model --> Tables["messaging_*"]
Model --> EmailReminder["MessagingEmailReminderService"]
EmailReminder --> EmailQueue["File email / Brevo"]
View["app/views/messaging.php"] --> PageJs["public/assets/js/messaging/messaging.js"]
Widget["messaging-widget.php"] --> WidgetJs["public/assets/js/messaging/widget.js"]
PageJs --> Controller
WidgetJs --> Controller
RealtimeJs["realtime.js"] --> Ws["realtime/messaging-server.js"]
Controller --> Realtime
Fichiers a connaitre¶
| Role | Fichiers |
|---|---|
| Routage HTTP | app/core/Router.php |
| Controleur | app/controllers/MessagingController.php |
| Modele principal | app/models/MessagingModel.php |
| Creation depuis une candidature | app/services/MessagingService.php |
| Conversations societe/collaborateur | app/services/InternalMessagingService.php |
| Conversations et diffusions admin | app/services/AdminMessagingService.php |
| Temps reel | app/services/MessagingRealtimeService.php, public/assets/js/messaging/realtime.js, realtime/messaging-server.js |
| Rappels email | app/services/MessagingEmailReminderService.php, app/cron/MessagingEmailReminderCron.php |
| Page complete | app/views/messaging.php, public/assets/js/messaging/messaging.js, public/assets/css/messaging.css |
| Widget rapide | app/views/partials/messaging-widget.php, public/assets/js/messaging/widget.js, public/assets/css/messaging-widget.css |
| UI partagee | public/assets/js/messaging/thread-shared.js, public/assets/js/messaging/notifications.js, public/assets/js/messaging/image-preview.js |
| Modales | app/views/partials/modals/messaging-*.php |
| SQL | app/bd/messaging.sql, app/bd/messaging-attachments.sql, app/bd/messaging-participant-senders.sql, app/bd/messaging-email-reminders.sql, app/bd/messaging-admin-campaigns.sql |
Routes¶
Toutes les routes passent par Auth::check().
| Route | Methode attendue | Controleur | Usage |
|---|---|---|---|
messaging |
GET | index() |
Page complete de messagerie. |
messaging/conversations |
GET | conversations() |
Liste des conversations du scope courant, avec recherche. |
messaging/conversation |
GET | conversation() |
Detail d'une conversation et marquage lu si necessaire. |
messaging/application |
GET | application() |
Donnees de candidature mission associees a une conversation. |
messaging/realtime/token |
GET | realtimeToken() |
Jeton court pour la connexion WebSocket. |
messaging/message/send |
POST | sendMessage() |
Envoi d'un message texte. |
messaging/attachment/send |
POST | sendAttachment() |
Envoi d'une piece jointe. |
messaging/conversation/read |
POST | markRead() |
Marquage lu explicite. |
messaging/unread-count |
GET | unreadCount() |
Compteur global de non-lus du scope courant. |
messaging/internal/collaborators |
GET | internalCollaborators() |
Recherche de collaborateurs de la societe active. |
messaging/internal/member/start |
POST | internalStartMemberConversation() |
La societe demarre ou reprend une conversation avec un collaborateur. |
messaging/internal/company/start |
POST | internalStartCompanyConversation() |
Un collaborateur demarre ou reprend une conversation avec sa societe. |
messaging/admin/companies |
GET | adminCompanies() |
Recherche societes et societes recentes pour l'admin. |
messaging/admin/direct/start |
POST | adminStartConversation() |
Conversation directe admin vers une societe, avec piece jointe optionnelle. |
messaging/admin/broadcast/send |
POST | adminBroadcast() |
Diffusion admin vers une selection ou toutes les societes. |
Identite et scopes¶
Le module ne travaille pas seulement avec membre_id ou societe_id. Il utilise une cle logique participant_key.
| Cle | Sens |
|---|---|
company:<societe_id> |
Boite de la societe active. |
member:<membre_id> |
Boite personnelle d'un collaborateur. |
admin:<membre_id> |
Boite administrateur. |
system |
Message systeme sans utilisateur emetteur. |
MessagingModel::getCurrentPrimaryParticipantKey() resout la cle courante a partir de la session :
- un collaborateur force le scope
member; - un admin peut demander explicitement
scope=admin; scope=companyforce la boite de la societe active ;- en mode
auto, la boite standard suit d'abordactive_societe_id, puis l'admin, puis le membre.
getCurrentParticipantKeys() retourne parfois plusieurs cles. Cas verifie : un utilisateur de type fournisseur rattache a une societe obtient la cle company:<id> et aussi member:<auth_user_id>. Les requetes de lecture/non-lu doivent donc toujours utiliser la liste de cles, pas une seule identite.
Schema de donnees¶
messaging_conversations¶
Conversation principale.
Champs structurants :
subject: objet affiche ;conversation_type:direct,mission_application,broadcast,support,system;source_typeetsource_id: lien metier unique quand la conversation vient d'une ressource source ;mission_id: lien optionnel versmissions;created_by_membre_id,created_by_societe_id: createur fonctionnel ;last_message_id,last_message_at: denormalisation pour le tri et les listes.
Contrainte importante : UNIQUE KEY uk_messaging_source (source_type, source_id). Pour une source metier donnee, il ne doit pas y avoir deux conversations.
messaging_conversation_participants¶
Associe une conversation a ses destinataires.
Champs structurants :
participant_key;participant_type:member,company,admin,system;role:owner,participant,support,recipient,announcer,candidate;last_read_at: base du calcul des non-lus ;muted_at,archived_at.
Contrainte importante : UNIQUE KEY uk_messaging_participant (conversation_id, participant_key).
messaging_messages¶
Messages d'une conversation.
Champs structurants :
sender_type:member,company,admin,system;message_type:text,system,notification,attachment;sender_participant_key;sender_membre_id,sender_societe_id;body;deleted_at.
insertMessage() met a jour last_message_* et planifie les rappels email apres insertion.
messaging_message_attachments¶
Pieces jointes liees a un message de type attachment.
Le fichier physique est stocke sous public/uploads/messaging/attachments/. La table conserve file_name, original_name, file_path, mime_type, file_size.
messaging_email_reminders¶
File applicative des rappels email pour messages non lus.
Champs structurants :
recipient_keyet type destinataire ;notify_at_utc;first_unread_message_id,last_unread_message_id,unread_messages_count;last_email_queue_id;last_notified_at;status:pending,sent,cancelled,failed.
Unicite : une seule ligne active par couple (conversation_id, recipient_key).
messaging_conversation_links¶
Table de liens metier non couverts par source_type/source_id.
Utilisations verifiees :
internal_member_directpour une conversation societe/collaborateur ;admin_company_directpour une conversation admin/societe.
Les cles sont uniques par (link_type, link_key) et par (conversation_id, link_type).
messaging_broadcast_campaigns et messaging_broadcast_campaign_targets¶
Historique des diffusions admin :
- une campagne porte
subject,body,status,created_by_admin_membre_id; - chaque cible relie
campaign_id,societe_id,conversation_id,initial_message_id,delivery_status.
Flux : candidature mission¶
MessagingService::createForMissionApplication($applicationId) cree ou recupere une conversation pour une candidature.
Etapes a respecter :
- Charger le contexte via
getMissionApplicationContext(). - Verifier si une conversation existe deja avec
source_type = mission_applicationetsource_id = application_id. - Creer
messaging_conversationsavecconversation_type = mission_application. - Ajouter les participants :
- annonceur :
company:<mission.societe_id>, roleannouncer; - candidat :
company:<candidate_societe_id>, rolecandidate. - Inserer le message initial :
- texte de candidature si present ;
- sinon message systeme.
- Publier un evenement temps reel de creation si le WebSocket est actif.
Le script app/bd/messaging.sql contient aussi un rattrapage idempotent pour les candidatures deja existantes.
Flux : liste et lecture¶
La page complete et le widget consomment principalement :
GET /messaging/conversations;GET /messaging/conversation?id=<id>;GET /messaging/unread-count.
MessagingModel::getConversations() limite la liste a 100 maximum, 40 par defaut. La recherche porte sur l'objet, le titre de mission, le dernier message, l'emetteur et les participants.
MessagingModel::getConversationWithMessages() refuse l'acces si la cle du participant courant n'est pas dans messaging_conversation_participants ou si le participant est archive. Quand la conversation est chargee, MessagingController::conversation() appelle markConversationReadWithMeta() et publie conversation.read si l'etat a change.
Flux : envoi texte¶
POST /messaging/message/send attend :
conversation_id;body.
Regles verifiees :
bodyest obligatoire aprestrim();- l'utilisateur doit avoir acces a la conversation ;
- l'insertion est transactionnelle ;
- le sender est calcule par
resolveCurrentSenderContextForConversation(); - apres insertion, la conversation est marquee comme lue pour l'emetteur ;
- le controleur publie
message.createden temps reel si l'envoi reussit.
Flux : pieces jointes¶
POST /messaging/attachment/send attend :
conversation_id;- fichier
attachment.
Regles verifiees dans MessagingModel :
- acces conversation obligatoire ;
- taille maximale : 5 Mo ;
- extensions autorisees :
jpg,jpeg,png,gif,pdf; - MIME autorises :
image/jpeg,image/png,image/gif,application/pdf; - stockage sous
uploads/messaging/attachmentsviaBaseModel::uploadFile(); - nettoyage du fichier physique si la transaction echoue apres upload ;
- pour un participant
candidate, l'envoi est reserve aux plansTPE,PME,ENTREPRISE;GRATUITest bloque ; - un admin est traite comme rang de plan maximum.
Le frontend accepte les memes familles MIME dans app/views/messaging.php.
Flux : conversations internes¶
InternalMessagingService gere les conversations entre la boite societe et un collaborateur.
Deux points d'entree :
startCompanyConversation($membreId, $subject, $body): la societe ecrit a un collaborateur ;startMemberConversation($subject, $body): le collaborateur ecrit a sa societe.
Invariants :
- une societe active est obligatoire ;
- le message est obligatoire ;
- le collaborateur cible doit appartenir a la societe active ;
- la conversation est reutilisee via
messaging_conversation_linksaveclink_type = internal_member_direct; - les participants sont
company:<societe_id>roleowneretmember:<membre_id>roleparticipant.
Flux : admin direct et diffusion¶
AdminMessagingService impose assertAdmin() pour ses operations.
Conversation directe :
link_type = admin_company_direct;- type de conversation par defaut :
support; - participants attendus :
admin:<admin_membre_id>etcompany:<societe_id>; - piece jointe optionnelle via
sendAttachmentInCurrentTransaction().
Diffusion :
sendBroadcast($subject, $body, $societeIds, $allSocieties);- le scope du modele est force a
admin; - le sujet et le message sont obligatoires ;
- si
all_societiesest vrai, le service charge toutes les societes ; - une campagne est creee dans
messaging_broadcast_campaigns; - chaque societe cible recoit une conversation/cible de campagne.
Non-lus¶
Le compteur est base sur :
- les participants actifs du scope courant ;
last_read_atdansmessaging_conversation_participants;- les messages non supprimes ;
- l'exclusion des messages dont
sender_participant_keyappartient aux cles courantes.
Ne pas recalculer les non-lus uniquement avec sender_membre_id ou sender_societe_id : cela casserait les cas multi-scope et fournisseur.
Temps reel¶
Le temps reel est optionnel.
Variables d'environnement lues par MessagingRealtimeService :
| Variable | Usage |
|---|---|
MESSAGING_WS_ENABLED |
Active/desactive le WebSocket. |
MESSAGING_WS_URL |
URL publique utilisee par le navigateur. |
MESSAGING_WS_INTERNAL_URL |
URL serveur appelee par PHP pour publier un evenement. |
MESSAGING_WS_SHARED_SECRET |
Secret HMAC pour tokens et publication interne. |
MESSAGING_WS_TOKEN_TTL_SECONDS |
TTL du jeton navigateur, minimum 30 s, 120 s par defaut. |
Le jeton contient les participantKeys, authUserId, activeSocieteId, isAdmin, iat, exp. Il est signe en HMAC SHA-256.
Evenements publies par le controleur :
message.created;conversation.read.
public/assets/js/messaging/realtime.js :
- demande un token via
/messaging/realtime/token; - ouvre le WebSocket avec
?token=...; - redispatch les messages en events DOM
messaging:realtimeetmessaging:realtime:<type>; - reconnecte avec backoff jusqu'a 30 s ;
- se reconnecte au focus et au retour de visibilite.
Polling et widget¶
Le module garde un polling meme avec le temps reel :
notifications.jsmet a jour le badge global via/messaging/unread-count;- si le WebSocket est connecte, le polling est espace ;
- sans WebSocket, le polling est plus frequent ;
widget.jsgere le dock desktop, les fenetres de conversations, le cache locallocalStorageet l'envoi rapide ;messaging.jsgere la page complete, la recherche, la navigation mobile, l'admin et les modales internes.
Le widget est masque sur mobile par widget.js (max-width: 991.98px) ; la page complete prend le relais.
Rappels email¶
MessagingEmailReminderService cree un rappel apres chaque message via MessagingModel::insertMessage().
Variables :
| Variable | Defaut | Usage |
|---|---|---|
MESSAGING_EMAIL_NOTIFICATIONS_ENABLED |
false |
Active les rappels email. |
MESSAGING_EMAIL_NOTIFICATION_DELAY_MINUTES |
5 |
Delai avant rappel. Borne : 1 a 1440 minutes. |
MESSAGING_EMAIL_CONVERSATION_COOLDOWN_MINUTES |
30 |
Delai minimum entre deux rappels d'une meme conversation. Borne : 1 a 1440 minutes. |
Template Brevo :
- source attendue :
messaging_unread_messages; - declaree dans
supports/statuses/email_template_sources.php.
Traitement :
app/cron/MessagingEmailReminderCron.phpappelleprocessDueReminders($limit);- la limite par defaut est 100, bornee a
MAX_BATCH_SIZE = 100; - un rappel est annule si le snapshot ne contient plus de messages non lus ;
- un rappel est reporte si le cooldown n'est pas termine ;
- sinon un email template est ajoute a la file via
enqueueTemplateEmail(); - le cron journalise
MESSAGING_EMAIL_REMINDERS_RUNviaActivityLogger.
Parametres de template construits :
RECIPIENT_FIRSTNAME;RECIPIENT_COMPANY_NAME;UNREAD_MESSAGES_COUNT;UNREAD_CONVERSATIONS_COUNT;LATEST_CONVERSATION_SUBJECT;LATEST_MESSAGE_EXCERPT;LATEST_UNREAD_AT;MESSAGING_URL.
Regles de securite et d'acces¶
Points a ne pas casser :
- toute lecture/ecriture conversation doit passer par
canAccessConversation()ou par une creation controlee du service specialise ; - ne jamais exposer une conversation sur simple
conversation_id; - garder
participant_keycomme identifiant fonctionnel pour les droits, les non-lus, le temps reel et les rappels ; - ne pas envoyer d'evenement WebSocket a une audience calculee cote client : l'audience vient de
getParticipantKeysForConversation()cote serveur ; - ne pas accepter d'autres extensions/MIME sans revoir l'apercu image, la securite upload et la politique produit ;
- verifier que les messages admin utilisent le scope
admin, sinon ils peuvent apparaitre comme messages de la societe active.
Points de vigilance¶
messaging.sqlcontient plusieurs tables puis des scripts de rattrapage. Sur un environnement deja migre partiellement, appliquer aussi les scripts incrementauxmessaging-attachments.sql,messaging-participant-senders.sql,messaging-email-reminders.sql,messaging-admin-campaigns.sqlselon l'etat reel de la base.- Les pieces jointes sont ecrites avant la transaction SQL. Le code supprime le fichier en cas d'echec apres upload ; verifier ce comportement si
BaseModel::uploadFile()change. last_read_atutiliseNOW()dans le modele, alors que les rappels email travaillent avecUTC_TIMESTAMP()etnow_utc(). Eviter les melanges de timezone dans une future refonte.- Les conversations de candidature dependent de
mission_applications,missions,societes,membres. Toute migration de Missions doit verifierMessagingServiceetMissionModel::getMessagingApplicationById(). - Le widget et la page complete consomment les memes endpoints mais ont des etats JS separes. Une modification de payload doit etre testee dans les deux interfaces.
Verification conseillee¶
Scenario minimal de reprise :
- Creer ou identifier deux societes et une candidature mission.
- Verifier qu'une conversation
mission_applicationexiste avec deux participantscompany:*. - Ouvrir la page
/messagingavec chaque participant et confirmer la visibilite reciproque. - Envoyer un message texte et verifier
last_message_id,last_message_at, compteur non-lu etlast_read_at. - Tester une piece jointe image ou PDF sous 5 Mo.
- Tester un candidat en plan insuffisant : l'API doit retourner
upgrade_required. - Si le temps reel est actif, verifier
MESSAGING_WS_*, la route token et la reception demessage.created. - Si les emails sont actifs, lancer
php app/cron/MessagingEmailReminderCron.php 10et verifier la file email ainsi quemessaging_email_reminders.
Questions ouvertes¶
- Il n'y a pas de test automatise dedie a la Messagerie dans les fichiers inspectes. Ajouter des tests d'acces conversation et de non-lus serait prioritaire.
- La politique produit exacte des pieces jointes pour les roles non-candidats est implicite dans
getAttachmentPolicy(): ils peuvent envoyer si la conversation est accessible. - Les erreurs WebSocket sont logguees mais ne bloquent pas l'usage grace au polling ; conserver cette degradation progressive si le serveur temps reel evolue.