feat(messaging): module messagerie unifié avec contact policies + member blocks

Refactor complet : fusion de Conversation + Message en un module Messaging unique
avec ContactPolicy (règles qui-peut-parler-à-qui) et MemberBlock (blocages utilisateur).

- Migration V28 : tables conversations/conversation_participants/messages/
  contact_policies/member_blocks
- Nouvelles entités : ContactPolicy, ConversationParticipant, MemberBlock
  (Conversation/Message mises à jour avec relations)
- Nouvelles repositories : ContactPolicyRepository, ConversationParticipantRepository,
  MemberBlockRepository
- MessagingResource (nouveau) remplace ConversationResource + MessageResource
- MessagingService (nouveau) remplace ConversationService + MessageService
  avec vérifications appartenance org + policies + blocages avant envoi
- Anciens fichiers Conversation/Message Resource/Service/Tests supprimés
This commit is contained in:
dahoud
2026-04-15 20:23:04 +00:00
parent a650b372f1
commit 719d45e1fe
25 changed files with 2120 additions and 3298 deletions

View File

@@ -0,0 +1,91 @@
package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.messagerie.TypePolitiqueCommunication;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Politique de communication d'une organisation.
*
* <p>Chaque organisation possède exactement une politique, créée automatiquement
* lors de la création de l'organisation avec les valeurs par défaut.
* L'administrateur peut la modifier via l'API.
*
* <p>Table : {@code contact_policies}
*
* @author UnionFlow Team
* @version 4.0
* @since 2026-04-13
*/
@Entity
@Table(
name = "contact_policies",
indexes = {
@Index(name = "idx_contact_policies_org", columnList = "organisation_id")
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_contact_policy_org", columnNames = "organisation_id")
}
)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class ContactPolicy extends BaseEntity {
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false)
private Organisation organisation;
@Enumerated(EnumType.STRING)
@Builder.Default
@Column(name = "type_politique", nullable = false, length = 30)
private TypePolitiqueCommunication typePolitique = TypePolitiqueCommunication.OUVERT;
@Builder.Default
@Column(name = "autoriser_membre_vers_membre", nullable = false)
private Boolean autoriserMembreVersMembre = Boolean.TRUE;
@Builder.Default
@Column(name = "autoriser_membre_vers_role", nullable = false)
private Boolean autoriserMembreVersRole = Boolean.TRUE;
@Builder.Default
@Column(name = "autoriser_notes_vocales", nullable = false)
private Boolean autoriserNotesVocales = Boolean.TRUE;
@PrePersist
@Override
protected void onCreate() {
super.onCreate();
if (typePolitique == null) {
typePolitique = TypePolitiqueCommunication.OUVERT;
}
if (autoriserMembreVersMembre == null) {
autoriserMembreVersMembre = Boolean.TRUE;
}
if (autoriserMembreVersRole == null) {
autoriserMembreVersRole = Boolean.TRUE;
}
if (autoriserNotesVocales == null) {
autoriserNotesVocales = Boolean.TRUE;
}
}
}

View File

@@ -1,119 +1,129 @@
package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.communication.ConversationType;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import dev.lions.unionflow.server.api.enums.messagerie.StatutConversation;
import dev.lions.unionflow.server.api.enums.messagerie.TypeConversation;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Entité Conversation pour le système de messagerie UnionFlow.
* Représente un fil de discussion entre membres.
* Fil de discussion entre membres d'une organisation.
*
* <p>Deux types sont supportés en V1 :
* <ul>
* <li>{@link TypeConversation#DIRECTE} — 1-1 entre deux membres</li>
* <li>{@link TypeConversation#ROLE_CANAL} — membre vers un rôle officiel
* (PRESIDENT, TRESORIER, SECRETAIRE…). Tous les porteurs du rôle répondent.</li>
* </ul>
*
* <p>Table : {@code conversations}
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-16
* @version 4.0
* @since 2026-04-13
*/
@Entity
@Table(name = "conversations", indexes = {
@Index(name = "idx_conversation_organisation", columnList = "organisation_id"),
@Index(name = "idx_conversation_type", columnList = "type"),
@Index(name = "idx_conversation_archived", columnList = "is_archived"),
@Index(name = "idx_conversation_created", columnList = "date_creation")
})
@Getter
@Setter
@Table(
name = "conversations",
indexes = {
@Index(name = "idx_conversations_organisation", columnList = "organisation_id"),
@Index(name = "idx_conversations_statut", columnList = "statut"),
@Index(name = "idx_conversations_dernier_msg", columnList = "dernier_message_at")
}
)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class Conversation extends BaseEntity {
/**
* Nom de la conversation
*/
@Column(name = "name", nullable = false, length = 255)
private String name;
/**
* Description optionnelle
*/
@Column(name = "description", length = 1000)
private String description;
/**
* Type de conversation (INDIVIDUAL, GROUP, BROADCAST, ANNOUNCEMENT)
*/
@Enumerated(EnumType.STRING)
@Column(name = "type", nullable = false, length = 20)
private ConversationType type;
/**
* Organisation associée (optionnelle)
*/
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id")
@JoinColumn(name = "organisation_id", nullable = false)
private Organisation organisation;
/**
* URL de l'avatar de la conversation
*/
@Column(name = "avatar_url", length = 500)
private String avatarUrl;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "type_conversation", nullable = false, length = 30)
private TypeConversation typeConversation;
/**
* Conversation muette
* Rôle cible pour les ROLE_CANAL (ex : "TRESORIER", "PRESIDENT").
* Null pour les conversations DIRECTE.
*/
@Column(name = "is_muted", nullable = false)
private Boolean isMuted = false;
@Column(name = "role_cible", length = 50)
private String roleCible;
/**
* Conversation épinglée
*/
@Column(name = "is_pinned", nullable = false)
private Boolean isPinned = false;
/** Titre affiché (nom du rôle ou du groupe, null pour DIRECTE). */
@Column(name = "titre", length = 200)
private String titre;
/**
* Conversation archivée
*/
@Column(name = "is_archived", nullable = false)
private Boolean isArchived = false;
@Enumerated(EnumType.STRING)
@Builder.Default
@Column(name = "statut", nullable = false, length = 20)
private StatutConversation statut = StatutConversation.ACTIVE;
/**
* Métadonnées additionnelles (JSON)
*/
@Column(name = "metadata", columnDefinition = "TEXT")
private String metadata;
@Column(name = "dernier_message_at")
private LocalDateTime dernierMessageAt;
/**
* Date de dernière mise à jour
*/
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Builder.Default
@Column(name = "nombre_messages", nullable = false)
private Integer nombreMessages = 0;
/**
* Participants de la conversation (many-to-many)
*/
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "conversation_participants",
joinColumns = @JoinColumn(name = "conversation_id"),
inverseJoinColumns = @JoinColumn(name = "membre_id")
)
private List<Membre> participants = new ArrayList<>();
@Builder.Default
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<ConversationParticipant> participants = new ArrayList<>();
/**
* Messages de la conversation (one-to-many)
*/
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Message> messages = new ArrayList<>();
/**
* Met à jour le timestamp
*/
@PreUpdate
protected void onUpdate() {
this.updatedAt = LocalDateTime.now();
@PrePersist
@Override
protected void onCreate() {
super.onCreate();
if (statut == null) {
statut = StatutConversation.ACTIVE;
}
if (nombreMessages == null) {
nombreMessages = 0;
}
}
// ── Méthodes métier ───────────────────────────────────────────────────────
/** Retourne true si la conversation accepte encore de nouveaux messages. */
public boolean estActive() {
return StatutConversation.ACTIVE.equals(statut);
}
/** Archive la conversation — plus aucun message n'est accepté. */
public void archiver() {
this.statut = StatutConversation.ARCHIVEE;
}
/** Incrémente le compteur et met à jour l'horodatage du dernier message. */
public void enregistrerNouveauMessage() {
this.nombreMessages = (this.nombreMessages == null ? 0 : this.nombreMessages) + 1;
this.dernierMessageAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,91 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Participation d'un membre à une conversation.
*
* <p>Stocke l'état de lecture individuel ({@code luJusqua}) et
* les préférences de notification du participant.
*
* <p>Table : {@code conversation_participants}
*
* @author UnionFlow Team
* @version 4.0
* @since 2026-04-13
*/
@Entity
@Table(
name = "conversation_participants",
indexes = {
@Index(name = "idx_conv_part_conversation", columnList = "conversation_id"),
@Index(name = "idx_conv_part_membre", columnList = "membre_id")
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_conv_participant",
columnNames = {"conversation_id", "membre_id"})
}
)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class ConversationParticipant extends BaseEntity {
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "conversation_id", nullable = false)
private Conversation conversation;
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "membre_id", nullable = false)
private Membre membre;
/**
* Rôle de ce participant dans la conversation.
* Ex : INITIATEUR, PARTICIPANT, MODERATEUR.
*/
@Builder.Default
@Column(name = "role_dans_conversation", length = 50)
private String roleDansConversation = "PARTICIPANT";
/**
* Horodatage du dernier message lu.
* Permet de calculer le nombre de messages non lus.
*/
@Column(name = "lu_jusqu_a")
private LocalDateTime luJusqua;
/** Si false, ce participant ne reçoit plus de notifications pour cette conversation. */
@Builder.Default
@Column(name = "notifier", nullable = false)
private Boolean notifier = Boolean.TRUE;
// ── Méthodes métier ───────────────────────────────────────────────────────
/** Marque tous les messages jusqu'à maintenant comme lus. */
public void marquerLu() {
this.luJusqua = LocalDateTime.now();
}
/** Retourne true si ce participant est l'initiateur de la conversation. */
public boolean estInitiateur() {
return "INITIATEUR".equals(roleDansConversation);
}
}

