From aa4350ffbbb4bec76e833645efa3c195111e22ae Mon Sep 17 00:00:00 2001 From: dahoud <41957584+DahoudG@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:12:55 +0000 Subject: [PATCH] =?UTF-8?q?feat(members):=20desactiverMembre=20cascade=20c?= =?UTF-8?q?ompl=C3=A8te=20(Keycloak,=20Kafka,=20audit,=20mono-admin)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor de MembreService.desactiverMembre en 8 étapes transactionnelles : 1. GARDE-FOU mono-admin : refuse 409 Conflict si le membre est le seul ORGADMIN d'au moins une org (évite l'orphelinage). 2. DB : actif=false + statutCompte='DESACTIVE'. 3. Adhésions actives → SUSPENDU + décrément nombreMembres. 4. MembreRole (ORGADMIN, TRESORIER...) → actif=false, dateFin=today. 5. Notifications pending (EN_ATTENTE, ECHEC_TEMPORAIRE) → ANNULEE. 6. Keycloak (lions-user-manager) : user.enabled=false → login bloqué. 7. Kafka : publishMemberDeactivated(membre) sur unionflow.members.events → consumers peuvent réagir (comptes épargne, inscriptions, approvals, etc.) 8. AuditLog MEMBRE_DESACTIVE : opérateur, timestamp, compteurs (RGPD/compliance). Côté liste : - listerMembres/compterMembres : filtre actif=true par défaut (SuperAdmin). - MembreRepository.findDistinctByOrganisationIdIn : idem pour OrgAdmin. Services ajoutés : - AuditService.logMembreDesactive - KafkaEventProducer.publishMemberDeactivated --- .../server/messaging/KafkaEventProducer.java | 34 ++++ .../server/repository/MembreRepository.java | 11 +- .../server/service/AuditService.java | 27 +++ .../server/service/MembreService.java | 159 +++++++++++++++--- 4 files changed, 209 insertions(+), 22 deletions(-) diff --git a/src/main/java/dev/lions/unionflow/server/messaging/KafkaEventProducer.java b/src/main/java/dev/lions/unionflow/server/messaging/KafkaEventProducer.java index e9e4289..9cdde7e 100644 --- a/src/main/java/dev/lions/unionflow/server/messaging/KafkaEventProducer.java +++ b/src/main/java/dev/lions/unionflow/server/messaging/KafkaEventProducer.java @@ -43,6 +43,9 @@ public class KafkaEventProducer { @Channel("contributions-events-out") Emitter> contributionsEventsEmitter; + @Channel("chat-messages-out") + Emitter> chatMessagesEmitter; + /** * Publie un event d'approbation en attente. */ @@ -116,6 +119,28 @@ public class KafkaEventProducer { publishToChannel(membersEventsEmitter, memberId.toString(), event, "members-events"); } + /** + * Publie un event de désactivation de membre (soft delete). + * Les consommateurs peuvent réagir : bloquer comptes épargne, annuler inscriptions, + * reassigner approvals pending, nettoyer notifications, etc. + */ + public void publishMemberDeactivated(dev.lions.unionflow.server.entity.Membre membre) { + if (membre == null || membre.getId() == null) return; + Map data = new java.util.HashMap<>(); + data.put("membreId", membre.getId().toString()); + data.put("email", membre.getEmail()); + data.put("nomComplet", membre.getNomComplet()); + data.put("numeroMembre", membre.getNumeroMembre()); + // organisationId principal (si présent) pour routage par org + String orgId = membre.getMembresOrganisations() != null + && !membre.getMembresOrganisations().isEmpty() + && membre.getMembresOrganisations().get(0).getOrganisation() != null + ? membre.getMembresOrganisations().get(0).getOrganisation().getId().toString() + : ""; + var event = buildEvent("MEMBER_DEACTIVATED", orgId, data); + publishToChannel(membersEventsEmitter, membre.getId().toString(), event, "members-events"); + } + /** * Publie un event de cotisation payée. */ @@ -124,6 +149,15 @@ public class KafkaEventProducer { publishToChannel(contributionsEventsEmitter, contributionId.toString(), event, "contributions-events"); } + /** + * Publie un event de nouveau message de chat. + * Les clients WebSocket de l'organisation sont notifiés pour rafraîchir leurs messages. + */ + public void publishNouveauMessage(UUID conversationId, String organizationId, Map messageData) { + var event = buildEvent("NOUVEAU_MESSAGE", organizationId, messageData); + publishToChannel(chatMessagesEmitter, conversationId.toString(), event, "chat-messages"); + } + /** * Construit un event avec structure standardisée. */ diff --git a/src/main/java/dev/lions/unionflow/server/repository/MembreRepository.java b/src/main/java/dev/lions/unionflow/server/repository/MembreRepository.java index 825cebd..9d2ea87 100644 --- a/src/main/java/dev/lions/unionflow/server/repository/MembreRepository.java +++ b/src/main/java/dev/lions/unionflow/server/repository/MembreRepository.java @@ -87,6 +87,7 @@ public class MembreRepository extends BaseRepository { /** * Trouve les membres appartenant à au moins une des organisations données (pour admin d'organisation). + * Filtre les membres désactivés (actif=false) pour ne pas polluer les listes UI. */ public List findDistinctByOrganisationIdIn(Set organisationIds, Page page, Sort sort) { if (organisationIds == null || organisationIds.isEmpty()) { @@ -94,7 +95,9 @@ public class MembreRepository extends BaseRepository { } String orderBy = sort != null ? " ORDER BY " + buildOrderBy(sort) : ""; TypedQuery query = entityManager.createQuery( - "SELECT DISTINCT m FROM Membre m JOIN m.membresOrganisations mo WHERE mo.organisation.id IN :organisationIds" + "SELECT DISTINCT m FROM Membre m JOIN m.membresOrganisations mo " + + "WHERE mo.organisation.id IN :organisationIds " + + "AND (m.actif = true OR m.actif IS NULL)" + orderBy, Membre.class); query.setParameter("organisationIds", organisationIds); @@ -103,13 +106,15 @@ public class MembreRepository extends BaseRepository { return query.getResultList(); } - /** Compte les membres distincts appartenant à au moins une des organisations données. */ + /** Compte les membres distincts appartenant à au moins une des organisations données (filtre actif=true). */ public long countDistinctByOrganisationIdIn(Set organisationIds) { if (organisationIds == null || organisationIds.isEmpty()) { return 0L; } TypedQuery query = entityManager.createQuery( - "SELECT COUNT(DISTINCT m) FROM Membre m JOIN m.membresOrganisations mo WHERE mo.organisation.id IN :organisationIds", + "SELECT COUNT(DISTINCT m) FROM Membre m JOIN m.membresOrganisations mo " + + "WHERE mo.organisation.id IN :organisationIds " + + "AND (m.actif = true OR m.actif IS NULL)", Long.class); query.setParameter("organisationIds", organisationIds); return query.getSingleResult(); diff --git a/src/main/java/dev/lions/unionflow/server/service/AuditService.java b/src/main/java/dev/lions/unionflow/server/service/AuditService.java index 0eac088..6c3237e 100644 --- a/src/main/java/dev/lions/unionflow/server/service/AuditService.java +++ b/src/main/java/dev/lions/unionflow/server/service/AuditService.java @@ -35,6 +35,33 @@ public class AuditService { @Inject OrganisationRepository organisationRepository; + /** + * Enregistre un audit de désactivation de membre (soft delete). + * Portée GLOBALE (trace centrale de toute action admin sur un compte). + */ + @Transactional + public void logMembreDesactive(UUID membreId, String membreEmail, String adminOperateur, + int nbAdhesionsSuspendues, int nbRolesDesactives) { + AuditLog auditLog = new AuditLog(); + auditLog.setTypeAction("MEMBRE_DESACTIVE"); + auditLog.setSeverite("WARN"); + auditLog.setUtilisateur(adminOperateur != null ? adminOperateur : "system"); + auditLog.setModule("MEMBRES"); + auditLog.setDescription("Désactivation (soft delete) d'un compte membre"); + auditLog.setDetails(String.format( + "membreId=%s, email=%s, adhesionsSuspendues=%d, rolesDesactives=%d", + membreId, membreEmail != null ? membreEmail : "", nbAdhesionsSuspendues, nbRolesDesactives)); + auditLog.setEntiteType("Membre"); + auditLog.setEntiteId(membreId != null ? membreId.toString() : null); + auditLog.setDateHeure(LocalDateTime.now()); + auditLog.setPortee(PorteeAudit.GLOBAL); + try { + auditLogRepository.persist(auditLog); + } catch (Exception e) { + log.warn("Persist audit MEMBRE_DESACTIVE échoué pour membreId={} : {}", membreId, e.getMessage()); + } + } + /** * Enregistre un log d'audit LCB-FT lorsqu'une transaction épargne dépasse le seuil. * Portée ORGANISATION pour traçabilité anti-blanchiment. diff --git a/src/main/java/dev/lions/unionflow/server/service/MembreService.java b/src/main/java/dev/lions/unionflow/server/service/MembreService.java index c952b04..a09ec4b 100644 --- a/src/main/java/dev/lions/unionflow/server/service/MembreService.java +++ b/src/main/java/dev/lions/unionflow/server/service/MembreService.java @@ -69,6 +69,15 @@ public class MembreService { @Inject dev.lions.unionflow.server.messaging.KafkaEventProducer kafkaEventProducer; + @Inject + MembreKeycloakSyncService keycloakSyncService; + + @Inject + AuditService auditService; + + @Inject + dev.lions.unionflow.server.repository.NotificationRepository notificationRepository; + /** Crée un nouveau membre en attente de validation admin */ @Transactional public Membre creerMembre(Membre membre) { @@ -293,7 +302,25 @@ public class MembreService { return membreRepository.findByNomOrPrenom(recherche); } - /** Désactive un membre */ + /** + * Désactive un membre avec propagation complète des cascades métier. + * + *

Garde-fous et effets : + *

    + *
  1. Check mono-admin : si le membre est le seul ORGADMIN d'une org, + * lève {@link jakarta.ws.rs.WebApplicationException} 409 Conflict — l'appelant + * doit d'abord assigner un autre admin pour éviter l'orphelinage.
  2. + *
  3. DB : {@code actif=false}, {@code statutCompte='DESACTIVE'}
  4. + *
  5. Toutes les adhésions actives → {@code SUSPENDU}, {@code nombreMembres} décrémenté
  6. + *
  7. Tous les {@link dev.lions.unionflow.server.entity.MembreRole} → {@code actif=false} + * (perte immédiate des droits fonctionnels)
  8. + *
  9. Keycloak (lions-user-manager) : {@code user.enabled=false} → login impossible
  10. + *
  11. Kafka : événement {@code member.deactivated} émis pour les consommateurs externes
  12. + *
+ * + *

Non couvert (laissé à des services spécialisés) : comptes épargne, cotisations, + * inscriptions événements, approbations en attente — à traiter via workflow dédié. + */ @Transactional public void desactiverMembre(UUID id) { LOG.infof("Désactivation du membre ID: %s", id); @@ -303,18 +330,107 @@ public class MembreService { throw new IllegalArgumentException("Membre non trouvé avec l'ID: " + id); } - membre.setActif(false); + // ── 1. GARDE-FOU mono-admin : refuser si l'orphelinage créerait une org sans admin ── + List orgsOrphelines = verifierOrgsOrphelinees(id); + if (!orgsOrphelines.isEmpty()) { + final String msg = "Suppression impossible : ce membre est le seul administrateur de " + + orgsOrphelines.size() + " organisation(s) (" + + String.join(", ", orgsOrphelines) + + "). Veuillez d'abord désigner un autre administrateur avant de supprimer ce compte."; + LOG.warnf("Refus désactivation %s (mono-admin de %s)", id, orgsOrphelines); + throw new jakarta.ws.rs.WebApplicationException(msg, jakarta.ws.rs.core.Response.Status.CONFLICT); + } - // Décrémenter le compteur nombreMembres pour chaque organisation active du membre - // (fix DATA-01 : le compteur restait figé lors d'une désactivation directe) - membreOrganisationRepository.findOrganisationsActivesParMembre(id).forEach(mo -> { + // ── 2. DB : flags principaux du membre ─────────────────────────────────────────── + membre.setActif(false); + membre.setStatutCompte("DESACTIVE"); + + // ── 3. Adhésions actives → SUSPENDU + décrément compteur org ───────────────────── + final var adhesionsActives = membreOrganisationRepository.findOrganisationsActivesParMembre(id); + for (var mo : adhesionsActives) { mo.getOrganisation().retirerMembre(); mo.setStatutMembre(dev.lions.unionflow.server.api.enums.membre.StatutMembre.SUSPENDU); - LOG.infof("Compteur membres décrémenté pour organisation %s (membre désactivé)", - mo.getOrganisation().getId()); - }); + } + final int nbAdhesionsSuspendues = adhesionsActives.size(); - LOG.infof("Membre désactivé: %s", membre.getNomComplet()); + // ── 4. Désactivation des rôles fonctionnels (ORGADMIN, TRESORIER, etc.) ───────── + final int rolesDesactives = (int) membreRoleRepository.update( + "actif = false, dateFin = ?1, modifiePar = ?2 " + + "WHERE membreOrganisation.membre.id = ?3 AND actif = true", + LocalDate.now(), "system", id); + LOG.infof("%d MembreRole désactivés pour le membre %s", rolesDesactives, id); + + // ── 5. Annulation des notifications pending pour ce membre ────────────────────── + try { + final long notifsAnnulees = notificationRepository.update( + "statut = ?1, dateModification = ?2 " + + "WHERE membre.id = ?3 AND statut IN (?4, ?5) AND actif = true", + "ANNULEE", java.time.LocalDateTime.now(), id, + "EN_ATTENTE", "ECHEC_TEMPORAIRE"); + if (notifsAnnulees > 0) { + LOG.infof("%d notifications pending annulées pour membre %s", notifsAnnulees, id); + } + } catch (Exception e) { + LOG.warnf("Annulation notifications pending échouée pour %s : %s", id, e.getMessage()); + } + + // ── 6. Propagation Keycloak (non bloquant) ─────────────────────────────────────── + try { + keycloakSyncService.syncMembreToKeycloak(id); + LOG.infof("Compte Keycloak désactivé pour membre %s", id); + } catch (Exception e) { + LOG.warnf("Sync Keycloak échouée pour membre %s : %s (DB reste cohérente)", + id, e.getMessage()); + } + + // ── 7. Événement Kafka pour les autres modules/services ───────────────────────── + try { + kafkaEventProducer.publishMemberDeactivated(membre); + } catch (Exception e) { + LOG.warnf("Publication Kafka member.deactivated échouée pour %s : %s", id, e.getMessage()); + } + + // ── 8. Audit log (traçabilité RGPD/compliance) ────────────────────────────────── + try { + String operateur = securityIdentity != null && !securityIdentity.isAnonymous() + ? securityIdentity.getPrincipal().getName() + : "system"; + auditService.logMembreDesactive(id, membre.getEmail(), operateur, + nbAdhesionsSuspendues, rolesDesactives); + } catch (Exception e) { + LOG.warnf("Audit log MEMBRE_DESACTIVE échoué pour %s : %s", id, e.getMessage()); + } + + LOG.infof("Membre désactivé avec cascade complète : %s (adhésions=%d, rôles=%d)", + membre.getNomComplet(), nbAdhesionsSuspendues, rolesDesactives); + } + + /** + * Vérifie si la désactivation d'un membre entraînerait l'orphelinage d'organisations + * (i.e. le membre est le seul ORGADMIN actif d'au moins une org). + * + * @return liste des noms d'organisations qui deviendraient orphelines (vide si OK) + */ + private List verifierOrgsOrphelinees(UUID membreId) { + List orphelines = new ArrayList<>(); + // Toutes les orgs où ce membre est ORGADMIN actif + final LocalDate today = LocalDate.now(); + List rolesAdmin = membreRoleRepository.list( + "membreOrganisation.membre.id = ?1 AND role.code = ?2 AND actif = true " + + "AND (dateDebut IS NULL OR dateDebut <= ?3) " + + "AND (dateFin IS NULL OR dateFin >= ?3)", + membreId, "ORGADMIN", today); + + for (var role : rolesAdmin) { + if (role.getOrganisation() == null) continue; + UUID orgId = role.getOrganisation().getId(); + long totalAdmins = membreRoleRepository.countAdminsByOrganisationId(orgId); + // Si ce membre est le seul admin (total=1) et qu'on le désactive → org orpheline + if (totalAdmins <= 1) { + orphelines.add(role.getOrganisation().getNom()); + } + } + return orphelines; } /** Génère un numéro de membre unique */ @@ -342,10 +458,11 @@ public class MembreService { if (ids.isEmpty()) return List.of(); return membreRepository.findDistinctByOrganisationIdIn(ids, page, sort); } - return membreRepository.findAll(page, sort); + // SuperAdmin : filtre les désactivés par défaut (ne pas polluer les listes UI) + return membreRepository.findAllActifs(page, sort); } - /** Compte les membres. Pour ADMIN_ORGANISATION, compte uniquement les membres de ses organisations. */ + /** Compte les membres actifs. Pour ADMIN_ORGANISATION, compte uniquement les membres de ses organisations. */ public long compterMembres() { Optional> orgIds = getOrganisationIdsForCurrentUserIfAdminOrg(); if (orgIds.isPresent()) { @@ -353,7 +470,7 @@ public class MembreService { if (ids.isEmpty()) return 0L; return membreRepository.countDistinctByOrganisationIdIn(ids); } - return membreRepository.count(); + return membreRepository.countActifs(); } /** Recherche des membres avec pagination. Pour ADMIN_ORGANISATION, limite aux membres de ses organisations. */ @@ -394,14 +511,18 @@ public class MembreService { long membresActifs = membreRepository.countActifs(); long membresInactifs = totalMembres - membresActifs; long nouveauxMembres30Jours = membreRepository.countNouveauxMembres(LocalDate.now().minusDays(30)); + long totalOrganisations = organisationService.rechercherOrganisationsCount(""); - return Map.of( - "totalMembres", totalMembres, - "membresActifs", membresActifs, - "membresInactifs", membresInactifs, - "nouveauxMembres30Jours", nouveauxMembres30Jours, - "tauxActivite", totalMembres > 0 ? (membresActifs * 100.0 / totalMembres) : 0.0, - "timestamp", LocalDateTime.now()); + Map stats = new java.util.HashMap<>(); + stats.put("totalMembres", totalMembres); + stats.put("total", totalMembres); // alias pour compatibilité mobile + stats.put("membresActifs", membresActifs); + stats.put("membresInactifs", membresInactifs); + stats.put("nouveauxMembres30Jours", nouveauxMembres30Jours); + stats.put("tauxActivite", totalMembres > 0 ? (membresActifs * 100.0 / totalMembres) : 0.0); + stats.put("totalOrganisations", totalOrganisations); + stats.put("timestamp", LocalDateTime.now()); + return stats; } // ========================================