Aller au contenu

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=company force la boite de la societe active ;
  • en mode auto, la boite standard suit d'abord active_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_type et source_id : lien metier unique quand la conversation vient d'une ressource source ;
  • mission_id : lien optionnel vers missions ;
  • 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_key et 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).

Table de liens metier non couverts par source_type/source_id.

Utilisations verifiees :

  • internal_member_direct pour une conversation societe/collaborateur ;
  • admin_company_direct pour 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 :

  1. Charger le contexte via getMissionApplicationContext().
  2. Verifier si une conversation existe deja avec source_type = mission_application et source_id = application_id.
  3. Creer messaging_conversations avec conversation_type = mission_application.
  4. Ajouter les participants :
  5. annonceur : company:<mission.societe_id>, role announcer ;
  6. candidat : company:<candidate_societe_id>, role candidate.
  7. Inserer le message initial :
  8. texte de candidature si present ;
  9. sinon message systeme.
  10. 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 :

  • body est obligatoire apres trim() ;
  • 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.created en 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/attachments via BaseModel::uploadFile() ;
  • nettoyage du fichier physique si la transaction echoue apres upload ;
  • pour un participant candidate, l'envoi est reserve aux plans TPE, PME, ENTREPRISE ; GRATUIT est 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_links avec link_type = internal_member_direct ;
  • les participants sont company:<societe_id> role owner et member:<membre_id> role participant.

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> et company:<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_societies est 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_at dans messaging_conversation_participants ;
  • les messages non supprimes ;
  • l'exclusion des messages dont sender_participant_key appartient 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:realtime et messaging: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.js met 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.js gere le dock desktop, les fenetres de conversations, le cache local localStorage et l'envoi rapide ;
  • messaging.js gere 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.php appelle processDueReminders($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_RUN via ActivityLogger.

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_key comme 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

  1. messaging.sql contient plusieurs tables puis des scripts de rattrapage. Sur un environnement deja migre partiellement, appliquer aussi les scripts incrementaux messaging-attachments.sql, messaging-participant-senders.sql, messaging-email-reminders.sql, messaging-admin-campaigns.sql selon l'etat reel de la base.
  2. 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.
  3. last_read_at utilise NOW() dans le modele, alors que les rappels email travaillent avec UTC_TIMESTAMP() et now_utc(). Eviter les melanges de timezone dans une future refonte.
  4. Les conversations de candidature dependent de mission_applications, missions, societes, membres. Toute migration de Missions doit verifier MessagingService et MissionModel::getMessagingApplicationById().
  5. 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 :

  1. Creer ou identifier deux societes et une candidature mission.
  2. Verifier qu'une conversation mission_application existe avec deux participants company:*.
  3. Ouvrir la page /messaging avec chaque participant et confirmer la visibilite reciproque.
  4. Envoyer un message texte et verifier last_message_id, last_message_at, compteur non-lu et last_read_at.
  5. Tester une piece jointe image ou PDF sous 5 Mo.
  6. Tester un candidat en plan insuffisant : l'API doit retourner upgrade_required.
  7. Si le temps reel est actif, verifier MESSAGING_WS_*, la route token et la reception de message.created.
  8. Si les emails sont actifs, lancer php app/cron/MessagingEmailReminderCron.php 10 et verifier la file email ainsi que messaging_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.