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:
@@ -7,6 +7,7 @@ import java.time.LocalDate;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repository pour l'entité MembreRole
|
* Repository pour l'entité MembreRole
|
||||||
@@ -18,6 +19,8 @@ import java.util.UUID;
|
|||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
public class MembreRoleRepository implements PanacheRepository<MembreRole> {
|
public class MembreRoleRepository implements PanacheRepository<MembreRole> {
|
||||||
|
|
||||||
|
private static final Logger LOG = Logger.getLogger(MembreRoleRepository.class);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trouve une attribution membre-role par son UUID
|
* 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)
|
return find("membreOrganisation.membre.id = ?1 AND role.id = ?2", membreId, roleId)
|
||||||
.firstResult();
|
.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import dev.lions.unionflow.server.entity.Organisation;
|
|||||||
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
|
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
|
||||||
import dev.lions.unionflow.server.repository.MembreRepository;
|
import dev.lions.unionflow.server.repository.MembreRepository;
|
||||||
import dev.lions.unionflow.server.repository.OrganisationRepository;
|
import dev.lions.unionflow.server.repository.OrganisationRepository;
|
||||||
|
import dev.lions.unionflow.server.service.MembreRoleSyncService;
|
||||||
import io.quarkus.security.identity.SecurityIdentity;
|
import io.quarkus.security.identity.SecurityIdentity;
|
||||||
import jakarta.annotation.Priority;
|
import jakarta.annotation.Priority;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
@@ -54,6 +55,9 @@ public class OrganisationContextFilter implements ContainerRequestFilter {
|
|||||||
@Inject
|
@Inject
|
||||||
OrganisationRepository organisationRepository;
|
OrganisationRepository organisationRepository;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
MembreRoleSyncService membreRoleSyncService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void filter(ContainerRequestContext requestContext) throws IOException {
|
public void filter(ContainerRequestContext requestContext) throws IOException {
|
||||||
String orgIdHeader = requestContext.getHeaderString(HEADER_ACTIVE_ORG);
|
String orgIdHeader = requestContext.getHeaderString(HEADER_ACTIVE_ORG);
|
||||||
@@ -128,6 +132,18 @@ public class OrganisationContextFilter implements ContainerRequestFilter {
|
|||||||
.build());
|
.build());
|
||||||
return;
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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é")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import dev.lions.unionflow.server.repository.AdresseRepository;
|
|||||||
import dev.lions.unionflow.server.repository.EvenementRepository;
|
import dev.lions.unionflow.server.repository.EvenementRepository;
|
||||||
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
|
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
|
||||||
import dev.lions.unionflow.server.repository.MembreRepository;
|
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.repository.TypeReferenceRepository;
|
||||||
import dev.lions.unionflow.server.entity.Organisation;
|
import dev.lions.unionflow.server.entity.Organisation;
|
||||||
import dev.lions.unionflow.server.repository.OrganisationRepository;
|
import dev.lions.unionflow.server.repository.OrganisationRepository;
|
||||||
@@ -63,6 +64,9 @@ public class OrganisationService {
|
|||||||
@Inject
|
@Inject
|
||||||
EvenementRepository evenementRepository;
|
EvenementRepository evenementRepository;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
MembreRoleRepository membreRoleRepository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Crée une nouvelle organisation
|
* Crée une nouvelle organisation
|
||||||
*
|
*
|
||||||
@@ -565,11 +569,16 @@ public class OrganisationService {
|
|||||||
dto.setObjectifs(organisation.getObjectifs());
|
dto.setObjectifs(organisation.getObjectifs());
|
||||||
dto.setActivitesPrincipales(organisation.getActivitesPrincipales());
|
dto.setActivitesPrincipales(organisation.getActivitesPrincipales());
|
||||||
dto.setNombreMembres(organisation.getNombreMembres());
|
dto.setNombreMembres(organisation.getNombreMembres());
|
||||||
dto.setNombreAdministrateurs(organisation.getNombreAdministrateurs());
|
|
||||||
if (organisation.getId() != null) {
|
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());
|
long countEvenements = evenementRepository.countActifsByOrganisationId(organisation.getId());
|
||||||
dto.setNombreEvenements((int) countEvenements);
|
dto.setNombreEvenements((int) countEvenements);
|
||||||
} else {
|
} else {
|
||||||
|
dto.setNombreAdministrateurs(0);
|
||||||
dto.setNombreEvenements(0);
|
dto.setNombreEvenements(0);
|
||||||
}
|
}
|
||||||
dto.setBudgetAnnuel(organisation.getBudgetAnnuel());
|
dto.setBudgetAnnuel(organisation.getBudgetAnnuel());
|
||||||
|
|||||||
Reference in New Issue
Block a user