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;
}
}