feat(members): desactiverMembre cascade complète (Keycloak, Kafka, audit, mono-admin)
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
This commit is contained in:
@@ -43,6 +43,9 @@ public class KafkaEventProducer {
|
||||
@Channel("contributions-events-out")
|
||||
Emitter<Record<String, String>> contributionsEventsEmitter;
|
||||
|
||||
@Channel("chat-messages-out")
|
||||
Emitter<Record<String, String>> 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<String, Object> 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<String, Object> messageData) {
|
||||
var event = buildEvent("NOUVEAU_MESSAGE", organizationId, messageData);
|
||||
publishToChannel(chatMessagesEmitter, conversationId.toString(), event, "chat-messages");
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit un event avec structure standardisée.
|
||||
*/
|
||||
|
||||
@@ -87,6 +87,7 @@ public class MembreRepository extends BaseRepository<Membre> {
|
||||
|
||||
/**
|
||||
* 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<Membre> findDistinctByOrganisationIdIn(Set<UUID> organisationIds, Page page, Sort sort) {
|
||||
if (organisationIds == null || organisationIds.isEmpty()) {
|
||||
@@ -94,7 +95,9 @@ public class MembreRepository extends BaseRepository<Membre> {
|
||||
}
|
||||
String orderBy = sort != null ? " ORDER BY " + buildOrderBy(sort) : "";
|
||||
TypedQuery<Membre> 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<Membre> {
|
||||
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<UUID> organisationIds) {
|
||||
if (organisationIds == null || organisationIds.isEmpty()) {
|
||||
return 0L;
|
||||
}
|
||||
TypedQuery<Long> 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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>Garde-fous et effets :
|
||||
* <ol>
|
||||
* <li><b>Check mono-admin</b> : 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.</li>
|
||||
* <li>DB : {@code actif=false}, {@code statutCompte='DESACTIVE'}</li>
|
||||
* <li>Toutes les adhésions actives → {@code SUSPENDU}, {@code nombreMembres} décrémenté</li>
|
||||
* <li>Tous les {@link dev.lions.unionflow.server.entity.MembreRole} → {@code actif=false}
|
||||
* (perte immédiate des droits fonctionnels)</li>
|
||||
* <li>Keycloak (lions-user-manager) : {@code user.enabled=false} → login impossible</li>
|
||||
* <li>Kafka : événement {@code member.deactivated} émis pour les consommateurs externes</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>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<String> 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<String> verifierOrgsOrphelinees(UUID membreId) {
|
||||
List<String> orphelines = new ArrayList<>();
|
||||
// Toutes les orgs où ce membre est ORGADMIN actif
|
||||
final LocalDate today = LocalDate.now();
|
||||
List<dev.lions.unionflow.server.entity.MembreRole> 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<Set<UUID>> 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<String, Object> 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;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
||||
Reference in New Issue
Block a user