feat(backend): implémentation complète du système de messagerie

Ajoute l'infrastructure backend complète pour les conversations et messages :

## Entités
- Conversation : conversations individuelles, groupes, broadcast, annonces
- Message : messages avec statut, priorité, pièces jointes, soft delete

## Repositories
- ConversationRepository : findByParticipant, findByIdAndParticipant (sécurité)
- MessageRepository : findByConversation, countUnread, markAsRead

## Services
- ConversationService : CRUD conversations, archive, mute, pin
- MessageService : send, edit, delete, getMessages

## REST Endpoints (12 total)
- GET /api/conversations - Lister mes conversations
- GET /api/conversations/{id} - Récupérer une conversation
- POST /api/conversations - Créer conversation
- PUT /api/conversations/{id}/archive - Archiver
- PUT /api/conversations/{id}/mark-read - Marquer comme lu
- PUT /api/conversations/{id}/toggle-mute - Activer/désactiver son
- PUT /api/conversations/{id}/toggle-pin - Épingler
- GET /api/messages?conversationId=X - Lister messages
- POST /api/messages - Envoyer message
- PUT /api/messages/{id} - Éditer message
- DELETE /api/messages/{id} - Supprimer message

## Database
- Migration V6 : tables conversations, messages, conversation_participants
- Indexes sur organisation, type, archived, deleted pour performance

## Sécurité
- SecuriteHelper.resolveMembreId() : résolution membre depuis JWT
- Vérification accès conversation avant toute opération
- @RolesAllowed sur tous les endpoints

Débloquer la fonctionnalité Communication mobile (actuellement 100% stubs).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
dahoud
2026-03-16 06:39:39 +00:00
parent f5271cc29e
commit 3be01e28a7
10 changed files with 1226 additions and 0 deletions

View File

@@ -0,0 +1,119 @@
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 java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Entité Conversation pour le système de messagerie UnionFlow.
* Représente un fil de discussion entre membres.
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-16
*/
@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
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)
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id")
private Organisation organisation;
/**
* URL de l'avatar de la conversation
*/
@Column(name = "avatar_url", length = 500)
private String avatarUrl;
/**
* Conversation muette
*/
@Column(name = "is_muted", nullable = false)
private Boolean isMuted = false;
/**
* Conversation épinglée
*/
@Column(name = "is_pinned", nullable = false)
private Boolean isPinned = false;
/**
* Conversation archivée
*/
@Column(name = "is_archived", nullable = false)
private Boolean isArchived = false;
/**
* Métadonnées additionnelles (JSON)
*/
@Column(name = "metadata", columnDefinition = "TEXT")
private String metadata;
/**
* Date de dernière mise à jour
*/
@Column(name = "updated_at")
private LocalDateTime updatedAt;
/**
* 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<>();
/**
* Messages de la conversation (one-to-many)
*/
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Message> messages = new ArrayList<>();
/**
* Met à jour le timestamp
*/
@PreUpdate
protected void onUpdate() {
this.updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,156 @@
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 java.time.LocalDateTime;
/**
* Entité Message pour le système de messagerie UnionFlow.
* Représente un message individuel dans une conversation.
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-16
*/
@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
public class Message extends BaseEntity {
/**
* Conversation parente
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "conversation_id", nullable = false)
private Conversation conversation;
/**
* Expéditeur du message
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "sender_id", nullable = false)
private Membre sender;
/**
* 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;
/**
* Statut du message (SENT, DELIVERED, READ, FAILED)
*/
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private MessageStatus status;
/**
* Priorité du message (NORMAL, HIGH, URGENT)
*/
@Enumerated(EnumType.STRING)
@Column(name = "priority", nullable = false, length = 20)
private MessagePriority priority = MessagePriority.NORMAL;
/**
* 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)
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id")
private Organisation organisation;
/**
* Date de lecture du message
*/
@Column(name = "read_at")
private LocalDateTime readAt;
/**
* Métadonnées additionnelles (JSON)
*/
@Column(name = "metadata", columnDefinition = "TEXT")
private String metadata;
/**
* Pièces jointes (CSV URLs)
*/
@Column(name = "attachments", length = 2000)
private String attachments;
/**
* Message édité
*/
@Column(name = "is_edited", nullable = false)
private Boolean isEdited = false;
/**
* Date d'édition
*/
@Column(name = "edited_at")
private LocalDateTime editedAt;
/**
* 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();
}
/**
* Marque le message comme édité
*/
public void markAsEdited() {
this.isEdited = true;
this.editedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,72 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.Conversation;
import dev.lions.unionflow.server.entity.Membre;
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
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-16
*/
@ApplicationScoped
public class ConversationRepository implements PanacheRepositoryBase<Conversation, UUID> {
/**
* Trouve toutes les conversations d'un membre
*/
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();
}
/**
* Trouve une conversation par ID et vérifie que le membre en fait partie
*/
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();
}
/**
* Trouve les conversations d'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();
}
}

View File

@@ -0,0 +1,66 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.Message;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.UUID;
/**
* Repository pour Message
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-16
*/
@ApplicationScoped
public class MessageRepository implements PanacheRepositoryBase<Message, UUID> {
/**
* Trouve tous les messages d'une conversation (non supprimés)
*/
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();
}
/**
* Compte les messages non lus d'une conversation pour un membre
*/
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
return count(
"conversation.id = ?1 AND sender.id != ?2 AND status IN ('SENT', 'DELIVERED') AND isDeleted = false",
conversationId,
membreId
);
}
/**
* Marque tous les messages d'une conversation comme lus pour un membre
*/
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) {
return find(
"conversation.id = ?1 AND isDeleted = false ORDER BY dateCreation DESC",
conversationId
)
.firstResult();
}
}

View File

@@ -0,0 +1,145 @@
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

@@ -0,0 +1,120 @@
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,208 @@
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)
.isMuted(c.getIsMuted())
.isPinned(c.getIsPinned())
.isArchived(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())
.isEdited(m.getIsEdited())
.isDeleted(m.getIsDeleted())
.build();
}
}

View File

@@ -0,0 +1,203 @@
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)
.isEdited(m.getIsEdited())
.editedAt(m.getEditedAt())
.isDeleted(m.getIsDeleted())
.build();
}
}

View File

@@ -1,11 +1,16 @@
package dev.lions.unionflow.server.service.support;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.repository.MembreRepository;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.NotFoundException;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.jboss.logging.Logger;
import java.util.UUID;
/**
* Helper CDI partagé pour les services sécurisés.
*
@@ -21,6 +26,9 @@ public class SecuriteHelper {
@Inject
SecurityIdentity securityIdentity;
@Inject
MembreRepository membreRepository;
/**
* Résout l'email du principal connecté depuis le JWT.
*
@@ -52,6 +60,26 @@ public class SecuriteHelper {
return (name != null && !name.isBlank()) ? name : null;
}
/**
* Résout l'ID du membre connecté depuis le JWT.
*
* @return UUID du membre
* @throws NotFoundException si le membre n'existe pas
*/
public UUID resolveMembreId() {
String email = resolveEmail();
if (email == null || email.isBlank()) {
throw new NotFoundException("Identité non disponible");
}
Membre membre = membreRepository.findByEmail(email.trim())
.or(() -> membreRepository.findByEmail(email.trim().toLowerCase()))
.filter(m -> m.getActif() == null || m.getActif())
.orElseThrow(() -> new NotFoundException("Membre non trouvé pour l'email: " + email));
return membre.getId();
}
/**
* Délègue la récupération des rôles à l'identité Quarkus.
*/