feat(communication): module messagerie unifié + contact policies + blocages

Aligné avec le backend MessagingResource :
- Nouveau module communication (conversations, messages, participants)
- Respect des ContactPolicy (qui peut parler à qui par rôle)
- Gestion MemberBlock (blocages individuels)
- UI : conversations list, conversation detail, broadcast, tiles
- BLoC : MessagingBloc avec events (envoyer, démarrer conversation rôle, etc.)
This commit is contained in:
dahoud
2026-04-15 20:26:35 +00:00
parent 07b8488714
commit 45dcd2171e
23 changed files with 2096 additions and 1588 deletions

View File

@@ -0,0 +1,35 @@
/// Entité métier ContactPolicy v4
///
/// Correspond au DTO backend : ContactPolicyResponse
library contact_policy;
import 'package:equatable/equatable.dart';
/// Politique de communication d'une organisation
class ContactPolicy extends Equatable {
final String? id;
final String? organisationId;
final String typePolitique; // OUVERT | BUREAU_SEULEMENT | GROUPES_INTERNES
final bool autoriserMembreVersMembre;
final bool autoriserMembreVersRole;
final bool autoriserNotesVocales;
const ContactPolicy({
this.id,
this.organisationId,
required this.typePolitique,
this.autoriserMembreVersMembre = true,
this.autoriserMembreVersRole = true,
this.autoriserNotesVocales = true,
});
bool get isOuvert => typePolitique == 'OUVERT';
bool get isBureauSeulement => typePolitique == 'BUREAU_SEULEMENT';
bool get isGroupesInternes => typePolitique == 'GROUPES_INTERNES';
@override
List<Object?> get props => [
id, organisationId, typePolitique,
autoriserMembreVersMembre, autoriserMembreVersRole, autoriserNotesVocales,
];
}

View File