View File

@@ -0,0 +1,68 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import java.util.UUID;
/**
* Blocage unilatéral entre deux membres au sein d'une organisation.
*
* <p>Un membre bloqué ne peut plus envoyer de messages au bloqueur.
* Le blocage est limité à une organisation (un membre bloqué dans l'asso X
* peut encore écrire dans la tontine Y).
*
* <p>Table : {@code member_blocks}
*
* @author UnionFlow Team
* @version 4.0
* @since 2026-04-13
*/
@Entity
@Table(
name = "member_blocks",
indexes = {
@Index(name = "idx_member_blocks_bloqueur", columnList = "bloqueur_id"),
@Index(name = "idx_member_blocks_bloque", columnList = "bloque_id, organisation_id")
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_member_block",
columnNames = {"bloqueur_id", "bloque_id", "organisation_id"})
}
)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class MemberBlock extends BaseEntity {
/** Membre qui effectue le blocage */
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "bloqueur_id", nullable = false)
private Membre bloqueur;
/** Membre qui est bloqué */
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "bloque_id", nullable = false)
private Membre bloque;
/** Organisation dans laquelle le blocage est actif */
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false)
private Organisation organisation;
}

View File

@@ -1,156 +1,140 @@
package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.communication.MessagePriority;
import dev.lions.unionflow.server.api.enums.communication.MessageStatus;
import dev.lions.unionflow.server.api.enums.communication.MessageType;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import dev.lions.unionflow.server.api.enums.messagerie.TypeContenu;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Entité Message pour le système de messagerie UnionFlow.
* Représente un message individuel dans une conversation.
* Message envoyé dans une conversation.
*
* <p>Supporte trois types de contenu :
* <ul>
* <li>{@link TypeContenu#TEXTE} — message texte classique</li>
* <li>{@link TypeContenu#VOCAL} — note vocale (Opus/AAC), stockée sur object storage.
* Champs {@code urlFichier} + {@code dureeAudio} obligatoires.</li>
* <li>{@link TypeContenu#IMAGE} — image JPEG/PNG. Champ {@code urlFichier} obligatoire.</li>
* </ul>
*
* <p>La suppression est douce : {@code supprimeLe} est renseigné au lieu de
* supprimer la ligne. Le contenu devient {@code "[Message supprimé]"}.
*
* <p>Table : {@code messages}
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-16
* @version 4.0
* @since 2026-04-13
*/
@Entity
@Table(name = "messages", indexes = {
@Index(name = "idx_message_conversation", columnList = "conversation_id"),
@Index(name = "idx_message_sender", columnList = "sender_id"),
@Index(name = "idx_message_organisation", columnList = "organisation_id"),
@Index(name = "idx_message_status", columnList = "status"),
@Index(name = "idx_message_created", columnList = "date_creation"),
@Index(name = "idx_message_deleted", columnList = "is_deleted")
})
@Getter
@Setter
@Table(
name = "messages",
indexes = {
@Index(name = "idx_messages_conversation", columnList = "conversation_id"),
@Index(name = "idx_messages_expediteur", columnList = "expediteur_id"),
@Index(name = "idx_messages_date_creation", columnList = "date_creation"),
@Index(name = "idx_messages_parent", columnList = "message_parent_id")
}
)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class Message extends BaseEntity {
/**
* Conversation parente
*/
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "conversation_id", nullable = false)
private Conversation conversation;
/**
* Expéditeur du message
*/
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "sender_id", nullable = false)
private Membre sender;
@JoinColumn(name = "expediteur_id", nullable = false)
private Membre expediteur;
/**
* Nom de l'expéditeur (dénormalisé pour performance)
*/
@Column(name = "sender_name", nullable = false, length = 255)
private String senderName;
/**
* Avatar de l'expéditeur (dénormalisé)
*/
@Column(name = "sender_avatar", length = 500)
private String senderAvatar;
/**
* Contenu du message
*/
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
private String content;
/**
* Type de message (INDIVIDUAL, BROADCAST, TARGETED, SYSTEM)
*/
@Enumerated(EnumType.STRING)
@Column(name = "type", nullable = false, length = 20)
private MessageType type;
@Builder.Default
@Column(name = "type_message", nullable = false, length = 20)
private TypeContenu typeMessage = TypeContenu.TEXTE;
/** Texte du message — null pour les vocaux/images. */
@Column(name = "contenu", columnDefinition = "TEXT")
private String contenu;
/**
* Statut du message (SENT, DELIVERED, READ, FAILED)
* URL du fichier audio (notes vocales) ou image.
* Format : https://storage.lions.dev/chat/{conversationId}/{messageId}.opus
*/
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private MessageStatus status;
@Column(name = "url_fichier", length = 500)
private String urlFichier;
/** Durée en secondes pour les notes vocales. */
@Column(name = "duree_audio")
private Integer dureeAudio;
/**
* Priorité du message (NORMAL, HIGH, URGENT)
* Transcription automatique du vocal — null en V1.
* Sera renseigné par un service Speech-to-Text en V2.
*/
@Enumerated(EnumType.STRING)
@Column(name = "priority", nullable = false, length = 20)
private MessagePriority priority = MessagePriority.NORMAL;
@Column(name = "transcription", columnDefinition = "TEXT")
private String transcription;
/**
* IDs des destinataires (CSV pour targeted messages)
*/
@Column(name = "recipient_ids", length = 2000)
private String recipientIds;
/**
* Rôles destinataires (CSV pour role-based messaging)
*/
@Column(name = "recipient_roles", length = 500)
private String recipientRoles;
/**
* Organisation associée (optionnelle)
*/
/** Message auquel celui-ci répond (threading léger). */
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id")
private Organisation organisation;
@JoinColumn(name = "message_parent_id")
private Message messageParent;
/**
* Date de lecture du message
*/
@Column(name = "read_at")
private LocalDateTime readAt;
/** Date de suppression douce (null = message actif). */
@Column(name = "supprime_le")
private LocalDateTime supprimeLe;
/**
* Métadonnées additionnelles (JSON)
*/
@Column(name = "metadata", columnDefinition = "TEXT")
private String metadata;
@PrePersist
@Override
protected void onCreate() {
super.onCreate();
if (typeMessage == null) {
typeMessage = TypeContenu.TEXTE;
}
}
/**
* Pièces jointes (CSV URLs)
*/
@Column(name = "attachments", length = 2000)
private String attachments;
// ── Méthodes métier ───────────────────────────────────────────────────────
/**
* Message édité
*/
@Column(name = "is_edited", nullable = false)
private Boolean isEdited = false;
/** Retourne true si le message a été supprimé par son auteur. */
public boolean estSupprime() {
return supprimeLe != null;
}
/**
* Date d'édition
*/
@Column(name = "edited_at")
private LocalDateTime editedAt;
/** Retourne true si c'est un message texte. */
public boolean estTextuel() {
return TypeContenu.TEXTE.equals(typeMessage);
}
/**
* Message supprimé (soft delete)
*/
@Column(name = "is_deleted", nullable = false)
private Boolean isDeleted = false;
/**
* Marque le message comme lu
*/
public void markAsRead() {
this.status = MessageStatus.READ;
this.readAt = LocalDateTime.now();
/** Retourne true si c'est une note vocale. */
public boolean estVocal() {
return TypeContenu.VOCAL.equals(typeMessage);
}
/**
* Marque le message comme édité
* Supprime le message de façon douce.
* Le contenu original est remplacé par un marqueur.
*/
public void markAsEdited() {
this.isEdited = true;
this.editedAt = LocalDateTime.now();
public void supprimer() {
this.supprimeLe = LocalDateTime.now();
this.contenu = "[Message supprimé]";
this.urlFichier = null;
}
}

View File

@@ -0,0 +1,27 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.ContactPolicy;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.Optional;
import java.util.UUID;
/**
* Repository pour les politiques de communication des organisations.
*
* @author UnionFlow Team
* @version 4.0
* @since 2026-04-13
*/
@ApplicationScoped
public class ContactPolicyRepository implements PanacheRepositoryBase<ContactPolicy, UUID> {
/**
* Trouve la politique de communication d'une organisation.
* Chaque organisation a exactement une politique.
*/
public Optional<ContactPolicy> findByOrganisationId(UUID organisationId) {
return find("organisation.id = ?1 AND actif = true", organisationId)
.firstResultOptional();
}
}

View File

@@ -0,0 +1,44 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.ConversationParticipant;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Repository pour les participants aux conversations.
*
* @author UnionFlow Team
* @version 4.0
* @since 2026-04-13
*/
@ApplicationScoped
public class ConversationParticipantRepository
implements PanacheRepositoryBase<ConversationParticipant, UUID> {
/**
* Trouve la participation d'un membre à une conversation.
*/
public Optional<ConversationParticipant> findParticipant(UUID conversationId, UUID membreId) {
return find("conversation.id = ?1 AND membre.id = ?2 AND actif = true",
conversationId, membreId
).firstResultOptional();
}
/**
* Liste tous les participants actifs d'une conversation.
*/
public List<ConversationParticipant> findByConversation(UUID conversationId) {
return find("conversation.id = ?1 AND actif = true", conversationId).list();
}
/**
* Vérifie si un membre est participant à une conversation.
*/
public boolean estParticipant(UUID conversationId, UUID membreId) {
return count("conversation.id = ?1 AND membre.id = ?2 AND actif = true",
conversationId, membreId) > 0;
}
}

View File

@@ -1,72 +1,80 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.Conversation;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.api.enums.messagerie.StatutConversation;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Repository pour Conversation
* Repository pour les conversations de la messagerie.
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-16
* @version 4.0
* @since 2026-04-13
*/
@ApplicationScoped
public class ConversationRepository implements PanacheRepositoryBase<Conversation, UUID> {
/**
* Trouve toutes les conversations d'un membre
* Trouve une conversation par son ID avec Optional.
*/
public List<Conversation> findByParticipant(UUID membreId, boolean includeArchived) {
String query = """
SELECT DISTINCT c FROM Conversation c
JOIN c.participants p
WHERE p.id = :membreId
AND (c.actif IS NULL OR c.actif = true)
""";
if (!includeArchived) {
query += " AND c.isArchived = false";
}
query += " ORDER BY c.updatedAt DESC NULLS LAST, c.dateCreation DESC";
return getEntityManager()
.createQuery(query, Conversation.class)
.setParameter("membreId", membreId)
.getResultList();
public Optional<Conversation> findConversationById(UUID id) {
return find("id", id).firstResultOptional();
}
/**
* Trouve une conversation par ID et vérifie que le membre en fait partie
* Liste toutes les conversations d'un membre (via les participants).
* Triées par date du dernier message décroissante.
*/
public Optional<Conversation> findByIdAndParticipant(UUID conversationId, UUID membreId) {
String query = """
SELECT DISTINCT c FROM Conversation c
JOIN c.participants p
WHERE c.id = :conversationId
AND p.id = :membreId
AND (c.actif IS NULL OR c.actif = true)
""";
return getEntityManager()
.createQuery(query, Conversation.class)
.setParameter("conversationId", conversationId)
.setParameter("membreId", membreId)
.getResultStream()
.findFirst();
public List<Conversation> findByMembreId(UUID membreId) {
return find(
"SELECT DISTINCT c FROM Conversation c " +
"JOIN c.participants p " +
"WHERE p.membre.id = ?1 AND p.actif = true " +
"ORDER BY c.dernierMessageAt DESC NULLS LAST",
membreId
).list();
}
/**
* Trouve les conversations d'une organisation
* Trouve une conversation directe existante entre deux membres dans une organisation.
*/
public List<Conversation> findByOrganisation(UUID organisationId) {
return find("organisation.id = ?1 AND (actif IS NULL OR actif = true) ORDER BY updatedAt DESC NULLS LAST", organisationId)
.list();
public Optional<Conversation> findConversationDirecte(UUID membreAId, UUID membreBId, UUID organisationId) {
return find(
"SELECT DISTINCT c FROM Conversation c " +
"JOIN c.participants p1 " +
"JOIN c.participants p2 " +
"WHERE c.typeConversation = 'DIRECTE' " +
"AND c.organisation.id = ?3 " +
"AND p1.membre.id = ?1 AND p1.actif = true " +
"AND p2.membre.id = ?2 AND p2.actif = true",
membreAId, membreBId, organisationId
).firstResultOptional();
}
/**
* Trouve le canal d'un rôle dans une organisation.
*/
public Optional<Conversation> findCanalRole(UUID organisationId, String roleCible) {
return find(
"organisation.id = ?1 AND roleCible = ?2 AND typeConversation = 'ROLE_CANAL' AND actif = true",
organisationId, roleCible
).firstResultOptional();
}
/**
* Liste les conversations actives d'un membre.
*/
public List<Conversation> findActivesByMembre(UUID membreId) {
return find(
"SELECT DISTINCT c FROM Conversation c " +
"JOIN c.participants p " +
"WHERE p.membre.id = ?1 AND p.actif = true AND c.statut = ?2 " +
"ORDER BY c.dernierMessageAt DESC NULLS LAST",
membreId, StatutConversation.ACTIVE
).list();
}
}

View File

@@ -0,0 +1,51 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.MemberBlock;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Repository pour les blocages entre membres.
*
* @author UnionFlow Team
* @version 4.0
* @since 2026-04-13
*/
@ApplicationScoped
public class MemberBlockRepository implements PanacheRepositoryBase<MemberBlock, UUID> {
/**
* Vérifie si un membre en a bloqué un autre dans une organisation.
*/
public boolean estBloque(UUID bloqueurId, UUID bloqueId, UUID organisationId) {
return count("bloqueur.id = ?1 AND bloque.id = ?2 AND organisation.id = ?3 AND actif = true",
bloqueurId, bloqueId, organisationId) > 0;
}
/**
* Trouve le blocage entre deux membres dans une organisation.
*/
public Optional<MemberBlock> findBlocage(UUID bloqueurId, UUID bloqueId, UUID organisationId) {
return find("bloqueur.id = ?1 AND bloque.id = ?2 AND organisation.id = ?3 AND actif = true",
bloqueurId, bloqueId, organisationId
).firstResultOptional();
}
/**
* Liste tous les membres bloqués par un membre dans toutes ses organisations.
*/
public List<MemberBlock> findByBloqueur(UUID bloqueurId) {
return find("bloqueur.id = ?1 AND actif = true", bloqueurId).list();
}
/**
* Liste les blocages actifs d'un membre dans une organisation spécifique.
*/
public List<MemberBlock> findByBloqueurEtOrganisation(UUID bloqueurId, UUID organisationId) {
return find("bloqueur.id = ?1 AND organisation.id = ?2 AND actif = true",
bloqueurId, organisationId).list();
}
}

View File

@@ -2,65 +2,77 @@ package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.Message;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import io.quarkus.panache.common.Page;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Repository pour Message
* Repository pour les messages de la messagerie.
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-16
* @version 4.0
* @since 2026-04-13
*/
@ApplicationScoped
public class MessageRepository implements PanacheRepositoryBase<Message, UUID> {
/**
* Trouve tous les messages d'une conversation (non supprimés)
* Trouve un message par son ID avec Optional.
*/
public List<Message> findByConversation(UUID conversationId, int limit) {
return find(
"conversation.id = ?1 AND isDeleted = false AND (actif IS NULL OR actif = true) ORDER BY dateCreation DESC",
conversationId
)
.page(0, limit)
.list();
public Optional<Message> findMessageById(UUID id) {
return find("id", id).firstResultOptional();
}
/**
* Compte les messages non lus d'une conversation pour un membre
* Récupère les messages d'une conversation, paginés, du plus récent au plus ancien.
*
* @param conversationId ID de la conversation
* @param page numéro de page (0-based)
* @param size nombre de messages par page
*/
public long countUnreadByConversationAndMember(UUID conversationId, UUID membreId) {
// Pour simplifier, on compte les messages SENT/DELIVERED (pas READ)
// et dont le sender n'est PAS le membre en question
public List<Message> findByConversationPagine(UUID conversationId, int page, int size) {
return find(
"conversation.id = ?1 AND actif = true ORDER BY dateCreation DESC",
conversationId
).page(Page.of(page, size)).list();
}
/**
* Compte les messages non lus dans une conversation pour un membre donné.
* Un message est non lu si sa dateCreation est postérieure au luJusqua du participant.
*/
public long countNonLus(UUID conversationId, UUID membreId) {
return count(
"conversation.id = ?1 AND sender.id != ?2 AND status IN ('SENT', 'DELIVERED') AND isDeleted = false",
conversationId,
membreId
"SELECT COUNT(m) FROM Message m, ConversationParticipant p " +
"WHERE m.conversation.id = ?1 " +
"AND p.conversation.id = ?1 " +
"AND p.membre.id = ?2 " +
"AND m.actif = true " +
"AND (p.luJusqua IS NULL OR m.dateCreation > p.luJusqua) " +
"AND m.expediteur.id <> ?2",
conversationId, membreId
);
}
/**
* Marque tous les messages d'une conversation comme lus pour un membre
* Récupère les messages non supprimés d'une conversation (pour les tests).
*/
public int markAllAsReadByConversationAndMember(UUID conversationId, UUID membreId) {
return update(
"status = 'READ', readAt = CURRENT_TIMESTAMP WHERE conversation.id = ?1 AND sender.id != ?2 AND status != 'READ' AND isDeleted = false",
conversationId,
membreId
);
}
/**
* Trouve le dernier message d'une conversation
*/
public Message findLastByConversation(UUID conversationId) {
public List<Message> findActifsByConversation(UUID conversationId) {
return find(
"conversation.id = ?1 AND isDeleted = false ORDER BY dateCreation DESC",
"conversation.id = ?1 AND actif = true ORDER BY dateCreation ASC",
conversationId
)
.firstResult();
).list();
}
/**
* Trouve le dernier message actif d'une conversation.
*/
public Optional<Message> findDernierMessage(UUID conversationId) {
return find(
"conversation.id = ?1 AND actif = true ORDER BY dateCreation DESC",
conversationId
).firstResultOptional();
}
}

View File

@@ -1,145 +0,0 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.api.dto.communication.request.CreateConversationRequest;
import dev.lions.unionflow.server.api.dto.communication.response.ConversationResponse;
import dev.lions.unionflow.server.service.ConversationService;
import dev.lions.unionflow.server.service.support.SecuriteHelper;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Resource REST pour la gestion des conversations
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-16
*/
@Path("/api/conversations")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "Communication", description = "Gestion des conversations et messages")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "MEMBRE", "ADMIN_ORGANISATION"})
public class ConversationResource {
private static final Logger LOG = Logger.getLogger(ConversationResource.class);
@Inject
ConversationService conversationService;
@Inject
SecuriteHelper securiteHelper;
/**
* Liste les conversations de l'utilisateur connecté
*/
@GET
@Operation(summary = "Lister mes conversations")
@APIResponse(responseCode = "200", description = "Liste des conversations")
public Response getConversations(
@Parameter(description = "Inclure conversations archivées")
@QueryParam("includeArchived") @DefaultValue("false") boolean includeArchived,
@Parameter(description = "Filtrer par organisation")
@QueryParam("organisationId") String organisationId
) {
UUID membreId = securiteHelper.resolveMembreId();
UUID orgId = organisationId != null ? UUID.fromString(organisationId) : null;
List<ConversationResponse> conversations = conversationService.getConversations(membreId, orgId, includeArchived);
return Response.ok(conversations).build();
}
/**
* Récupère une conversation par ID
*/
@GET
@Path("/{id}")
@Operation(summary = "Récupérer une conversation")
@APIResponse(responseCode = "200", description = "Conversation trouvée")
@APIResponse(responseCode = "404", description = "Conversation non trouvée")
public Response getConversationById(@PathParam("id") UUID conversationId) {
UUID membreId = securiteHelper.resolveMembreId();
ConversationResponse conversation = conversationService.getConversationById(conversationId, membreId);
return Response.ok(conversation).build();
}
/**
* Crée une nouvelle conversation
*/
@POST
@Operation(summary = "Créer une conversation")
@APIResponse(responseCode = "201", description = "Conversation créée")
@APIResponse(responseCode = "400", description = "Données invalides")
public Response createConversation(@Valid CreateConversationRequest request) {
UUID creatorId = securiteHelper.resolveMembreId();
ConversationResponse conversation = conversationService.createConversation(request, creatorId);
return Response.status(Response.Status.CREATED).entity(conversation).build();
}
/**
* Archive une conversation
*/
@PUT
@Path("/{id}/archive")
@Operation(summary = "Archiver/désarchiver une conversation")
@APIResponse(responseCode = "204", description = "Conversation archivée")
public Response archiveConversation(
@PathParam("id") UUID conversationId,
@QueryParam("archive") @DefaultValue("true") boolean archive
) {
UUID membreId = securiteHelper.resolveMembreId();
conversationService.archiveConversation(conversationId, membreId, archive);
return Response.noContent().build();
}
/**
* Marque une conversation comme lue
*/
@PUT
@Path("/{id}/mark-read")
@Operation(summary = "Marquer conversation comme lue")
@APIResponse(responseCode = "204", description = "Marquée comme lue")
public Response markAsRead(@PathParam("id") UUID conversationId) {
UUID membreId = securiteHelper.resolveMembreId();
conversationService.markAsRead(conversationId, membreId);
return Response.noContent().build();
}
/**
* Toggle mute conversation
*/
@PUT
@Path("/{id}/toggle-mute")
@Operation(summary = "Activer/désactiver le son")
@APIResponse(responseCode = "204", description = "Paramètre modifié")
public Response toggleMute(@PathParam("id") UUID conversationId) {
UUID membreId = securiteHelper.resolveMembreId();
conversationService.toggleMute(conversationId, membreId);
return Response.noContent().build();
}
/**
* Toggle pin conversation
*/
@PUT
@Path("/{id}/toggle-pin")
@Operation(summary = "Épingler/désépingler")
@APIResponse(responseCode = "204", description = "Paramètre modifié")
public Response togglePin(@PathParam("id") UUID conversationId) {
UUID membreId = securiteHelper.resolveMembreId();
conversationService.togglePin(conversationId, membreId);
return Response.noContent().build();
}
}

View File

@@ -1,120 +0,0 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.api.dto.communication.request.SendMessageRequest;
import dev.lions.unionflow.server.api.dto.communication.response.MessageResponse;
import dev.lions.unionflow.server.service.MessageService;
import dev.lions.unionflow.server.service.support.SecuriteHelper;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Resource REST pour la gestion des messages
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-16
*/
@Path("/api/messages")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "Communication", description = "Gestion des conversations et messages")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "MEMBRE", "ADMIN_ORGANISATION"})
public class MessageResource {
private static final Logger LOG = Logger.getLogger(MessageResource.class);
@Inject
MessageService messageService;
@Inject
SecuriteHelper securiteHelper;
/**
* Récupère les messages d'une conversation
*/
@GET
@Operation(summary = "Lister les messages d'une conversation")
@APIResponse(responseCode = "200", description = "Liste des messages")
@APIResponse(responseCode = "404", description = "Conversation non trouvée")
public Response getMessages(
@Parameter(description = "ID de la conversation", required = true)
@QueryParam("conversationId") UUID conversationId,
@Parameter(description = "Nombre maximum de messages")
@QueryParam("limit") @DefaultValue("50") int limit
) {
if (conversationId == null) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "conversationId requis"))
.build();
}
UUID membreId = securiteHelper.resolveMembreId();
List<MessageResponse> messages = messageService.getMessages(conversationId, membreId, limit);
return Response.ok(messages).build();
}
/**
* Envoie un message
*/
@POST
@Operation(summary = "Envoyer un message")
@APIResponse(responseCode = "201", description = "Message envoyé")
@APIResponse(responseCode = "400", description = "Données invalides")
@APIResponse(responseCode = "404", description = "Conversation non trouvée")
public Response sendMessage(@Valid SendMessageRequest request) {
UUID senderId = securiteHelper.resolveMembreId();
MessageResponse message = messageService.sendMessage(request, senderId);
return Response.status(Response.Status.CREATED).entity(message).build();
}
/**
* Édite un message
*/
@PUT
@Path("/{id}")
@Operation(summary = "Éditer un message")
@APIResponse(responseCode = "200", description = "Message édité")
@APIResponse(responseCode = "404", description = "Message non trouvé")
public Response editMessage(
@PathParam("id") UUID messageId,
Map<String, String> body
) {
String newContent = body.get("content");
if (newContent == null || newContent.isEmpty()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "content requis"))
.build();
}
UUID senderId = securiteHelper.resolveMembreId();
MessageResponse message = messageService.editMessage(messageId, senderId, newContent);
return Response.ok(message).build();
}
/**
* Supprime un message
*/
@DELETE
@Path("/{id}")
@Operation(summary = "Supprimer un message")
@APIResponse(responseCode = "204", description = "Message supprimé")
@APIResponse(responseCode = "404", description = "Message non trouvé")
public Response deleteMessage(@PathParam("id") UUID messageId) {
UUID senderId = securiteHelper.resolveMembreId();
messageService.deleteMessage(messageId, senderId);
return Response.noContent().build();
}
}

