feat(sync): MembreRoleSyncService + count admins dynamique

- Nouveau MembreRoleSyncService.ensureOrgAdminRole : auto-crée un MembreRole ORGADMIN
  quand un user avec rôle Keycloak ADMIN_ORGANISATION se connecte sans entrée DB
  (couvre les comptes créés directement dans Keycloak).
- OrganisationContextFilter appelle syncService.ensureOrgAdminRole quand le rôle
  Keycloak est présent mais MembreRole absent (non bloquant sur erreur).
- MembreRoleRepository.countAdminsByOrganisationId : count strict (ORGADMIN + actif
  + dateDebut/dateFin valides) avec fallback sur codes alternatifs si strict=0.
- OrganisationService.convertToResponse : nombreAdministrateurs dynamique via
  MembreRoleRepository (remplace le champ Organisation jamais mis à jour).
This commit is contained in:
dahoud
2026-04-15 20:05:49 +00:00
parent e81c75b828
commit 78d8fd7cd8
4 changed files with 176 additions and 1 deletions

View File

@@ -7,6 +7,7 @@ import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.jboss.logging.Logger;
/**
* Repository pour l'entité MembreRole
@@ -18,6 +19,8 @@ import java.util.UUID;
@ApplicationScoped
public class MembreRoleRepository implements PanacheRepository<MembreRole> {
private static final Logger LOG = Logger.getLogger(MembreRoleRepository.class);
/**
* Trouve une attribution membre-role par son UUID
*
@@ -76,5 +79,70 @@ public class MembreRoleRepository implements PanacheRepository<MembreRole> {
return find("membreOrganisation.membre.id = ?1 AND role.id = ?2", membreId, roleId)
.firstResult();
}
/**
* Compte les administrateurs actifs d'une organisation.
*
* <p>Le code DB du rôle admin d'organisation est {@code ORGADMIN}
* (cf. seed V13__Seed_Standard_Roles.sql). Ne pas confondre avec le rôle
* Keycloak {@code ADMIN_ORGANISATION} utilisé dans {@code @RolesAllowed} —
* les deux représentent le même concept mais avec un code différent par
* source (Keycloak vs table roles DB).
*
* <p>L'unique constraint {@code uk_mr_membre_org_role} garantit qu'un même
* membre n'est comptabilisé qu'une fois même s'il se voit attribuer
* plusieurs fois le rôle.
*
* @param organisationId ID de l'organisation
* @return nombre d'admins actifs de cette organisation
*/
public long countAdminsByOrganisationId(UUID organisationId) {
final LocalDate today = LocalDate.now();
// Diagnostic : inventaire complet des MembreRole liés à cette organisation
final long totalForOrg = count("organisation.id = ?1", organisationId);
final List<MembreRole> allForOrg = list("organisation.id = ?1", organisationId);
final String codesFound = allForOrg.stream()
.map(mr -> String.format(
"%s[actif=%s,dateDebut=%s,dateFin=%s]",
mr.getRole() != null ? mr.getRole().getCode() : "null",
mr.getActif(),
mr.getDateDebut(),
mr.getDateFin()))
.reduce((a, b) -> a + ", " + b)
.orElse("(aucun)");
final long strictCount = count(
"organisation.id = ?1 AND role.code = ?2 AND actif = true "
+ "AND (dateDebut IS NULL OR dateDebut <= ?3) "
+ "AND (dateFin IS NULL OR dateFin >= ?3)",
organisationId,
"ORGADMIN",
today);
LOG.infof(
"countAdminsByOrganisationId(org=%s) → strict=%d, total_membres_roles_pour_cette_org=%d, detail=[%s]",
organisationId, strictCount, totalForOrg, codesFound);
// Fallback : si aucun match strict mais qu'il existe des entrées actives
// avec un code admin alternatif (ex. ADMIN_ORGANISATION résiduel), on les
// compte quand même pour éviter un faux zéro.
if (strictCount == 0 && totalForOrg > 0) {
final long fallbackCount = count(
"organisation.id = ?1 AND role.code IN (?2) AND actif = true "
+ "AND (dateDebut IS NULL OR dateDebut <= ?3) "
+ "AND (dateFin IS NULL OR dateFin >= ?3)",
organisationId,
List.of("ORGADMIN", "ADMIN_ORGANISATION", "ADMIN"),
today);
if (fallbackCount > 0) {
LOG.warnf(
"countAdminsByOrganisationId(org=%s) strict=0 mais fallback (codes alternatifs)=%d — le seed V13 utilise 'ORGADMIN', vérifier les assignations",
organisationId, fallbackCount);
return fallbackCount;
}
}
return strictCount;
}
}

View File