@@ -1,127 +1,120 @@
/// Entité métier Conversation
/// Entités métier Conversation v4
///
/// Représente une conversation (fil de messages) dans UnionFlow
/// Correspond aux DTOs backend : ConversationSummaryResponse et ConversationResponse
library conversation;
import 'package:equatable/equatable.dart';
import 'message.dart';
/// Type de conversation
enum ConversationType {
/// Conversation individuelle (1-1)
individual,
// ── Résumé de conversation (liste) ───────────────────────────────────────────
/// Conversation de groupe
group,
/// Canal broadcast (lecture seule pour la plupart)
broadcast,
/// Canal d'annonces organisation
announcement,
}
/// Entité Conversation
class Conversation extends Equatable {
/// Résumé d'une conversation pour l'affichage en liste
class ConversationSummary extends Equatable {
final String id;
final String name;
final String? description;
final ConversationType type;
final List<String> participantIds;
final String? organizationId;
final Message? lastMessage;
final int unreadCount;
final bool isMuted;
final bool isPinned;
final bool isArchived;
final DateTime createdAt;
final DateTime? updatedAt;
final String? avatarUrl;
final Map<String, dynamic>? metadata;
final String typeConversation; // DIRECTE | ROLE_CANAL | GROUPE
final String titre;
final String statut; // ACTIVE | ARCHIVEE
final String? dernierMessageApercu;
final String? dernierMessageType;
final DateTime? dernierMessageAt;
final int nonLus;
final String? organisationId;
const Conversation({
const ConversationSummary({
required this.id,
required this.name,
this.description,
required this.type,
required this.participantIds,
this.organizationId,
this.lastMessage,
this.unreadCount = 0,
this.isMuted = false,
this.isPinned = false,
this.isArchived = false,
required this.createdAt,
this.updatedAt,
this.avatarUrl,
this.metadata,
required this.typeConversation,
required this.titre,
required this.statut,
this.dernierMessageApercu,
this.dernierMessageType,
this.dernierMessageAt,
this.nonLus = 0,
this.organisationId,
});
/// Vérifie si la conversation a des messages non lus
bool get hasUnread => unreadCount > 0;
/// Vérifie si c'est une conversation individuelle
bool get isIndividual => type == ConversationType.individual;
/// Vérifie si c'est un broadcast
bool get isBroadcast => type == ConversationType.broadcast;
/// Nombre de participants
int get participantCount => participantIds.length;
/// Copie avec modifications
Conversation copyWith({
String? id,
String? name,
String? description,
ConversationType? type,
List<String>? participantIds,
String? organizationId,
Message? lastMessage,
int? unreadCount,
bool? isMuted,
bool? isPinned,
bool? isArchived,
DateTime? createdAt,
DateTime? updatedAt,
String? avatarUrl,
Map<String, dynamic>? metadata,
}) {
return Conversation(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
type: type ?? this.type,
participantIds: participantIds ?? this.participantIds,
organizationId: organizationId ?? this.organizationId,
lastMessage: lastMessage ?? this.lastMessage,
unreadCount: unreadCount ?? this.unreadCount,
isMuted: isMuted ?? this.isMuted,
isPinned: isPinned ?? this.isPinned,
isArchived: isArchived ?? this.isArchived,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
avatarUrl: avatarUrl ?? this.avatarUrl,
metadata: metadata ?? this.metadata,
);
}
bool get hasUnread => nonLus > 0;
bool get isDirecte => typeConversation == 'DIRECTE';
bool get isRoleCanal => typeConversation == 'ROLE_CANAL';
bool get isGroupe => typeConversation == 'GROUPE';
bool get isActive => statut == 'ACTIVE';
@override
List<Object?> get props => [
id,
name,
description,
type,
participantIds,
organizationId,
lastMessage,
unreadCount,
isMuted,
isPinned,
isArchived,
createdAt,
updatedAt,
avatarUrl,
metadata,
id, typeConversation, titre, statut,
dernierMessageApercu, dernierMessageType, dernierMessageAt,
nonLus, organisationId,
];
}
// ── Participant ───────────────────────────────────────────────────────────────
/// Participant dans une conversation
class ConversationParticipant extends Equatable {
final String membreId;
final String? prenom;
final String? nom;
final String? roleDansConversation;
final DateTime? luJusqua;
const ConversationParticipant({
required this.membreId,
this.prenom,
this.nom,
this.roleDansConversation,
this.luJusqua,
});
String get nomComplet {
if (prenom != null && nom != null) return '$prenom $nom';
if (prenom != null) return prenom!;
if (nom != null) return nom!;
return membreId;
}
@override
List<Object?> get props => [membreId, prenom, nom, roleDansConversation, luJusqua];
}
// ── Conversation complète (détail) ───────────────────────────────────────────
/// Conversation complète avec participants et messages
class Conversation extends Equatable {
final String id;
final String typeConversation;
final String titre;
final String statut;
final String? organisationId;
final String? organisationNom;
final DateTime? dateCreation;
final int nombreMessages;
final List<ConversationParticipant> participants;
final List<Message> messages;
final int nonLus;
final String? roleCible;
const Conversation({
required this.id,
required this.typeConversation,
required this.titre,
required this.statut,
this.organisationId,
this.organisationNom,
this.dateCreation,
this.nombreMessages = 0,
this.participants = const [],
this.messages = const [],
this.nonLus = 0,
this.roleCible,
});
bool get isDirecte => typeConversation == 'DIRECTE';
bool get isRoleCanal => typeConversation == 'ROLE_CANAL';
bool get isGroupe => typeConversation == 'GROUPE';
bool get isActive => statut == 'ACTIVE';
@override
List<Object?> get props => [
id, typeConversation, titre, statut, organisationId, organisationNom,
dateCreation, nombreMessages, participants, messages, nonLus, roleCible,
];
}

View File

@@ -1,173 +1,68 @@
/// Entité métier Message
/// Entité métier Message v4
///
/// Représente un message dans le système de communication UnionFlow
/// Correspond au DTO backend : MessageResponse
library message;
import 'package:equatable/equatable.dart';
/// Type de message
enum MessageType {
/// Message individuel (membre à membre)
individual,
/// Broadcast organisation (OrgAdmin → tous)
broadcast,
/// Message ciblé par rôle (Moderator → groupe)
targeted,
/// Notification système
system,
}
/// Statut de lecture du message
enum MessageStatus {
/// Envoyé mais non lu
sent,
/// Livré (reçu par le serveur)
delivered,
/// Lu par le destinataire
read,
/// Échec d'envoi
failed,
}
/// Priorité du message
enum MessagePriority {
/// Priorité normale
normal,
/// Priorité élevée (important)
high,
/// Priorité urgente (critique)
urgent,
}
/// Entité Message
/// Message dans une conversation
class Message extends Equatable {
final String id;
final String conversationId;
final String senderId;
final String senderName;
final String? senderAvatar;
final String content;
final MessageType type;
final MessageStatus status;
final MessagePriority priority;
final List<String> recipientIds;
final List<String>? recipientRoles;
final String? organizationId;
final DateTime createdAt;
final DateTime? readAt;
final Map<String, dynamic>? metadata;
final List<String>? attachments;
final bool isEdited;
final DateTime? editedAt;
final bool isDeleted;
final String typeMessage; // TEXTE | VOCAL | IMAGE | SYSTEME
final String? contenu;
final String? urlFichier;
final int? dureeAudio;
final bool supprime;
final String? expediteurId;
final String? expediteurNom;
final String? expediteurPrenom;
final String? messageParentId;
final String? messageParentApercu;
final DateTime? dateEnvoi;
const Message({
required this.id,
required this.conversationId,
required this.senderId,
required this.senderName,
this.senderAvatar,
required this.content,
required this.type,
required this.status,
this.priority = MessagePriority.normal,
required this.recipientIds,
this.recipientRoles,
this.organizationId,
required this.createdAt,
this.readAt,
this.metadata,
this.attachments,
this.isEdited = false,
this.editedAt,
this.isDeleted = false,
required this.typeMessage,
this.contenu,
this.urlFichier,
this.dureeAudio,
this.supprime = false,
this.expediteurId,
this.expediteurNom,
this.expediteurPrenom,
this.messageParentId,
this.messageParentApercu,
this.dateEnvoi,
});
/// Vérifie si le message a été lu
bool get isRead => status == MessageStatus.read;
String get expediteurNomComplet {
if (expediteurPrenom != null && expediteurNom != null) {
return '$expediteurPrenom $expediteurNom';
}
if (expediteurPrenom != null) return expediteurPrenom!;
if (expediteurNom != null) return expediteurNom!;
return '';
}
/// Vérifie si le message est urgent
bool get isUrgent => priority == MessagePriority.urgent;
bool get isTexte => typeMessage == 'TEXTE';
bool get isVocal => typeMessage == 'VOCAL';
bool get isImage => typeMessage == 'IMAGE';
bool get isSysteme => typeMessage == 'SYSTEME';
bool get hasParent => messageParentId != null;
/// Vérifie si le message est un broadcast
bool get isBroadcast => type == MessageType.broadcast;
/// Vérifie si le message a des pièces jointes
bool get hasAttachments => attachments != null && attachments!.isNotEmpty;
/// Copie avec modifications
Message copyWith({
String? id,
String? conversationId,
String? senderId,
String? senderName,
String? senderAvatar,
String? content,
MessageType? type,
MessageStatus? status,
MessagePriority? priority,
List<String>? recipientIds,
List<String>? recipientRoles,
String? organizationId,
DateTime? createdAt,
DateTime? readAt,
Map<String, dynamic>? metadata,
List<String>? attachments,
bool? isEdited,
DateTime? editedAt,
bool? isDeleted,
}) {
return Message(
id: id ?? this.id,
conversationId: conversationId ?? this.conversationId,
senderId: senderId ?? this.senderId,
senderName: senderName ?? this.senderName,
senderAvatar: senderAvatar ?? this.senderAvatar,
content: content ?? this.content,
type: type ?? this.type,
status: status ?? this.status,
priority: priority ?? this.priority,
recipientIds: recipientIds ?? this.recipientIds,
recipientRoles: recipientRoles ?? this.recipientRoles,
organizationId: organizationId ?? this.organizationId,
createdAt: createdAt ?? this.createdAt,
readAt: readAt ?? this.readAt,
metadata: metadata ?? this.metadata,
attachments: attachments ?? this.attachments,
isEdited: isEdited ?? this.isEdited,
editedAt: editedAt ?? this.editedAt,
isDeleted: isDeleted ?? this.isDeleted,
);
/// Texte à afficher dans la liste (aperçu)
String get apercu {
if (supprime) return '🚫 Message supprimé';
if (isVocal) return '🎙️ Note vocale${dureeAudio != null ? ' (${dureeAudio}s)' : ''}';
if (isImage) return '📷 Image';
if (isSysteme) return contenu ?? '🔔 Notification système';
return contenu ?? '';
}
@override
List<Object?> get props => [
id,
conversationId,
senderId,
senderName,
senderAvatar,
content,
type,
status,
priority,
recipientIds,
recipientRoles,
organizationId,
createdAt,
readAt,
metadata,
attachments,
isEdited,
editedAt,
isDeleted,
id, typeMessage, contenu, urlFichier, dureeAudio, supprime,
expediteurId, expediteurNom, expediteurPrenom,
messageParentId, messageParentApercu, dateEnvoi,
];
}

View File

@@ -1,145 +1,87 @@
/// Repository interface pour la communication
/// Repository interface pour la messagerie v4
///
/// Contrat de données pour les messages, conversations et templates
/// Contrat de données aligné sur l'API backend v4 (/api/messagerie/*)
library messaging_repository;
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/message.dart';
import '../entities/conversation.dart';
import '../entities/message_template.dart';
import '../entities/message.dart';
import '../entities/contact_policy.dart';
/// Interface du repository de messagerie
abstract class MessagingRepository {
// === CONVERSATIONS ===
/// Récupère toutes les conversations de l'utilisateur
Future<Either<Failure, List<Conversation>>> getConversations({
String? organizationId,
bool includeArchived = false,
// ── Conversations ─────────────────────────────────────────────────────────
/// Récupère les conversations résumées de l'utilisateur connecté
Future<List<ConversationSummary>> getMesConversations();
/// Récupère la conversation complète (avec participants et messages)
Future<Conversation> getConversation(String conversationId);
/// Démarre une conversation directe avec un membre
Future<Conversation> demarrerConversationDirecte({
required String destinataireId,
required String organisationId,
String? premierMessage,
});
/// Récupère une conversation par son ID
Future<Either<Failure, Conversation>> getConversationById(String conversationId);
/// Crée une nouvelle conversation
Future<Either<Failure, Conversation>> createConversation({
required String name,
required List<String> participantIds,
String? organizationId,
String? description,
/// Démarre un canal de communication avec un rôle officiel
Future<Conversation> demarrerConversationRole({
required String roleCible,
required String organisationId,
String? premierMessage,
});
/// Archive une conversation
Future<Either<Failure, void>> archiveConversation(String conversationId);
Future<void> archiverConversation(String conversationId);
/// Marque une conversation comme lue
Future<Either<Failure, void>> markConversationAsRead(String conversationId);
// ── Messages ──────────────────────────────────────────────────────────────
/// Mute/démute une conversation
Future<Either<Failure, void>> toggleMuteConversation(String conversationId);
/// Pin/unpin une conversation
Future<Either<Failure, void>> togglePinConversation(String conversationId);
// === MESSAGES ===
/// Récupère les messages d'une conversation
Future<Either<Failure, List<Message>>> getMessages({
required String conversationId,
int? limit,
String? beforeMessageId,
/// Envoie un message dans une conversation
Future<Message> envoyerMessage(
String conversationId, {
required String typeMessage,
String? contenu,
String? urlFichier,
int? dureeAudio,
String? messageParentId,
});
/// Envoie un message individuel
Future<Either<Failure, Message>> sendMessage({
required String conversationId,
required String content,
List<String>? attachments,
MessagePriority priority = MessagePriority.normal,
/// Récupère l'historique des messages (paginé)
Future<List<Message>> getMessages(String conversationId, {int page = 0});
/// Marque tous les messages d'une conversation comme lus
Future<void> marquerLu(String conversationId);
/// Supprime un message (soft-delete)
Future<void> supprimerMessage(String conversationId, String messageId);
// ── Blocages ──────────────────────────────────────────────────────────────
/// Bloque un membre
Future<void> bloquerMembre({
required String membreABloquerId,
String? organisationId,
String? raison,
});
/// Envoie un broadcast à toute l'organisation
Future<Either<Failure, Message>> sendBroadcast({
required String organizationId,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
List<String>? attachments,
});
/// Débloque un membre
Future<void> debloquerMembre(String membreId, {String? organisationId});
/// Envoie un message ciblé par rôles
Future<Either<Failure, Message>> sendTargetedMessage({
required String organizationId,
required List<String> targetRoles,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
});
/// Récupère la liste des membres bloqués
Future<List<Map<String, dynamic>>> getMesBlocages();
/// Marque un message comme lu
Future<Either<Failure, void>> markMessageAsRead(String messageId);
// ── Politique de communication ────────────────────────────────────────────
/// Édite un message
Future<Either<Failure, Message>> editMessage({
required String messageId,
required String newContent,
});
/// Récupère la politique de communication d'une organisation
Future<ContactPolicy> getPolitique(String organisationId);
/// Supprime un message
Future<Either<Failure, void>> deleteMessage(String messageId);
// === TEMPLATES ===
/// Récupère tous les templates disponibles
Future<Either<Failure, List<MessageTemplate>>> getTemplates({
String? organizationId,
TemplateCategory? category,
});
/// Récupère un template par son ID
Future<Either<Failure, MessageTemplate>> getTemplateById(String templateId);
/// Crée un nouveau template
Future<Either<Failure, MessageTemplate>> createTemplate({
required String name,
required String description,
required TemplateCategory category,
required String subject,
required String body,
List<Map<String, dynamic>>? variables,
String? organizationId,
});
/// Met à jour un template
Future<Either<Failure, MessageTemplate>> updateTemplate({
required String templateId,
String? name,
String? description,
String? subject,
String? body,
bool? isActive,
});
/// Supprime un template
Future<Either<Failure, void>> deleteTemplate(String templateId);
/// Envoie un message à partir d'un template
Future<Either<Failure, Message>> sendFromTemplate({
required String templateId,
required Map<String, String> variables,
required List<String> recipientIds,
});
// === STATISTIQUES ===
/// Récupère le nombre de messages non lus
Future<Either<Failure, int>> getUnreadCount({String? organizationId});
/// Récupère les statistiques de communication
Future<Either<Failure, Map<String, dynamic>>> getMessagingStats({
required String organizationId,
DateTime? startDate,
DateTime? endDate,
/// Met à jour la politique de communication (ADMIN seulement)
Future<ContactPolicy> mettreAJourPolitique(
String organisationId, {
required String typePolitique,
required bool autoriserMembreVersMembre,
required bool autoriserMembreVersRole,
required bool autoriserNotesVocales,
});
}

View File

@@ -1,9 +1,8 @@
/// Use case: Récupérer les conversations
/// Use case: Récupérer les conversations v4
library get_conversations;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/conversation.dart';
import '../repositories/messaging_repository.dart';
@@ -13,13 +12,7 @@ class GetConversations {
GetConversations(this.repository);
Future<Either<Failure, List<Conversation>>> call({
String? organizationId,
bool includeArchived = false,
}) async {
return await repository.getConversations(
organizationId: organizationId,
includeArchived: includeArchived,
);
Future<List<ConversationSummary>> call() async {
return await repository.getMesConversations();
}
}

View File

@@ -1,9 +1,8 @@
/// Use case: Récupérer les messages d'une conversation
/// Use case: Récupérer les messages d'une conversation v4
library get_messages;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/message.dart';
import '../repositories/messaging_repository.dart';
@@ -13,19 +12,10 @@ class GetMessages {
GetMessages(this.repository);
Future<Either<Failure, List<Message>>> call({
Future<List<Message>> call({
required String conversationId,
int? limit,
String? beforeMessageId,
int page = 0,
}) async {
if (conversationId.isEmpty) {
return Left(ValidationFailure('ID conversation requis'));
}
return await repository.getMessages(
conversationId: conversationId,
limit: limit,
beforeMessageId: beforeMessageId,
);
return await repository.getMessages(conversationId, page: page);
}
}

View File

@@ -1,10 +1,10 @@
/// Use case: Envoyer un broadcast organisation
/// Use case: Broadcast — non utilisé en v4 (remplacé par canal rôle)
///
/// Conservé pour la compatibilité du graphe de dépendances.
library send_broadcast;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/message.dart';
import '../repositories/messaging_repository.dart';
@lazySingleton
@@ -13,32 +13,13 @@ class SendBroadcast {
SendBroadcast(this.repository);
Future<Either<Failure, Message>> call({
required String organizationId,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
List<String>? attachments,
/// Démarre un canal de communication avec le rôle BUREAU
Future<void> call({
required String organisationId,
}) async {
// Validation
if (subject.trim().isEmpty) {
return Left(ValidationFailure('Le sujet ne peut pas être vide'));
}
if (content.trim().isEmpty) {
return Left(ValidationFailure('Le message ne peut pas être vide'));
}
if (organizationId.isEmpty) {
return Left(ValidationFailure('ID organisation requis'));
}
return await repository.sendBroadcast(
organizationId: organizationId,
subject: subject,
content: content,
priority: priority,
attachments: attachments,
await repository.demarrerConversationRole(
roleCible: 'PRESIDENT',
organisationId: organisationId,
);
}
}

View File

@@ -1,9 +1,8 @@
/// Use case: Envoyer un message
/// Use case: Envoyer un message v4
library send_message;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/message.dart';
import '../repositories/messaging_repository.dart';
@@ -13,22 +12,15 @@ class SendMessage {
SendMessage(this.repository);
Future<Either<Failure, Message>> call({
Future<Message> call({
required String conversationId,
required String content,
List<String>? attachments,
MessagePriority priority = MessagePriority.normal,
required String contenu,
String typeMessage = 'TEXTE',
}) async {
// Validation
if (content.trim().isEmpty) {
return Left(ValidationFailure('Le message ne peut pas être vide'));
}
return await repository.sendMessage(
conversationId: conversationId,
content: content,
attachments: attachments,
priority: priority,
return await repository.envoyerMessage(
conversationId,
typeMessage: typeMessage,
contenu: contenu,
);
}
}