View File

@@ -0,0 +1,200 @@
package dev.lions.unionflow.server.resource;
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.service.MessagingService;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.UUID;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
/**
* Resource REST pour la messagerie instantanée.
*
* <p>Endpoints :
* <ul>
* <li>POST /api/messagerie/conversations/directe — démarrer conversation 1-1</li>
* <li>POST /api/messagerie/conversations/role — contacter un rôle officiel</li>
* <li>GET /api/messagerie/conversations — mes conversations</li>
* <li>GET /api/messagerie/conversations/{id} — détail + messages</li>
* <li>DELETE /api/messagerie/conversations/{id} — archiver</li>
* <li>POST /api/messagerie/conversations/{id}/messages — envoyer un message</li>
* <li>GET /api/messagerie/conversations/{id}/messages — historique</li>
* <li>PUT /api/messagerie/conversations/{id}/lire — marquer comme lu</li>
* <li>DELETE /api/messagerie/conversations/{cId}/messages/{mId} — supprimer message</li>
* <li>POST /api/messagerie/blocages — bloquer un membre</li>
* <li>DELETE /api/messagerie/blocages/{membreId} — débloquer</li>
* <li>GET /api/messagerie/blocages — mes blocages</li>
* <li>GET /api/messagerie/politique/{orgId} — politique de communication</li>
* <li>PUT /api/messagerie/politique/{orgId} — mettre à jour (ADMIN)</li>
* </ul>
*
* @author UnionFlow Team
* @version 4.0
* @since 2026-04-13
*/
@Path("/api/messagerie")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE"})
@Tag(name = "Messagerie", description = "Messagerie instantanée — conversations, messages, notes vocales")
public class MessagingResource {
private static final Logger LOG = Logger.getLogger(MessagingResource.class);
@Inject
MessagingService messagingService;
// ── Conversations ─────────────────────────────────────────────────────────
@POST
@Path("/conversations/directe")
public Response demarrerConversationDirecte(@Valid DemarrerConversationDirecteRequest request) {
LOG.infof("POST /api/messagerie/conversations/directe → destinataire: %s", request.destinataireId());
ConversationResponse result = messagingService.demarrerConversationDirecte(request);
return Response.status(Response.Status.CREATED).entity(result).build();
}
@POST
@Path("/conversations/role")
public Response demarrerConversationRole(@Valid DemarrerConversationRoleRequest request) {
LOG.infof("POST /api/messagerie/conversations/role → rôle: %s, org: %s",
request.roleCible(), request.organisationId());
ConversationResponse result = messagingService.demarrerConversationRole(request);
return Response.status(Response.Status.CREATED).entity(result).build();
}
@GET
@Path("/conversations")
public Response getMesConversations() {
LOG.debug("GET /api/messagerie/conversations");
List<ConversationSummaryResponse> result = messagingService.getMesConversations();
return Response.ok(result).build();
}
@GET
@Path("/conversations/{id}")
public Response getConversation(@PathParam("id") UUID id) {
LOG.infof("GET /api/messagerie/conversations/%s", id);
ConversationResponse result = messagingService.getConversation(id);
return Response.ok(result).build();
}
@DELETE
@Path("/conversations/{id}")
public Response archiverConversation(@PathParam("id") UUID id) {
LOG.infof("DELETE /api/messagerie/conversations/%s", id);
ConversationResponse result = messagingService.archiverConversation(id);
return Response.ok(result).build();
}
// ── Messages ──────────────────────────────────────────────────────────────
@POST
@Path("/conversations/{id}/messages")
public Response envoyerMessage(
@PathParam("id") UUID conversationId,
@Valid EnvoyerMessageRequest request) {
LOG.infof("POST /api/messagerie/conversations/%s/messages", conversationId);
MessageResponse result = messagingService.envoyerMessage(conversationId, request);
return Response.status(Response.Status.CREATED).entity(result).build();
}
@GET
@Path("/conversations/{id}/messages")
public Response getMessages(
@PathParam("id") UUID conversationId,
@QueryParam("page") @DefaultValue("0") int page) {
LOG.infof("GET /api/messagerie/conversations/%s/messages?page=%d", conversationId, page);
List<MessageResponse> result = messagingService.getMessages(conversationId, page);
return Response.ok(result).build();
}
@PUT
@Path("/conversations/{id}/lire")
public Response marquerLu(@PathParam("id") UUID conversationId) {
LOG.infof("PUT /api/messagerie/conversations/%s/lire", conversationId);
messagingService.marquerConversationLue(conversationId);
return Response.noContent().build();
}
@DELETE
@Path("/conversations/{conversationId}/messages/{messageId}")
public Response supprimerMessage(
@PathParam("conversationId") UUID conversationId,
@PathParam("messageId") UUID messageId) {
LOG.infof("DELETE /api/messagerie/conversations/%s/messages/%s", conversationId, messageId);
messagingService.supprimerMessage(conversationId, messageId);
return Response.noContent().build();
}
// ── Blocages ──────────────────────────────────────────────────────────────
@POST
@Path("/blocages")
public Response bloquerMembre(@Valid BloquerMembreRequest request) {
LOG.infof("POST /api/messagerie/blocages → bloquer: %s", request.membreABloquerId());
messagingService.bloquerMembre(request);
return Response.noContent().build();
}
@DELETE
@Path("/blocages/{membreId}")
public Response debloquerMembre(
@PathParam("membreId") UUID membreId,
@QueryParam("organisationId") UUID organisationId) {
LOG.infof("DELETE /api/messagerie/blocages/%s", membreId);
messagingService.debloquerMembre(membreId, organisationId);
return Response.noContent().build();
}
@GET
@Path("/blocages")
public Response getMesBlocages() {
LOG.debug("GET /api/messagerie/blocages");
return Response.ok(messagingService.getMesBlocages()).build();
}
// ── Politique de communication ────────────────────────────────────────────
@GET
@Path("/politique/{organisationId}")
public Response getPolitique(@PathParam("organisationId") UUID organisationId) {
LOG.infof("GET /api/messagerie/politique/%s", organisationId);
ContactPolicyResponse result = messagingService.getPolitique(organisationId);
return Response.ok(result).build();
}
@PUT
@Path("/politique/{organisationId}")
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION"})
public Response mettreAJourPolitique(
@PathParam("organisationId") UUID organisationId,
@Valid MettreAJourPolitiqueRequest request) {
LOG.infof("PUT /api/messagerie/politique/%s", organisationId);
ContactPolicyResponse result = messagingService.mettreAJourPolitique(organisationId, request);
return Response.ok(result).build();
}
}

