package dev.lions.unionflow.server.service; import dev.lions.unionflow.server.api.dto.messagerie.request.BloquerMembreRequest; import dev.lions.unionflow.server.api.dto.messagerie.request.DemarrerConversationDirecteRequest; import dev.lions.unionflow.server.api.dto.messagerie.request.DemarrerConversationRoleRequest; import dev.lions.unionflow.server.api.dto.messagerie.request.EnvoyerMessageRequest; import dev.lions.unionflow.server.api.dto.messagerie.request.MettreAJourPolitiqueRequest; import dev.lions.unionflow.server.api.dto.messagerie.response.ContactPolicyResponse; import dev.lions.unionflow.server.api.dto.messagerie.response.ConversationResponse; import dev.lions.unionflow.server.api.dto.messagerie.response.ConversationSummaryResponse; import dev.lions.unionflow.server.api.dto.messagerie.response.MessageResponse; import dev.lions.unionflow.server.api.enums.messagerie.StatutConversation; import dev.lions.unionflow.server.api.enums.messagerie.TypeContenu; import dev.lions.unionflow.server.api.enums.messagerie.TypeConversation; import dev.lions.unionflow.server.api.enums.messagerie.TypePolitiqueCommunication; import dev.lions.unionflow.server.entity.ContactPolicy; import dev.lions.unionflow.server.entity.Conversation; import dev.lions.unionflow.server.entity.ConversationParticipant; import dev.lions.unionflow.server.entity.MemberBlock; import dev.lions.unionflow.server.entity.Membre; import dev.lions.unionflow.server.entity.MembreOrganisation; import dev.lions.unionflow.server.entity.Message; import dev.lions.unionflow.server.entity.Organisation; import dev.lions.unionflow.server.repository.ContactPolicyRepository; import dev.lions.unionflow.server.repository.ConversationParticipantRepository; import dev.lions.unionflow.server.repository.ConversationRepository; import dev.lions.unionflow.server.repository.MemberBlockRepository; import dev.lions.unionflow.server.repository.MembreOrganisationRepository; import dev.lions.unionflow.server.repository.MembreRepository; import dev.lions.unionflow.server.repository.MessageRepository; import dev.lions.unionflow.server.messaging.KafkaEventProducer; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.transaction.Transactional; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.ForbiddenException; import jakarta.ws.rs.NotFoundException; import java.util.ArrayList; import java.util.List; import java.util.UUID; import java.util.stream.Collectors; import org.jboss.logging.Logger; /** * Service métier pour la messagerie instantanée. * *

Gère les conversations (directes et canaux-rôle), les messages (texte, * vocal, image), les blocages et les politiques de communication. * *

Politique par appartenance : deux membres de la même organisation * peuvent se contacter sans demande d'amitié préalable. * L'adhésion est la relation de confiance. * * @author UnionFlow Team * @version 4.0 * @since 2026-04-13 */ @ApplicationScoped public class MessagingService { private static final Logger LOG = Logger.getLogger(MessagingService.class); private static final int PAGE_SIZE_DEFAULT = 30; @Inject ConversationRepository conversationRepository; @Inject ConversationParticipantRepository participantRepository; @Inject MessageRepository messageRepository; @Inject ContactPolicyRepository contactPolicyRepository; @Inject MemberBlockRepository memberBlockRepository; @Inject MembreRepository membreRepository; @Inject MembreOrganisationRepository membreOrganisationRepository; @Inject KafkaEventProducer kafkaEventProducer; @Inject io.quarkus.security.identity.SecurityIdentity securityIdentity; // ── Conversations ───────────────────────────────────────────────────────── /** * Démarre ou récupère une conversation directe 1-1. * Idempotent : si la conversation existe déjà, elle est retournée. */ @Transactional public ConversationResponse demarrerConversationDirecte(DemarrerConversationDirecteRequest request) { Membre moi = getMembreConnecte(); Membre destinataire = membreRepository.findById(request.destinataireId()); if (destinataire == null) { throw new NotFoundException("Membre destinataire non trouvé : " + request.destinataireId()); } UUID orgId = request.organisationId(); verifierAppartenance(moi.getId(), orgId); verifierAppartenance(request.destinataireId(), orgId); verifierPolitique(moi.getId(), request.destinataireId(), orgId, false); // Idempotence : chercher une conversation directe existante return conversationRepository .findConversationDirecte(moi.getId(), request.destinataireId(), orgId) .map(c -> { // Envoyer le message initial si fourni if (request.contenuInitial() != null && !request.contenuInitial().isBlank()) { envoyerMessageDansConversation(c, moi, request.contenuInitial(), TypeContenu.TEXTE, null, null, null); } return toConversationResponse(c, moi.getId()); }) .orElseGet(() -> { Organisation org = getOrganisation(orgId); Conversation conv = Conversation.builder() .organisation(org) .typeConversation(TypeConversation.DIRECTE) .statut(StatutConversation.ACTIVE) .build(); conversationRepository.persist(conv); ajouterParticipant(conv, moi, "INITIATEUR"); ajouterParticipant(conv, destinataire, "PARTICIPANT"); if (request.contenuInitial() != null && !request.contenuInitial().isBlank()) { envoyerMessageDansConversation(conv, moi, request.contenuInitial(), TypeContenu.TEXTE, null, null, null); } LOG.infof("Conversation directe créée: %s ↔ %s dans org %s", moi.getEmail(), destinataire.getEmail(), orgId); return toConversationResponse(conv, moi.getId()); }); } /** * Démarre ou récupère un canal de rôle. * Le canal est partagé : tous les membres qui contactent "Le Trésorier" * aboutissent dans le même canal. */ @Transactional public ConversationResponse demarrerConversationRole(DemarrerConversationRoleRequest request) { Membre moi = getMembreConnecte(); UUID orgId = request.organisationId(); verifierAppartenance(moi.getId(), orgId); verifierPolitique(moi.getId(), null, orgId, true); String roleCible = request.roleCible(); List porteurs = trouverPorteursDuRole(orgId, roleCible); if (porteurs.isEmpty()) { throw new NotFoundException("Aucun membre avec le rôle " + roleCible + " dans cette organisation"); } Organisation org = getOrganisation(orgId); String titreCanal = libelleDuRole(roleCible); return conversationRepository.findCanalRole(orgId, roleCible) .map(c -> { // Ajouter l'initiateur s'il n'est pas encore participant if (!participantRepository.estParticipant(c.getId(), moi.getId())) { ajouterParticipant(c, moi, "PARTICIPANT"); } envoyerMessageDansConversation(c, moi, request.contenuInitial(), TypeContenu.TEXTE, null, null, null); return toConversationResponse(c, moi.getId()); }) .orElseGet(() -> { Conversation conv = Conversation.builder() .organisation(org) .typeConversation(TypeConversation.ROLE_CANAL) .roleCible(roleCible) .titre(titreCanal) .statut(StatutConversation.ACTIVE) .build(); conversationRepository.persist(conv); ajouterParticipant(conv, moi, "INITIATEUR"); porteurs.forEach(p -> ajouterParticipant(conv, p, "MODERATEUR")); envoyerMessageDansConversation(conv, moi, request.contenuInitial(), TypeContenu.TEXTE, null, null, null); LOG.infof("Canal rôle créé: %s dans org %s", roleCible, orgId); return toConversationResponse(conv, moi.getId()); }); } /** * Retourne la liste des conversations du membre connecté. */ public List getMesConversations() { Membre moi = getMembreConnecte(); return conversationRepository.findByMembreId(moi.getId()).stream() .map(c -> toConversationSummary(c, moi.getId())) .collect(Collectors.toList()); } /** * Retourne le détail d'une conversation (avec les derniers messages). */ public ConversationResponse getConversation(UUID conversationId) { Membre moi = getMembreConnecte(); Conversation conv = conversationRepository.findConversationById(conversationId) .orElseThrow(() -> new NotFoundException("Conversation non trouvée : " + conversationId)); verifierParticipant(conv, moi.getId()); return toConversationResponse(conv, moi.getId()); } /** * Archive une conversation. */ @Transactional public ConversationResponse archiverConversation(UUID conversationId) { Membre moi = getMembreConnecte(); Conversation conv = conversationRepository.findConversationById(conversationId) .orElseThrow(() -> new NotFoundException("Conversation non trouvée : " + conversationId)); verifierParticipant(conv, moi.getId()); conv.archiver(); return toConversationResponse(conv, moi.getId()); } // ── Messages ────────────────────────────────────────────────────────────── /** * Envoie un message dans une conversation existante. */ @Transactional public MessageResponse envoyerMessage(UUID conversationId, EnvoyerMessageRequest request) { Membre moi = getMembreConnecte(); Conversation conv = conversationRepository.findConversationById(conversationId) .orElseThrow(() -> new NotFoundException("Conversation non trouvée : " + conversationId)); verifierParticipant(conv, moi.getId()); if (!conv.estActive()) { throw new BadRequestException("Cette conversation est archivée"); } TypeContenu type = parseTypeContenu(request.typeMessage()); validerContenuMessage(type, request); Message message = envoyerMessageDansConversation( conv, moi, request.contenu(), type, request.urlFichier(), request.dureeAudio(), request.messageParentId() ); return toMessageResponse(message); } /** * Récupère les messages d'une conversation (paginés). */ public List getMessages(UUID conversationId, int page) { Membre moi = getMembreConnecte(); Conversation conv = conversationRepository.findConversationById(conversationId) .orElseThrow(() -> new NotFoundException("Conversation non trouvée : " + conversationId)); verifierParticipant(conv, moi.getId()); return messageRepository.findByConversationPagine(conversationId, page, PAGE_SIZE_DEFAULT) .stream() .map(this::toMessageResponse) .collect(Collectors.toList()); } /** * Marque tous les messages d'une conversation comme lus. */ @Transactional public void marquerConversationLue(UUID conversationId) { Membre moi = getMembreConnecte(); participantRepository.findParticipant(conversationId, moi.getId()) .ifPresent(p -> { p.marquerLu(); LOG.debugf("Conversation %s marquée lue par %s", conversationId, moi.getEmail()); }); } /** * Supprime un message (soft delete — contenu remplacé par "[Message supprimé]"). */ @Transactional public void supprimerMessage(UUID conversationId, UUID messageId) { Membre moi = getMembreConnecte(); Message message = messageRepository.findMessageById(messageId) .orElseThrow(() -> new NotFoundException("Message non trouvé : " + messageId)); if (!message.getConversation().getId().equals(conversationId)) { throw new NotFoundException("Message non trouvé dans cette conversation"); } if (!message.getExpediteur().getId().equals(moi.getId())) { throw new ForbiddenException("Vous ne pouvez supprimer que vos propres messages"); } message.supprimer(); } // ── Blocages ────────────────────────────────────────────────────────────── /** * Bloque un membre dans une organisation. */ @Transactional public void bloquerMembre(BloquerMembreRequest request) { Membre moi = getMembreConnecte(); UUID membreABloquerId = request.membreABloquerId(); UUID orgId = request.organisationId(); if (moi.getId().equals(membreABloquerId)) { throw new BadRequestException("Vous ne pouvez pas vous bloquer vous-même"); } Membre aBloquer = membreRepository.findById(membreABloquerId); if (aBloquer == null) { throw new NotFoundException("Membre non trouvé : " + membreABloquerId); } if (memberBlockRepository.estBloque(moi.getId(), membreABloquerId, orgId)) { throw new BadRequestException("Ce membre est déjà bloqué"); } Organisation org = getOrganisation(orgId); MemberBlock block = MemberBlock.builder() .bloqueur(moi) .bloque(aBloquer) .organisation(org) .build(); memberBlockRepository.persist(block); LOG.infof("%s a bloqué %s dans org %s", moi.getEmail(), aBloquer.getEmail(), orgId); } /** * Débloque un membre dans une organisation. */ @Transactional public void debloquerMembre(UUID membreId, UUID organisationId) { Membre moi = getMembreConnecte(); MemberBlock block = memberBlockRepository.findBlocage(moi.getId(), membreId, organisationId) .orElseThrow(() -> new NotFoundException("Aucun blocage trouvé pour ce membre")); block.setActif(false); } /** * Retourne la liste des membres bloqués par le membre connecté. */ public List getMesBlocages() { Membre moi = getMembreConnecte(); return memberBlockRepository.findByBloqueur(moi.getId()); } // ── Politique de communication ──────────────────────────────────────────── /** * Retourne la politique de communication d'une organisation. * Crée une politique par défaut si elle n'existe pas encore. */ @Transactional public ContactPolicyResponse getPolitique(UUID organisationId) { ContactPolicy policy = contactPolicyRepository.findByOrganisationId(organisationId) .orElseGet(() -> creerPolitiqueParDefaut(organisationId)); return toContactPolicyResponse(policy); } /** * Met à jour la politique de communication. * Réservé aux ADMIN et ADMIN_ORGANISATION. */ @Transactional public ContactPolicyResponse mettreAJourPolitique(UUID organisationId, MettreAJourPolitiqueRequest request) { ContactPolicy policy = contactPolicyRepository.findByOrganisationId(organisationId) .orElseGet(() -> creerPolitiqueParDefaut(organisationId)); if (request.typePolitique() != null) { policy.setTypePolitique(TypePolitiqueCommunication.valueOf(request.typePolitique())); } if (request.autoriserMembreVersMembre() != null) { policy.setAutoriserMembreVersMembre(request.autoriserMembreVersMembre()); } if (request.autoriserMembreVersRole() != null) { policy.setAutoriserMembreVersRole(request.autoriserMembreVersRole()); } if (request.autoriserNotesVocales() != null) { policy.setAutoriserNotesVocales(request.autoriserNotesVocales()); } return toContactPolicyResponse(policy); } // ── Méthodes privées ────────────────────────────────────────────────────── private Message envoyerMessageDansConversation( Conversation conv, Membre expediteur, String contenu, TypeContenu type, String urlFichier, Integer dureeAudio, UUID messageParentId) { Message.MessageBuilder builder = Message.builder() .conversation(conv) .expediteur(expediteur) .typeMessage(type) .contenu(contenu) .urlFichier(urlFichier) .dureeAudio(dureeAudio); if (messageParentId != null) { messageRepository.findMessageById(messageParentId) .ifPresent(builder::messageParent); } Message message = builder.build(); messageRepository.persist(message); conv.enregistrerNouveauMessage(); // Notifier via Kafka → WebSocket try { java.util.Map data = new java.util.HashMap<>(); data.put("conversationId", conv.getId().toString()); data.put("messageId", message.getId() != null ? message.getId().toString() : ""); data.put("expediteurId", expediteur.getId().toString()); data.put("typeMessage", type.name()); kafkaEventProducer.publishNouveauMessage(conv.getId(), conv.getOrganisation().getId().toString(), data); } catch (Exception e) { LOG.warnf("Impossible de publier l'event Kafka pour le message: %s", e.getMessage()); } return message; } private void ajouterParticipant(Conversation conv, Membre membre, String role) { if (!participantRepository.estParticipant(conv.getId(), membre.getId())) { ConversationParticipant participant = ConversationParticipant.builder() .conversation(conv) .membre(membre) .roleDansConversation(role) .notifier(true) .build(); participantRepository.persist(participant); } } private void verifierAppartenance(UUID membreId, UUID organisationId) { boolean appartient = membreOrganisationRepository .count("membre.id = ?1 AND organisation.id = ?2 AND actif = true", membreId, organisationId) > 0; if (!appartient) { throw new ForbiddenException("Le membre n'appartient pas à cette organisation"); } } private void verifierPolitique(UUID expediteurId, UUID destinataireId, UUID orgId, boolean versRole) { contactPolicyRepository.findByOrganisationId(orgId).ifPresent(policy -> { if (versRole && !policy.getAutoriserMembreVersRole()) { throw new ForbiddenException("La politique de cette organisation n'autorise pas les contacts vers les rôles"); } if (!versRole && !policy.getAutoriserMembreVersMembre()) { throw new ForbiddenException("La politique de cette organisation n'autorise pas les contacts entre membres"); } }); // Vérifier le blocage if (destinataireId != null && memberBlockRepository.estBloque(destinataireId, expediteurId, orgId)) { throw new ForbiddenException("Vous ne pouvez pas contacter ce membre"); } } private void verifierParticipant(Conversation conv, UUID membreId) { if (!participantRepository.estParticipant(conv.getId(), membreId)) { throw new ForbiddenException("Vous n'êtes pas participant à cette conversation"); } } private List trouverPorteursDuRole(UUID orgId, String role) { List membresOrg = membreOrganisationRepository .find("organisation.id = ?1 AND roleOrg = ?2 AND actif = true", orgId, role) .list(); return membresOrg.stream() .map(MembreOrganisation::getMembre) .collect(Collectors.toList()); } private ContactPolicy creerPolitiqueParDefaut(UUID organisationId) { Organisation org = getOrganisation(organisationId); ContactPolicy policy = ContactPolicy.builder() .organisation(org) .typePolitique(TypePolitiqueCommunication.OUVERT) .autoriserMembreVersMembre(true) .autoriserMembreVersRole(true) .autoriserNotesVocales(true) .build(); contactPolicyRepository.persist(policy); return policy; } private Membre getMembreConnecte() { String email = securityIdentity.getPrincipal().getName(); return membreRepository.find("email", email).firstResult(); } private Organisation getOrganisation(UUID orgId) { return (Organisation) dev.lions.unionflow.server.entity.Organisation.findById(orgId); } private TypeContenu parseTypeContenu(String type) { if (type == null || type.isBlank()) return TypeContenu.TEXTE; try { return TypeContenu.valueOf(type.toUpperCase()); } catch (IllegalArgumentException e) { return TypeContenu.TEXTE; } } private void validerContenuMessage(TypeContenu type, EnvoyerMessageRequest req) { switch (type) { case TEXTE: if (req.contenu() == null || req.contenu().isBlank()) { throw new BadRequestException("Le contenu est obligatoire pour un message texte"); } break; case VOCAL: if (req.urlFichier() == null || req.urlFichier().isBlank()) { throw new BadRequestException("L'URL du fichier audio est obligatoire pour une note vocale"); } if (req.dureeAudio() == null) { throw new BadRequestException("La durée audio est obligatoire pour une note vocale"); } break; case IMAGE: if (req.urlFichier() == null || req.urlFichier().isBlank()) { throw new BadRequestException("L'URL de l'image est obligatoire"); } break; default: break; } } private String libelleDuRole(String role) { return switch (role) { case "PRESIDENT" -> "Président"; case "TRESORIER" -> "Trésorier"; case "SECRETAIRE" -> "Secrétaire"; case "VICE_PRESIDENT" -> "Vice-Président"; case "ADMIN" -> "Administrateur"; case "ADMIN_ORGANISATION" -> "Administrateur"; default -> role; }; } // ── Conversions DTO ─────────────────────────────────────────────────────── private ConversationResponse toConversationResponse(Conversation conv, UUID membreConnecteId) { List msgs = messageRepository .findByConversationPagine(conv.getId(), 0, PAGE_SIZE_DEFAULT) .stream().map(this::toMessageResponse).collect(Collectors.toList()); List parts = participantRepository.findByConversation(conv.getId()).stream() .map(p -> ConversationResponse.ParticipantResponse.builder() .membreId(p.getMembre().getId()) .prenom(p.getMembre().getPrenom()) .nom(p.getMembre().getNom()) .roleDansConversation(p.getRoleDansConversation()) .luJusqua(p.getLuJusqua()) .build()) .collect(Collectors.toList()); long nonLus = messageRepository.countNonLus(conv.getId(), membreConnecteId); return ConversationResponse.builder() .id(conv.getId()) .typeConversation(conv.getTypeConversation().name()) .titre(resolverTitre(conv, membreConnecteId)) .statut(conv.getStatut().name()) .roleCible(conv.getRoleCible()) .organisationId(conv.getOrganisation().getId()) .organisationNom(conv.getOrganisation().getNom()) .dateCreation(conv.getDateCreation()) .dernierMessageAt(conv.getDernierMessageAt()) .nombreMessages(conv.getNombreMessages()) .participants(parts) .messages(msgs) .nonLus(nonLus) .build(); } private ConversationSummaryResponse toConversationSummary(Conversation conv, UUID membreConnecteId) { String apercu = messageRepository.findDernierMessage(conv.getId()) .map(m -> { if (TypeContenu.VOCAL.equals(m.getTypeMessage())) return "🎤 Note vocale"; if (TypeContenu.IMAGE.equals(m.getTypeMessage())) return "📷 Image"; String c = m.getContenu(); return c != null && c.length() > 100 ? c.substring(0, 97) + "..." : c; }) .orElse(null); String dernierType = messageRepository.findDernierMessage(conv.getId()) .map(m -> m.getTypeMessage().name()).orElse(null); long nonLus = messageRepository.countNonLus(conv.getId(), membreConnecteId); return ConversationSummaryResponse.builder() .id(conv.getId()) .typeConversation(conv.getTypeConversation().name()) .titre(resolverTitre(conv, membreConnecteId)) .statut(conv.getStatut().name()) .dernierMessageApercu(apercu) .dernierMessageType(dernierType) .dernierMessageAt(conv.getDernierMessageAt()) .nonLus(nonLus) .organisationId(conv.getOrganisation().getId()) .build(); } private MessageResponse toMessageResponse(Message message) { String contenuAffiche = message.estSupprime() ? "[Message supprimé]" : message.getContenu(); String parentApercu = null; UUID parentId = null; if (message.getMessageParent() != null) { parentId = message.getMessageParent().getId(); String pc = message.getMessageParent().getContenu(); parentApercu = pc != null && pc.length() > 100 ? pc.substring(0, 97) + "..." : pc; } return MessageResponse.builder() .id(message.getId()) .typeMessage(message.getTypeMessage().name()) .contenu(contenuAffiche) .urlFichier(message.estSupprime() ? null : message.getUrlFichier()) .dureeAudio(message.getDureeAudio()) .supprime(message.estSupprime()) .expediteurId(message.getExpediteur().getId()) .expediteurNom(message.getExpediteur().getNom()) .expediteurPrenom(message.getExpediteur().getPrenom()) .messageParentId(parentId) .messageParentApercu(parentApercu) .dateEnvoi(message.getDateCreation()) .build(); } private ContactPolicyResponse toContactPolicyResponse(ContactPolicy policy) { return ContactPolicyResponse.builder() .id(policy.getId()) .organisationId(policy.getOrganisation().getId()) .typePolitique(policy.getTypePolitique().name()) .autoriserMembreVersMembre(Boolean.TRUE.equals(policy.getAutoriserMembreVersMembre())) .autoriserMembreVersRole(Boolean.TRUE.equals(policy.getAutoriserMembreVersRole())) .autoriserNotesVocales(Boolean.TRUE.equals(policy.getAutoriserNotesVocales())) .build(); } /** * Résout le titre affiché pour une conversation. * Pour DIRECTE : "Prénom Nom" de l'autre participant. * Pour ROLE_CANAL : le titre du canal. */ private String resolverTitre(Conversation conv, UUID membreConnecteId) { if (conv.getTitre() != null) return conv.getTitre(); if (TypeConversation.DIRECTE.equals(conv.getTypeConversation())) { return participantRepository.findByConversation(conv.getId()).stream() .filter(p -> !p.getMembre().getId().equals(membreConnecteId)) .findFirst() .map(p -> p.getMembre().getPrenom() + " " + p.getMembre().getNom()) .orElse("Conversation"); } return conv.getRoleCible(); } }