@@ -6,6 +6,7 @@ import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.OrganisationRepository;
import dev.lions.unionflow.server.service.MembreRoleSyncService;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.annotation.Priority;
import jakarta.inject.Inject;
@@ -54,6 +55,9 @@ public class OrganisationContextFilter implements ContainerRequestFilter {
@Inject
OrganisationRepository organisationRepository;
@Inject
MembreRoleSyncService membreRoleSyncService;
@Override
public void filter(ContainerRequestContext requestContext) throws IOException {
String orgIdHeader = requestContext.getHeaderString(HEADER_ACTIVE_ORG);
@@ -128,6 +132,18 @@ public class OrganisationContextFilter implements ContainerRequestFilter {
.build());
return;
}
// Sync rôle DB ↔ Keycloak : si l'utilisateur est admin dans Keycloak
// mais n'a pas encore de MembreRole ORGADMIN en base, le créer.
if (securityIdentity.getRoles().contains("ADMIN_ORGANISATION")) {
try {
membreRoleSyncService.ensureOrgAdminRole(membreOrg);
} catch (Exception e) {
// Non bloquant — on log et on continue
LOG.warnf("ensureOrgAdminRole: erreur non bloquante pour %s : %s",
email, e.getMessage());
}
}
}
}
}

View File

@@ -0,0 +1,82 @@
package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.entity.MembreOrganisation;
import dev.lions.unionflow.server.entity.MembreRole;
import dev.lions.unionflow.server.repository.MembreRoleRepository;
import dev.lions.unionflow.server.repository.RoleRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.transaction.Transactional;
import java.time.LocalDate;
import org.jboss.logging.Logger;
/**
* Synchronise le rôle DB (MembreRole) avec le rôle Keycloak au premier accès.
*
* <p>Lorsqu'un utilisateur est admin dans Keycloak (rôle {@code ADMIN_ORGANISATION})
* mais n'a pas encore de ligne {@code MembreRole ORGADMIN} en base, ce service la
* crée à la volée. Cela couvre deux cas :
* <ol>
* <li>Comptes créés directement dans Keycloak sans passer par
* {@code MembreService#promouvoirAdminOrganisation}.</li>
* <li>Bases de données en dev qui ont démarré avant la migration V30.</li>
* </ol>
*/
@ApplicationScoped
public class MembreRoleSyncService {
private static final Logger LOG = Logger.getLogger(MembreRoleSyncService.class);
@Inject
MembreRoleRepository membreRoleRepository;
@Inject
RoleRepository roleRepository;
@PersistenceContext
EntityManager entityManager;
/**
* Assure qu'un MembreRole ORGADMIN existe pour ce MembreOrganisation.
* Idempotent : sans effet si l'entrée existe déjà.
*
* @param mo MembreOrganisation de l'admin (non null)
*/
@Transactional
public void ensureOrgAdminRole(MembreOrganisation mo) {
if (mo == null || mo.getId() == null || mo.getOrganisation() == null) {
return;
}
// Vérifier si un MembreRole ORGADMIN actif existe déjà pour ce membre dans cette org
long existing = membreRoleRepository.count(
"membreOrganisation.id = ?1 AND role.code = ?2 AND actif = true",
mo.getId(), "ORGADMIN");
if (existing > 0) {
return; // Déjà en place — rien à faire
}
// Chercher le rôle ORGADMIN dans la table roles
roleRepository.findByCode("ORGADMIN").ifPresentOrElse(
role -> {
MembreRole mr = new MembreRole();
mr.setMembreOrganisation(mo);
mr.setOrganisation(mo.getOrganisation());
mr.setRole(role);
mr.setActif(true);
mr.setDateDebut(LocalDate.now());
entityManager.persist(mr);
LOG.infof(
"MembreRole ORGADMIN auto-sync créé pour membre %s dans org %s",
mo.getMembre() != null ? mo.getMembre().getId() : "?",
mo.getOrganisation().getId());
},
() -> LOG.warnf(
"Rôle ORGADMIN introuvable dans la table roles — "
+ "vérifier que V13__Seed_Standard_Roles.sql a été appliqué")
);
}
}

View File

@@ -11,6 +11,7 @@ import dev.lions.unionflow.server.repository.AdresseRepository;
import dev.lions.unionflow.server.repository.EvenementRepository;
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.MembreRoleRepository;
import dev.lions.unionflow.server.repository.TypeReferenceRepository;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.repository.OrganisationRepository;
@@ -63,6 +64,9 @@ public class OrganisationService {
@Inject
EvenementRepository evenementRepository;
@Inject
MembreRoleRepository membreRoleRepository;
/**
* Crée une nouvelle organisation
*
@@ -565,11 +569,16 @@ public class OrganisationService {
dto.setObjectifs(organisation.getObjectifs());
dto.setActivitesPrincipales(organisation.getActivitesPrincipales());
dto.setNombreMembres(organisation.getNombreMembres());
dto.setNombreAdministrateurs(organisation.getNombreAdministrateurs());
if (organisation.getId() != null) {
// Compte dynamique des administrateurs (rôle ADMIN_ORGANISATION actif)
// — le champ Organisation.nombreAdministrateurs n'est pas tenu à jour.
long countAdmins = membreRoleRepository.countAdminsByOrganisationId(organisation.getId());
dto.setNombreAdministrateurs((int) countAdmins);
long countEvenements = evenementRepository.countActifsByOrganisationId(organisation.getId());
dto.setNombreEvenements((int) countEvenements);
} else {
dto.setNombreAdministrateurs(0);
dto.setNombreEvenements(0);
}
dto.setBudgetAnnuel(organisation.getBudgetAnnuel());