View File

@@ -1,208 +0,0 @@
package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.api.dto.communication.request.CreateConversationRequest;
import dev.lions.unionflow.server.api.dto.communication.response.ConversationResponse;
import dev.lions.unionflow.server.api.dto.communication.response.MessageResponse;
import dev.lions.unionflow.server.entity.Conversation;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Message;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.repository.ConversationRepository;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.MessageRepository;
import dev.lions.unionflow.server.repository.OrganisationRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.NotFoundException;
import org.jboss.logging.Logger;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Service de gestion des conversations
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-16
*/
@ApplicationScoped
public class ConversationService {
private static final Logger LOG = Logger.getLogger(ConversationService.class);
@Inject
ConversationRepository conversationRepository;
@Inject
MessageRepository messageRepository;
@Inject
MembreRepository membreRepository;
@Inject
OrganisationRepository organisationRepository;
/**
* Liste les conversations d'un membre
*/
public List<ConversationResponse> getConversations(UUID membreId, UUID organisationId, boolean includeArchived) {
LOG.infof("Récupération conversations pour membre %s", membreId);
List<Conversation> conversations;
if (organisationId != null) {
conversations = conversationRepository.findByOrganisation(organisationId);
} else {
conversations = conversationRepository.findByParticipant(membreId, includeArchived);
}
return conversations.stream()
.map(c -> convertToResponse(c, membreId))
.collect(Collectors.toList());
}
/**
* Récupère une conversation par ID
*/
public ConversationResponse getConversationById(UUID conversationId, UUID membreId) {
Conversation conversation = conversationRepository.findByIdAndParticipant(conversationId, membreId)
.orElseThrow(() -> new NotFoundException("Conversation non trouvée ou accès refusé"));
return convertToResponse(conversation, membreId);
}
/**
* Crée une nouvelle conversation
*/
@Transactional
public ConversationResponse createConversation(CreateConversationRequest request, UUID creatorId) {
LOG.infof("Création conversation: %s (type: %s)", request.name(), request.type());
Conversation conversation = new Conversation();
conversation.setName(request.name());
conversation.setDescription(request.description());
conversation.setType(request.type());
// Ajouter les participants
List<Membre> participants = request.participantIds().stream()
.map(id -> membreRepository.findById(id))
.filter(membre -> membre != null)
.collect(Collectors.toList());
// Ajouter le créateur s'il n'est pas dans la liste
Membre creator = membreRepository.findById(creatorId);
if (creator != null && !participants.contains(creator)) {
participants.add(creator);
}
conversation.setParticipants(participants);
// Organisation
if (request.organisationId() != null) {
Organisation org = organisationRepository.findById(request.organisationId());
conversation.setOrganisation(org);
}
conversation.setUpdatedAt(LocalDateTime.now());
conversationRepository.persist(conversation);
return convertToResponse(conversation, creatorId);
}
/**
* Archive/désarchive une conversation
*/
@Transactional
public void archiveConversation(UUID conversationId, UUID membreId, boolean archive) {
Conversation conversation = conversationRepository.findByIdAndParticipant(conversationId, membreId)
.orElseThrow(() -> new NotFoundException("Conversation non trouvée"));
conversation.setIsArchived(archive);
conversation.setUpdatedAt(LocalDateTime.now());
conversationRepository.persist(conversation);
}
/**
* Marque une conversation comme lue
*/
@Transactional
public void markAsRead(UUID conversationId, UUID membreId) {
// Vérifier accès
conversationRepository.findByIdAndParticipant(conversationId, membreId)
.orElseThrow(() -> new NotFoundException("Conversation non trouvée"));
messageRepository.markAllAsReadByConversationAndMember(conversationId, membreId);
}
/**
* Toggle mute
*/
@Transactional
public void toggleMute(UUID conversationId, UUID membreId) {
Conversation conversation = conversationRepository.findByIdAndParticipant(conversationId, membreId)
.orElseThrow(() -> new NotFoundException("Conversation non trouvée"));
conversation.setIsMuted(!conversation.getIsMuted());
conversation.setUpdatedAt(LocalDateTime.now());
}
/**
* Toggle pin
*/
@Transactional
public void togglePin(UUID conversationId, UUID membreId) {
Conversation conversation = conversationRepository.findByIdAndParticipant(conversationId, membreId)
.orElseThrow(() -> new NotFoundException("Conversation non trouvée"));
conversation.setIsPinned(!conversation.getIsPinned());
conversation.setUpdatedAt(LocalDateTime.now());
}
/**
* Convertit Conversation en DTO
*/
private ConversationResponse convertToResponse(Conversation c, UUID currentUserId) {
Message lastMsg = messageRepository.findLastByConversation(c.getId());
long unreadCount = messageRepository.countUnreadByConversationAndMember(c.getId(), currentUserId);
return ConversationResponse.builder()
.id(c.getId())
.name(c.getName())
.description(c.getDescription())
.type(c.getType())
.participantIds(c.getParticipants().stream().map(Membre::getId).collect(Collectors.toList()))
.organisationId(c.getOrganisation() != null ? c.getOrganisation().getId() : null)
.lastMessage(lastMsg != null ? convertMessageToResponse(lastMsg) : null)
.unreadCount((int) unreadCount)
.muted(Boolean.TRUE.equals(c.getIsMuted()))
.pinned(Boolean.TRUE.equals(c.getIsPinned()))
.archived(Boolean.TRUE.equals(c.getIsArchived()))
.createdAt(c.getDateCreation())
.updatedAt(c.getUpdatedAt())
.avatarUrl(c.getAvatarUrl())
.build();
}
/**
* Convertit Message en DTO simple
*/
private MessageResponse convertMessageToResponse(Message m) {
return MessageResponse.builder()
.id(m.getId())
.conversationId(m.getConversation().getId())
.senderId(m.getSender().getId())
.senderName(m.getSenderName())
.senderAvatar(m.getSenderAvatar())
.content(m.getContent())
.type(m.getType())
.status(m.getStatus())
.priority(m.getPriority())
.createdAt(m.getDateCreation())
.edited(Boolean.TRUE.equals(m.getIsEdited()))
.deleted(Boolean.TRUE.equals(m.getIsDeleted()))
.build();
}
}

View File

@@ -1,203 +0,0 @@
package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.api.dto.communication.request.SendMessageRequest;
import dev.lions.unionflow.server.api.dto.communication.response.MessageResponse;
import dev.lions.unionflow.server.api.enums.communication.MessagePriority;
import dev.lions.unionflow.server.api.enums.communication.MessageStatus;
import dev.lions.unionflow.server.api.enums.communication.MessageType;
import dev.lions.unionflow.server.entity.Conversation;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Message;
import dev.lions.unionflow.server.repository.ConversationRepository;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.MessageRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.NotFoundException;
import org.jboss.logging.Logger;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Service de gestion des messages
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-16
*/
@ApplicationScoped
public class MessageService {
private static final Logger LOG = Logger.getLogger(MessageService.class);
@Inject
MessageRepository messageRepository;
@Inject
ConversationRepository conversationRepository;
@Inject
MembreRepository membreRepository;
/**
* Récupère les messages d'une conversation
*/
public List<MessageResponse> getMessages(UUID conversationId, UUID membreId, int limit) {
LOG.infof("Récupération messages pour conversation %s (limit: %d)", conversationId, limit);
// Vérifier accès
conversationRepository.findByIdAndParticipant(conversationId, membreId)
.orElseThrow(() -> new NotFoundException("Conversation non trouvée ou accès refusé"));
List<Message> messages = messageRepository.findByConversation(conversationId, limit);
return messages.stream()
.map(this::convertToResponse)
.collect(Collectors.toList());
}
/**
* Envoie un message
*/
@Transactional
public MessageResponse sendMessage(SendMessageRequest request, UUID senderId) {
LOG.infof("Envoi message dans conversation %s", request.conversationId());
// Vérifier accès conversation
Conversation conversation = conversationRepository.findByIdAndParticipant(request.conversationId(), senderId)
.orElseThrow(() -> new NotFoundException("Conversation non trouvée ou accès refusé"));
Membre sender = membreRepository.findById(senderId);
if (sender == null) {
throw new NotFoundException("Expéditeur non trouvé");
}
Message message = new Message();
message.setConversation(conversation);
message.setSender(sender);
message.setSenderName(sender.getPrenom() + " " + sender.getNom());
message.setSenderAvatar(sender.getPhotoUrl());
message.setContent(request.content());
message.setType(request.type() != null ? request.type() : MessageType.INDIVIDUAL);
message.setStatus(MessageStatus.SENT);
message.setPriority(request.priority() != null ? request.priority() : MessagePriority.NORMAL);
// Destinataires (pour targeted messages)
if (request.recipientIds() != null && !request.recipientIds().isEmpty()) {
message.setRecipientIds(request.recipientIds().stream()
.map(UUID::toString)
.collect(Collectors.joining(",")));
}
// Rôles destinataires
if (request.recipientRoles() != null && !request.recipientRoles().isEmpty()) {
message.setRecipientRoles(String.join(",", request.recipientRoles()));
}
// Pièces jointes
if (request.attachments() != null && !request.attachments().isEmpty()) {
message.setAttachments(String.join(",", request.attachments()));
}
message.setOrganisation(conversation.getOrganisation());
messageRepository.persist(message);
// Mettre à jour la conversation
conversation.setUpdatedAt(LocalDateTime.now());
conversationRepository.persist(conversation);
LOG.infof("Message %s créé avec succès", message.getId());
return convertToResponse(message);
}
/**
* Édite un message
*/
@Transactional
public MessageResponse editMessage(UUID messageId, UUID senderId, String newContent) {
Message message = messageRepository.findById(messageId);
if (message == null) {
throw new NotFoundException("Message non trouvé");
}
if (!message.getSender().getId().equals(senderId)) {
throw new IllegalStateException("Vous ne pouvez éditer que vos propres messages");
}
message.setContent(newContent);
message.markAsEdited();
messageRepository.persist(message);
return convertToResponse(message);
}
/**
* Supprime un message (soft delete)
*/
@Transactional
public void deleteMessage(UUID messageId, UUID senderId) {
Message message = messageRepository.findById(messageId);
if (message == null) {
throw new NotFoundException("Message non trouvé");
}
if (!message.getSender().getId().equals(senderId)) {
throw new IllegalStateException("Vous ne pouvez supprimer que vos propres messages");
}
message.setIsDeleted(true);
message.setContent("[Message supprimé]");
messageRepository.persist(message);
}
/**
* Convertit Message en DTO
*/
private MessageResponse convertToResponse(Message m) {
// Parser recipient IDs
List<UUID> recipientIds = null;
if (m.getRecipientIds() != null && !m.getRecipientIds().isEmpty()) {
recipientIds = List.of(m.getRecipientIds().split(",")).stream()
.map(UUID::fromString)
.collect(Collectors.toList());
}
// Parser roles
List<String> roles = null;
if (m.getRecipientRoles() != null && !m.getRecipientRoles().isEmpty()) {
roles = List.of(m.getRecipientRoles().split(","));
}
// Parser attachments
List<String> attachments = null;
if (m.getAttachments() != null && !m.getAttachments().isEmpty()) {
attachments = List.of(m.getAttachments().split(","));
}
return MessageResponse.builder()
.id(m.getId())
.conversationId(m.getConversation().getId())
.senderId(m.getSender().getId())
.senderName(m.getSenderName())
.senderAvatar(m.getSenderAvatar())
.content(m.getContent())
.type(m.getType())
.status(m.getStatus())
.priority(m.getPriority())
.recipientIds(recipientIds)
.recipientRoles(roles)
.organisationId(m.getOrganisation() != null ? m.getOrganisation().getId() : null)
.createdAt(m.getDateCreation())
.readAt(m.getReadAt())
.attachments(attachments)
.edited(Boolean.TRUE.equals(m.getIsEdited()))
.editedAt(m.getEditedAt())
.deleted(Boolean.TRUE.equals(m.getIsDeleted()))
.build();
}
}

View File

@@ -0,0 +1,653 @@
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.
*
* <p>Gère les conversations (directes et canaux-rôle), les messages (texte,
* vocal, image), les blocages et les politiques de communication.
*
* <p>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<Membre> 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<ConversationSummaryResponse> 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<MessageResponse> 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<MemberBlock> 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<String, Object> 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<Membre> trouverPorteursDuRole(UUID orgId, String role) {
List<MembreOrganisation> 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<MessageResponse> msgs = messageRepository
.findByConversationPagine(conv.getId(), 0, PAGE_SIZE_DEFAULT)
.stream().map(this::toMessageResponse).collect(Collectors.toList());
List<ConversationResponse.ParticipantResponse> 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();
}
}