1391 lines
64 KiB
Java
1391 lines
64 KiB
Java
package dev.lions.unionflow.server.resource;
|
|
|
|
import dev.lions.unionflow.server.api.dto.common.PagedResponse;
|
|
import dev.lions.unionflow.server.api.dto.membre.request.CreateMembreRequest;
|
|
import dev.lions.unionflow.server.api.dto.membre.request.UpdateMembreRequest;
|
|
import dev.lions.unionflow.server.api.dto.membre.response.MembreResponse;
|
|
import dev.lions.unionflow.server.api.dto.membre.response.MembreSummaryResponse;
|
|
import dev.lions.unionflow.server.api.dto.membre.MembreSearchCriteria;
|
|
import dev.lions.unionflow.server.api.dto.membre.MembreSearchResultDTO;
|
|
import dev.lions.unionflow.server.entity.Membre;
|
|
import dev.lions.unionflow.server.entity.Organisation;
|
|
import dev.lions.unionflow.server.entity.MembreOrganisation;
|
|
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
|
|
import dev.lions.unionflow.server.repository.MembreRoleRepository;
|
|
import dev.lions.unionflow.server.service.MemberLifecycleService;
|
|
import dev.lions.unionflow.server.service.MembreKeycloakSyncService;
|
|
import dev.lions.unionflow.server.service.MembreService;
|
|
import dev.lions.unionflow.server.service.MembreSuiviService;
|
|
import dev.lions.unionflow.server.service.OrganisationService;
|
|
import io.quarkus.panache.common.Page;
|
|
import io.quarkus.panache.common.Sort;
|
|
import jakarta.annotation.security.PermitAll;
|
|
import jakarta.annotation.security.RolesAllowed;
|
|
import jakarta.enterprise.context.ApplicationScoped;
|
|
import jakarta.inject.Inject;
|
|
import jakarta.transaction.Transactional;
|
|
import jakarta.validation.Valid;
|
|
import jakarta.ws.rs.*;
|
|
import jakarta.ws.rs.core.MediaType;
|
|
import jakarta.ws.rs.core.Response;
|
|
import java.io.InputStream;
|
|
import java.time.LocalDate;
|
|
import java.util.ArrayList;
|
|
import java.util.HashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.UUID;
|
|
import org.eclipse.microprofile.jwt.JsonWebToken;
|
|
import org.eclipse.microprofile.openapi.annotations.Operation;
|
|
import org.eclipse.microprofile.openapi.annotations.media.Content;
|
|
import org.eclipse.microprofile.openapi.annotations.media.ExampleObject;
|
|
import org.eclipse.microprofile.openapi.annotations.media.Schema;
|
|
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
|
|
import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody;
|
|
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
|
|
import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
|
|
import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement;
|
|
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
|
import org.jboss.logging.Logger;
|
|
import org.jboss.resteasy.reactive.RestForm;
|
|
|
|
/** Resource REST pour la gestion des membres */
|
|
@Path("/api/membres")
|
|
@Produces(MediaType.APPLICATION_JSON)
|
|
@Consumes(MediaType.APPLICATION_JSON)
|
|
@ApplicationScoped
|
|
@Tag(name = "Membres", description = "API de gestion des membres")
|
|
@Transactional
|
|
public class MembreResource {
|
|
|
|
private static final Logger LOG = Logger.getLogger(MembreResource.class);
|
|
|
|
@Inject
|
|
MembreService membreService;
|
|
|
|
@Inject
|
|
MembreKeycloakSyncService keycloakSyncService;
|
|
|
|
@Inject
|
|
MembreSuiviService membreSuiviService;
|
|
|
|
@Inject
|
|
OrganisationService organisationService;
|
|
|
|
@Inject
|
|
MemberLifecycleService memberLifecycleService;
|
|
|
|
@Inject
|
|
MembreOrganisationRepository membreOrgRepository;
|
|
|
|
@Inject
|
|
MembreRoleRepository membreRoleRepository;
|
|
|
|
@Inject
|
|
io.quarkus.security.identity.SecurityIdentity securityIdentity;
|
|
|
|
@Inject
|
|
JsonWebToken jwt;
|
|
|
|
@GET
|
|
@RolesAllowed({ "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MODERATEUR" })
|
|
@Operation(summary = "Lister les membres")
|
|
@APIResponse(responseCode = "200", description = "Liste des membres avec pagination")
|
|
public PagedResponse<MembreSummaryResponse> listerMembres(
|
|
@Parameter(description = "Numéro de page (0-based)") @QueryParam("page") @DefaultValue("0") int page,
|
|
@Parameter(description = "Taille de la page") @QueryParam("size") @DefaultValue("20") int size,
|
|
@Parameter(description = "Champ de tri") @QueryParam("sort") @DefaultValue("nom") String sortField,
|
|
@Parameter(description = "Direction du tri (asc/desc)") @QueryParam("direction") @DefaultValue("asc") String sortDirection) {
|
|
|
|
LOG.infof("Récupération de la liste des membres - page: %d, size: %d", page, size);
|
|
|
|
Sort sort = "desc".equalsIgnoreCase(sortDirection)
|
|
? Sort.by(sortField).descending()
|
|
: Sort.by(sortField).ascending();
|
|
|
|
// Filtrage par rôle : ADMIN_ORGANISATION ne voit que ses organisations
|
|
java.util.Set<String> roles = securityIdentity.getRoles();
|
|
boolean onlyOrgAdmin = roles != null && roles.contains("ADMIN_ORGANISATION")
|
|
&& !roles.contains("ADMIN")
|
|
&& !roles.contains("SUPER_ADMIN");
|
|
|
|
List<Membre> membres;
|
|
long totalElements;
|
|
|
|
if (onlyOrgAdmin) {
|
|
java.security.Principal p = securityIdentity.getPrincipal();
|
|
String email = p != null ? p.getName() : null;
|
|
|
|
if (email == null || email.isEmpty()) {
|
|
LOG.warn("ADMIN_ORGANISATION sans email identifié - retour liste vide");
|
|
membres = List.of();
|
|
totalElements = 0;
|
|
} else {
|
|
List<UUID> orgIds = organisationService.listerOrganisationsPourUtilisateur(email)
|
|
.stream()
|
|
.map(dev.lions.unionflow.server.entity.Organisation::getId)
|
|
.collect(java.util.stream.Collectors.toList());
|
|
|
|
LOG.infof("ADMIN_ORGANISATION %s : accès à %d organisations", email, orgIds.size());
|
|
|
|
membres = membreService.listerMembresParOrganisations(orgIds, Page.of(page, size), sort);
|
|
totalElements = membreService.compterMembresParOrganisations(orgIds);
|
|
}
|
|
} else {
|
|
// ADMIN / SUPER_ADMIN : accès à tous les membres
|
|
membres = membreService.listerMembres(Page.of(page, size), sort);
|
|
totalElements = membreService.compterMembres();
|
|
}
|
|
|
|
List<MembreSummaryResponse> membresDTO = membreService.convertToSummaryResponseList(membres);
|
|
|
|
return new PagedResponse<>(membresDTO, totalElements, page, size);
|
|
}
|
|
|
|
@GET
|
|
@Path("/{id}")
|
|
@RolesAllowed({ "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MODERATEUR", "MEMBRE", "USER" })
|
|
@Operation(summary = "Récupérer un membre par son ID")
|
|
@APIResponse(responseCode = "200", description = "Membre trouvé")
|
|
@APIResponse(responseCode = "404", description = "Membre non trouvé")
|
|
public Response obtenirMembre(@Parameter(description = "UUID du membre") @PathParam("id") UUID id) {
|
|
LOG.infof("Récupération du membre ID: %s", id);
|
|
Membre membre = membreService.trouverParId(id)
|
|
.filter(m -> m.getActif() == null || m.getActif())
|
|
.orElseThrow(() -> new NotFoundException("Membre non trouvé avec l'ID: " + id));
|
|
|
|
return Response.ok(membreService.convertToResponse(membre)).build();
|
|
}
|
|
|
|
@GET
|
|
@Path("/me/suivis")
|
|
@RolesAllowed({ "USER", "MEMBRE", "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MODERATEUR", "CONSULTANT", "SECRETAIRE", "GESTIONNAIRE_RH" })
|
|
@Operation(summary = "Liste des ids des membres suivis (réseau)")
|
|
@APIResponse(responseCode = "200", description = "Liste des UUID suivis")
|
|
public Response obtenirMesSuivis() {
|
|
String email = securityIdentity.getPrincipal().getName();
|
|
List<UUID> ids = membreSuiviService.getFollowedIds(email);
|
|
return Response.ok(ids).build();
|
|
}
|
|
|
|
@GET
|
|
@Path("/me")
|
|
@RolesAllowed({ "USER", "MEMBRE", "ADMIN", "ADMIN_ORGANISATION", "SUPER_ADMIN" })
|
|
@Operation(summary = "Récupérer le membre connecté")
|
|
@APIResponse(responseCode = "200", description = "Membre connecté trouvé ou auto-provisionné")
|
|
public Response obtenirMembreConnecte() {
|
|
String email = securityIdentity.getPrincipal().getName();
|
|
LOG.infof("Récupération du membre connecté: %s", email);
|
|
|
|
Membre membre = membreService.trouverParEmail(email)
|
|
.orElseGet(() -> {
|
|
LOG.infof("Fiche membre inexistante pour %s — auto-provisionnement depuis JWT", email);
|
|
return autoProvisionnerMembre(email);
|
|
});
|
|
|
|
// Si la fiche existe mais est inactive (provisionnement précédent incomplet), on l'active
|
|
if (membre.getActif() != null && !membre.getActif()) {
|
|
LOG.infof("Fiche inactive pour %s — activation automatique", email);
|
|
membre = membreService.activerMembre(membre.getId());
|
|
}
|
|
|
|
MembreResponse response = membreService.convertToResponse(membre);
|
|
|
|
// Enrichir avec les rôles Keycloak si la table membres_roles est vide
|
|
if (response.getRoles() == null || response.getRoles().isEmpty()) {
|
|
java.util.Set<String> keycloakRoles = securityIdentity.getRoles();
|
|
// Filtrer les rôles internes Keycloak (offline_access, uma_authorization, etc.)
|
|
java.util.List<String> rolesFiltres = keycloakRoles.stream()
|
|
.filter(r -> !r.equals("offline_access") && !r.equals("uma_authorization")
|
|
&& !r.startsWith("default-roles-"))
|
|
.collect(java.util.stream.Collectors.toList());
|
|
response.setRoles(rolesFiltres);
|
|
}
|
|
|
|
return Response.ok(response).build();
|
|
}
|
|
|
|
/**
|
|
* Retourne la liste des organisations du membre connecté (pour le sélecteur multi-org).
|
|
* Inclut le type, la catégorie et les modules actifs de chaque organisation.
|
|
*/
|
|
@GET
|
|
@Path("/mes-organisations")
|
|
@RolesAllowed({ "USER", "MEMBRE", "ADMIN", "ADMIN_ORGANISATION", "SUPER_ADMIN", "MODERATEUR" })
|
|
@Operation(summary = "Organisations du membre connecté",
|
|
description = "Retourne la liste des organisations auxquelles le membre connecté appartient (multi-org)")
|
|
@APIResponse(responseCode = "200", description = "Liste des organisations")
|
|
public Response getMesOrganisations() {
|
|
String email = securityIdentity.getPrincipal().getName();
|
|
try {
|
|
var membre = membreService.trouverParEmail(email);
|
|
if (membre.isEmpty()) {
|
|
return Response.ok(java.util.List.of()).build();
|
|
}
|
|
// Charger les liens membre-organisation avec les infos d'org
|
|
var liens = organisationService.listerOrganisationsParMembre(membre.get().getId());
|
|
return Response.ok(liens).build();
|
|
} catch (Exception e) {
|
|
LOG.errorf(e, "Erreur lors de la récupération des organisations du membre %s", email);
|
|
return Response.serverError()
|
|
.entity(java.util.Map.of("error", "Erreur serveur"))
|
|
.build();
|
|
}
|
|
}
|
|
|
|
/** Crée et active une fiche membre depuis les claims JWT lors du premier accès. */
|
|
private Membre autoProvisionnerMembre(String email) {
|
|
String prenom = "Utilisateur";
|
|
String nom = "UnionFlow";
|
|
UUID keycloakId = null;
|
|
|
|
if (jwt != null) {
|
|
String givenName = jwt.getClaim("given_name");
|
|
String familyName = jwt.getClaim("family_name");
|
|
String sub = jwt.getSubject();
|
|
if (givenName != null && !givenName.isBlank()) prenom = givenName;
|
|
if (familyName != null && !familyName.isBlank()) nom = familyName;
|
|
if (sub != null) {
|
|
try { keycloakId = UUID.fromString(sub); } catch (Exception ignored) {}
|
|
}
|
|
}
|
|
|
|
CreateMembreRequest req = CreateMembreRequest.builder()
|
|
.prenom(prenom)
|
|
.nom(nom)
|
|
.email(email)
|
|
.dateNaissance(LocalDate.of(1900, 1, 1))
|
|
.build();
|
|
|
|
Membre nouveau = membreService.convertFromCreateRequest(req);
|
|
if (keycloakId != null) nouveau.setKeycloakId(keycloakId);
|
|
|
|
// creerMembre() force actif=false + EN_ATTENTE_VALIDATION.
|
|
// On active immédiatement car l'utilisateur est déjà authentifié via Keycloak.
|
|
try {
|
|
Membre cree = membreService.creerMembre(nouveau);
|
|
return membreService.activerMembre(cree.getId());
|
|
} catch (IllegalArgumentException e) {
|
|
// Fiche déjà présente mais inactive — on l'active directement
|
|
LOG.infof("Fiche existante pour %s — activation directe", email);
|
|
Membre existant = membreService.trouverParEmail(email)
|
|
.orElseThrow(() -> new jakarta.ws.rs.NotFoundException("Membre introuvable : " + email));
|
|
return membreService.activerMembre(existant.getId());
|
|
}
|
|
}
|
|
|
|
@POST
|
|
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MEMBRE"})
|
|
@Operation(summary = "Créer un nouveau membre")
|
|
@APIResponse(responseCode = "201", description = "Membre créé avec succès")
|
|
@APIResponse(responseCode = "400", description = "Données invalides")
|
|
public Response creerMembre(@Valid CreateMembreRequest membreDTO) {
|
|
LOG.infof("Création d'un nouveau membre: %s", membreDTO.email());
|
|
// Conversion DTO vers entité
|
|
Membre membre = membreService.convertFromCreateRequest(membreDTO);
|
|
|
|
// Création du membre — statut EN_ATTENTE_VALIDATION
|
|
Membre nouveauMembre = membreService.creerMembre(membre);
|
|
|
|
// Provisionner le compte Keycloak (non bloquant — l'admin peut activer manuellement)
|
|
String motDePasseTemporaire = null;
|
|
try {
|
|
motDePasseTemporaire = keycloakSyncService.provisionKeycloakUser(nouveauMembre.getId());
|
|
} catch (Exception e) {
|
|
LOG.warnf("Provisionnement Keycloak échoué pour %s (non bloquant): %s", nouveauMembre.getEmail(), e.getMessage());
|
|
}
|
|
|
|
// Lier le membre à l'organisation si un organisationId est fourni
|
|
java.util.Set<String> roles = securityIdentity.getRoles();
|
|
boolean onlyOrgAdmin = roles != null && roles.contains("ADMIN_ORGANISATION")
|
|
&& !roles.contains("ADMIN")
|
|
&& !roles.contains("SUPER_ADMIN");
|
|
|
|
if (membreDTO.organisationId() != null) {
|
|
if (onlyOrgAdmin) {
|
|
// Vérifier que l'ADMIN_ORGANISATION a accès à cette organisation
|
|
String email = securityIdentity.getPrincipal().getName();
|
|
List<UUID> userOrgIds = organisationService.listerOrganisationsPourUtilisateur(email)
|
|
.stream()
|
|
.map(org -> org.getId())
|
|
.collect(java.util.stream.Collectors.toList());
|
|
|
|
if (!userOrgIds.contains(membreDTO.organisationId())) {
|
|
return Response.status(Response.Status.FORBIDDEN)
|
|
.entity(Map.of("error", "Vous n'avez pas accès à cette organisation"))
|
|
.build();
|
|
}
|
|
}
|
|
|
|
// Auto-activer si l'organisation a une souscription active (l'admin a déjà payé)
|
|
boolean orgActif = membreService.orgHasActiveSubscription(membreDTO.organisationId());
|
|
|
|
// Lier le membre à l'organisation (SUPER_ADMIN ou ADMIN_ORGANISATION)
|
|
membreService.lierMembreOrganisationEtIncrementerQuota(
|
|
nouveauMembre,
|
|
membreDTO.organisationId(),
|
|
orgActif ? "ACTIF" : "EN_ATTENTE_VALIDATION");
|
|
|
|
if (orgActif) {
|
|
nouveauMembre = membreService.activerMembre(nouveauMembre.getId());
|
|
try {
|
|
keycloakSyncService.activerMembreDansKeycloak(nouveauMembre.getId());
|
|
} catch (Exception e) {
|
|
LOG.warnf("Activation Keycloak échouée pour %s (non bloquant): %s",
|
|
nouveauMembre.getEmail(), e.getMessage());
|
|
}
|
|
}
|
|
} else if (onlyOrgAdmin) {
|
|
return Response.status(Response.Status.BAD_REQUEST)
|
|
.entity(Map.of("error", "organisationId obligatoire pour ADMIN_ORGANISATION"))
|
|
.build();
|
|
}
|
|
|
|
// Conversion de retour vers DTO
|
|
MembreResponse nouveauMembreDTO = membreService.convertToResponse(nouveauMembre);
|
|
nouveauMembreDTO.setMotDePasseTemporaire(motDePasseTemporaire);
|
|
|
|
return Response.status(Response.Status.CREATED).entity(nouveauMembreDTO).build();
|
|
}
|
|
|
|
@PUT
|
|
@Path("/{id}")
|
|
@RolesAllowed({ "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION" })
|
|
@Operation(summary = "Mettre à jour un membre existant")
|
|
@APIResponse(responseCode = "200", description = "Membre mis à jour avec succès")
|
|
@APIResponse(responseCode = "404", description = "Membre non trouvé")
|
|
@APIResponse(responseCode = "400", description = "Données invalides")
|
|
public Response mettreAJourMembre(
|
|
@Parameter(description = "UUID du membre") @PathParam("id") UUID id,
|
|
@Valid UpdateMembreRequest membreDTO) {
|
|
LOG.infof("Mise à jour du membre ID: %s", id);
|
|
|
|
// Recupérer le membre
|
|
Membre membreAModifier = membreService.trouverParId(id)
|
|
.orElseThrow(() -> new NotFoundException("Membre non trouvé avec l'ID: " + id));
|
|
|
|
// Mettre à jour depuis la requête
|
|
membreService.updateFromRequest(membreAModifier, membreDTO);
|
|
|
|
// Mise à jour en base
|
|
Membre membreMisAJour = membreService.mettreAJourMembre(id, membreAModifier);
|
|
|
|
// Conversion de retour
|
|
MembreResponse membreMisAJourDTO = membreService.convertToResponse(membreMisAJour);
|
|
|
|
return Response.ok(membreMisAJourDTO).build();
|
|
}
|
|
|
|
@DELETE
|
|
@Path("/{id}")
|
|
@RolesAllowed({ "ADMIN", "SUPER_ADMIN" })
|
|
@Operation(summary = "Désactiver un membre")
|
|
@APIResponse(responseCode = "204", description = "Membre désactivé avec succès")
|
|
@APIResponse(responseCode = "404", description = "Membre non trouvé")
|
|
public Response desactiverMembre(
|
|
@Parameter(description = "UUID du membre") @PathParam("id") UUID id) {
|
|
LOG.infof("Désactivation du membre ID: %s", id);
|
|
membreService.desactiverMembre(id);
|
|
return Response.noContent().build();
|
|
}
|
|
|
|
@POST
|
|
@Path("/{id}/suivre")
|
|
@RolesAllowed({ "USER", "MEMBRE", "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MODERATEUR", "CONSULTANT", "SECRETAIRE", "GESTIONNAIRE_RH" })
|
|
@Operation(summary = "Suivre un membre (réseau)")
|
|
@APIResponse(responseCode = "200", description = "Suivi activé")
|
|
@APIResponse(responseCode = "400", description = "Requête invalide")
|
|
@APIResponse(responseCode = "404", description = "Membre cible non trouvé")
|
|
public Response suivreMembre(@Parameter(description = "UUID du membre à suivre") @PathParam("id") UUID id) {
|
|
String email = securityIdentity.getPrincipal().getName();
|
|
try {
|
|
boolean following = membreSuiviService.follow(email, id);
|
|
return Response.ok(Map.of("following", following)).build();
|
|
} catch (IllegalArgumentException e) {
|
|
if (e.getMessage().contains("introuvable")) {
|
|
return Response.status(Response.Status.NOT_FOUND).entity(Map.of("message", e.getMessage())).build();
|
|
}
|
|
return Response.status(Response.Status.BAD_REQUEST).entity(Map.of("message", e.getMessage())).build();
|
|
}
|
|
}
|
|
|
|
@DELETE
|
|
@Path("/{id}/suivre")
|
|
@RolesAllowed({ "USER", "MEMBRE", "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MODERATEUR", "CONSULTANT", "SECRETAIRE", "GESTIONNAIRE_RH" })
|
|
@Operation(summary = "Ne plus suivre un membre (réseau)")
|
|
@APIResponse(responseCode = "200", description = "Suivi désactivé")
|
|
@APIResponse(responseCode = "400", description = "Requête invalide")
|
|
public Response nePlusSuivreMembre(@Parameter(description = "UUID du membre à ne plus suivre") @PathParam("id") UUID id) {
|
|
String email = securityIdentity.getPrincipal().getName();
|
|
try {
|
|
boolean following = membreSuiviService.unfollow(email, id);
|
|
return Response.ok(Map.of("following", following)).build();
|
|
} catch (IllegalArgumentException e) {
|
|
return Response.status(Response.Status.BAD_REQUEST).entity(Map.of("message", e.getMessage())).build();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Liste tous les membres actifs (statut compte = ACTIF).
|
|
* Utilisé notamment pour la création de campagnes de cotisations.
|
|
*/
|
|
@GET
|
|
@Path("/actifs")
|
|
@RolesAllowed({ "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "TRESORIER" })
|
|
@Operation(summary = "Membres actifs", description = "Liste tous les membres dont le compte est actif")
|
|
@APIResponse(responseCode = "200", description = "Liste des membres actifs")
|
|
public Response getMembresActifs() {
|
|
try {
|
|
LOG.info("GET /api/membres/actifs");
|
|
List<Membre> membres = membreService.listerMembresActifs();
|
|
List<MembreResponse> membresDTO = membreService.convertToResponseList(membres);
|
|
return Response.ok(membresDTO).build();
|
|
} catch (Exception e) {
|
|
LOG.errorf(e, "Erreur récupération membres actifs");
|
|
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
|
.entity(Map.of("error", e.getMessage())).build();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Liste les membres d'une organisation spécifique (statut ACTIF dans l'organisation).
|
|
* Utilisé pour la création de campagnes ciblées.
|
|
*/
|
|
@GET
|
|
@Path("/organisation/{organisationId}")
|
|
@RolesAllowed({ "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "TRESORIER" })
|
|
@Operation(summary = "Membres d'une organisation", description = "Liste les membres actifs d'une organisation")
|
|
@APIResponse(responseCode = "200", description = "Liste des membres")
|
|
public Response getMembresParOrganisation(
|
|
@Parameter(description = "UUID de l'organisation") @PathParam("organisationId") UUID organisationId) {
|
|
try {
|
|
LOG.infof("GET /api/membres/organisation/%s", organisationId);
|
|
List<dev.lions.unionflow.server.entity.MembreOrganisation> liens =
|
|
membreOrgRepository.findMembresActifsParOrganisation(organisationId);
|
|
List<MembreResponse> membresDTO = liens.stream()
|
|
.filter(mo -> mo.getMembre() != null)
|
|
.map(mo -> membreService.convertToResponse(mo.getMembre()))
|
|
.collect(java.util.stream.Collectors.toList());
|
|
return Response.ok(membresDTO).build();
|
|
} catch (Exception e) {
|
|
LOG.errorf(e, "Erreur récupération membres organisation %s", organisationId);
|
|
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
|
.entity(Map.of("error", e.getMessage())).build();
|
|
}
|
|
}
|
|
|
|
@GET
|
|
@Path("/recherche")
|
|
@Operation(summary = "Rechercher des membres par nom ou prénom")
|
|
@APIResponse(responseCode = "200", description = "Résultats de la recherche")
|
|
public Response rechercherMembres(
|
|
@Parameter(description = "Terme de recherche") @QueryParam("q") String recherche,
|
|
@Parameter(description = "Numéro de page (0-based)") @QueryParam("page") @DefaultValue("0") int page,
|
|
@Parameter(description = "Taille de la page") @QueryParam("size") @DefaultValue("20") int size,
|
|
@Parameter(description = "Champ de tri") @QueryParam("sort") @DefaultValue("nom") String sortField,
|
|
@Parameter(description = "Direction du tri (asc/desc)") @QueryParam("direction") @DefaultValue("asc") String sortDirection) {
|
|
|
|
LOG.infof("Recherche de membres avec le terme: %s", recherche);
|
|
if (recherche == null || recherche.trim().isEmpty()) {
|
|
return Response.status(Response.Status.BAD_REQUEST)
|
|
.entity(Map.of("message", "Le terme de recherche est requis"))
|
|
.build();
|
|
}
|
|
|
|
Sort sort = "desc".equalsIgnoreCase(sortDirection)
|
|
? Sort.by(sortField).descending()
|
|
: Sort.by(sortField).ascending();
|
|
|
|
List<Membre> membres = membreService.rechercherMembres(recherche.trim(), Page.of(page, size), sort);
|
|
List<MembreSummaryResponse> membresDTO = membreService.convertToSummaryResponseList(membres);
|
|
|
|
return Response.ok(membresDTO).build();
|
|
}
|
|
|
|
@GET
|
|
@Path("/stats")
|
|
@Operation(summary = "Obtenir les statistiques avancées des membres")
|
|
@APIResponse(responseCode = "200", description = "Statistiques complètes des membres")
|
|
public Response obtenirStatistiques() {
|
|
LOG.info("Récupération des statistiques avancées des membres");
|
|
Map<String, Object> statistiques = membreService.obtenirStatistiquesAvancees();
|
|
return Response.ok(statistiques).build();
|
|
}
|
|
|
|
@GET
|
|
@Path("/autocomplete/villes")
|
|
@Operation(summary = "Obtenir la liste des villes pour autocomplétion")
|
|
@APIResponse(responseCode = "200", description = "Liste des villes distinctes")
|
|
public Response obtenirVilles(
|
|
@Parameter(description = "Terme de recherche (optionnel)") @QueryParam("query") String query) {
|
|
LOG.infof("Récupération des villes pour autocomplétion - query: %s", query);
|
|
List<String> villes = membreService.obtenirVillesDistinctes(query);
|
|
return Response.ok(villes).build();
|
|
}
|
|
|
|
@GET
|
|
@Path("/autocomplete/professions")
|
|
@Operation(summary = "Obtenir la liste des professions pour autocomplétion")
|
|
@APIResponse(responseCode = "200", description = "Liste des professions distinctes")
|
|
public Response obtenirProfessions(
|
|
@Parameter(description = "Terme de recherche (optionnel)") @QueryParam("query") String query) {
|
|
LOG.infof("Récupération des professions pour autocomplétion - query: %s", query);
|
|
List<String> professions = membreService.obtenirProfessionsDistinctes(query);
|
|
return Response.ok(professions).build();
|
|
}
|
|
|
|
@GET
|
|
@Path("/recherche-avancee")
|
|
@Operation(summary = "Recherche avancée de membres avec filtres multiples (DEPRECATED)")
|
|
@APIResponse(responseCode = "200", description = "Résultats de la recherche avancée")
|
|
@Deprecated
|
|
public Response rechercheAvancee(
|
|
@Parameter(description = "Terme de recherche") @QueryParam("q") String recherche,
|
|
@Parameter(description = "Statut actif (true/false)") @QueryParam("actif") Boolean actif,
|
|
@Parameter(description = "Date d'adhésion minimum (YYYY-MM-DD)") @QueryParam("dateAdhesionMin") String dateAdhesionMin,
|
|
@Parameter(description = "Date d'adhésion maximum (YYYY-MM-DD)") @QueryParam("dateAdhesionMax") String dateAdhesionMax,
|
|
@Parameter(description = "Numéro de page (0-based)") @QueryParam("page") @DefaultValue("0") int page,
|
|
@Parameter(description = "Taille de la page") @QueryParam("size") @DefaultValue("20") int size,
|
|
@Parameter(description = "Champ de tri") @QueryParam("sort") @DefaultValue("nom") String sortField,
|
|
@Parameter(description = "Direction du tri (asc/desc)") @QueryParam("direction") @DefaultValue("asc") String sortDirection) {
|
|
|
|
LOG.infof(
|
|
"Recherche avancée de membres (DEPRECATED) - recherche: %s, actif: %s", recherche, actif);
|
|
|
|
Sort sort = "desc".equalsIgnoreCase(sortDirection)
|
|
? Sort.by(sortField).descending()
|
|
: Sort.by(sortField).ascending();
|
|
|
|
// Conversion des dates si fournies
|
|
java.time.LocalDate dateMin = dateAdhesionMin != null ? java.time.LocalDate.parse(dateAdhesionMin) : null;
|
|
java.time.LocalDate dateMax = dateAdhesionMax != null ? java.time.LocalDate.parse(dateAdhesionMax) : null;
|
|
|
|
List<Membre> membres = membreService.rechercheAvancee(
|
|
recherche, actif, dateMin, dateMax, Page.of(page, size), sort);
|
|
List<MembreSummaryResponse> membresDTO = membreService.convertToSummaryResponseList(membres);
|
|
|
|
return Response.ok(membresDTO).build();
|
|
}
|
|
|
|
/**
|
|
* Nouvelle recherche avancée avec critères complets et résultats enrichis
|
|
* Réservée aux super
|
|
* administrateurs pour des recherches sophistiquées
|
|
*/
|
|
@POST
|
|
@Path("/search/advanced")
|
|
@RolesAllowed({ "SUPER_ADMIN", "ADMIN", "ADMIN_ORGANISATION" })
|
|
@Operation(summary = "Recherche avancée de membres avec critères multiples", description = """
|
|
Recherche sophistiquée de membres avec de nombreux critères de filtrage :
|
|
- Recherche textuelle dans nom, prénom, email
|
|
- Filtres par organisation, rôles, statut
|
|
- Filtres par âge, région, profession
|
|
- Filtres par dates d'adhésion
|
|
- Résultats paginés avec statistiques
|
|
|
|
Réservée aux super administrateurs et administrateurs.
|
|
""")
|
|
@APIResponses({
|
|
@APIResponse(responseCode = "200", description = "Recherche effectuée avec succès", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = MembreSearchResultDTO.class), examples = @ExampleObject(name = "Exemple de résultats", value = """
|
|
{
|
|
"membres": [...],
|
|
"totalElements": 247,
|
|
"totalPages": 13,
|
|
"currentPage": 0,
|
|
"pageSize": 20,
|
|
"hasNext": true,
|
|
"hasPrevious": false,
|
|
"executionTimeMs": 45,
|
|
"statistics": {
|
|
"membresActifs": 230,
|
|
"membresInactifs": 17,
|
|
"ageMoyen": 34.5,
|
|
"nombreOrganisations": 12
|
|
}
|
|
}
|
|
"""))),
|
|
@APIResponse(responseCode = "400", description = "Critères de recherche invalides", content = @Content(mediaType = MediaType.APPLICATION_JSON, examples = @ExampleObject(value = """
|
|
{
|
|
"message": "Critères de recherche invalides",
|
|
"details": "La date minimum ne peut pas être postérieure à la date maximum"
|
|
}
|
|
"""))),
|
|
@APIResponse(responseCode = "403", description = "Accès non autorisé - Rôle SUPER_ADMIN, ADMIN ou ADMIN_ORGANISATION requis"),
|
|
@APIResponse(responseCode = "500", description = "Erreur interne du serveur")
|
|
})
|
|
@SecurityRequirement(name = "keycloak")
|
|
public Response searchMembresAdvanced(
|
|
@RequestBody(description = "Critères de recherche avancée", required = false, content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = MembreSearchCriteria.class), examples = @ExampleObject(name = "Exemple de critères", value = """
|
|
{
|
|
"query": "marie",
|
|
"statut": "ACTIF",
|
|
"ageMin": 25,
|
|
"ageMax": 45,
|
|
"region": "Dakar",
|
|
"roles": ["PRESIDENT", "SECRETAIRE"],
|
|
"dateAdhesionMin": "2020-01-01",
|
|
"includeInactifs": false
|
|
}
|
|
"""))) MembreSearchCriteria criteria,
|
|
@Parameter(description = "Numéro de page (0-based)", example = "0") @QueryParam("page") @DefaultValue("0") int page,
|
|
@Parameter(description = "Taille de la page", example = "20") @QueryParam("size") @DefaultValue("20") int size,
|
|
@Parameter(description = "Champ de tri", example = "nom") @QueryParam("sort") @DefaultValue("nom") String sortField,
|
|
@Parameter(description = "Direction du tri (asc/desc)", example = "asc") @QueryParam("direction") @DefaultValue("asc") String sortDirection) {
|
|
|
|
long startTime = System.currentTimeMillis();
|
|
|
|
// Validation des critères
|
|
if (criteria == null) {
|
|
LOG.warn("Recherche avancée de membres - critères null rejetés");
|
|
throw new IllegalArgumentException("Les critères de recherche sont requis");
|
|
}
|
|
|
|
LOG.infof(
|
|
"Recherche avancée de membres - critères: %s, page: %d, size: %d",
|
|
criteria.getDescription(), page, size);
|
|
|
|
// Nettoyage et validation des critères
|
|
criteria.sanitize();
|
|
|
|
if (!criteria.hasAnyCriteria()) {
|
|
throw new IllegalArgumentException("Au moins un critère de recherche doit être spécifié");
|
|
}
|
|
|
|
if (!criteria.isValid()) {
|
|
throw new IllegalArgumentException(
|
|
"Critères de recherche invalides: Vérifiez la cohérence des dates et des âges");
|
|
}
|
|
|
|
// Construction du tri
|
|
Sort sort = "desc".equalsIgnoreCase(sortDirection)
|
|
? Sort.by(sortField).descending()
|
|
: Sort.by(sortField).ascending();
|
|
|
|
// Exécution de la recherche
|
|
MembreSearchResultDTO result = membreService.searchMembresAdvanced(criteria, Page.of(page, size), sort);
|
|
|
|
// Calcul du temps d'exécution
|
|
long executionTime = System.currentTimeMillis() - startTime;
|
|
result.setExecutionTimeMs(executionTime);
|
|
|
|
LOG.infof(
|
|
"Recherche avancée terminée - %d résultats trouvés en %d ms",
|
|
result.getTotalElements(), executionTime);
|
|
|
|
return Response.ok(result).build();
|
|
}
|
|
|
|
@POST
|
|
@Path("/export/selection")
|
|
@RolesAllowed({ "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION" })
|
|
@Consumes(MediaType.APPLICATION_JSON)
|
|
@Produces("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
|
@Operation(summary = "Exporter une sélection de membres en Excel")
|
|
@APIResponse(responseCode = "200", description = "Fichier Excel généré")
|
|
public Response exporterSelectionMembres(
|
|
@Parameter(description = "Liste des IDs des membres à exporter") List<UUID> membreIds,
|
|
@Parameter(description = "Format d'export") @QueryParam("format") @DefaultValue("EXCEL") String format) {
|
|
LOG.infof("Export de %d membres sélectionnés", membreIds.size());
|
|
LOG.infof("Export de %d membres sélectionnés", membreIds.size());
|
|
byte[] excelData = membreService.exporterMembresSelectionnes(membreIds, format);
|
|
return Response.ok(excelData)
|
|
.header("Content-Disposition", "attachment; filename=\"membres_selection_" +
|
|
java.time.LocalDate.now() + "." + (format != null ? format.toLowerCase() : "xlsx") + "\"")
|
|
.build();
|
|
}
|
|
|
|
@POST
|
|
@Path("/import")
|
|
@Consumes(MediaType.MULTIPART_FORM_DATA)
|
|
@Produces(MediaType.APPLICATION_JSON)
|
|
@RolesAllowed({ "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION" })
|
|
@Operation(summary = "Importer des membres depuis un fichier Excel ou CSV",
|
|
description = "Format strict (colonnes obligatoires: nom, prenom, email, telephone). " +
|
|
"Si organisationId est fourni, les membres sont rattachés à l'organisation et le quota souscription (tranche) est respecté.")
|
|
@APIResponse(responseCode = "200", description = "Import terminé")
|
|
public Response importerMembres(
|
|
@RestForm("file") org.jboss.resteasy.reactive.multipart.FileUpload file,
|
|
@RestForm("fileName") String fileName,
|
|
@RestForm("organisationId") String organisationIdStr,
|
|
@RestForm("typeMembreDefaut") String typeMembreDefaut,
|
|
@RestForm("mettreAJourExistants") boolean mettreAJourExistants,
|
|
@RestForm("ignorerErreurs") boolean ignorerErreurs) {
|
|
|
|
if (file == null || file.size() == 0) {
|
|
return Response.status(Response.Status.BAD_REQUEST)
|
|
.entity(Map.of("error", "Aucun fichier fourni"))
|
|
.build();
|
|
}
|
|
|
|
if (fileName == null || fileName.isEmpty()) {
|
|
fileName = file.fileName();
|
|
}
|
|
|
|
if (typeMembreDefaut == null || typeMembreDefaut.isEmpty()) {
|
|
typeMembreDefaut = "ACTIF";
|
|
}
|
|
|
|
UUID organisationId = (organisationIdStr != null && !organisationIdStr.isEmpty())
|
|
? UUID.fromString(organisationIdStr)
|
|
: null;
|
|
|
|
// Validation périmètre ADMIN_ORGANISATION : doit avoir accès à l'organisation
|
|
java.util.Set<String> roles = securityIdentity.getRoles();
|
|
boolean onlyOrgAdmin = roles != null && roles.contains("ADMIN_ORGANISATION")
|
|
&& !roles.contains("ADMIN")
|
|
&& !roles.contains("SUPER_ADMIN");
|
|
|
|
if (onlyOrgAdmin) {
|
|
if (organisationId == null) {
|
|
return Response.status(Response.Status.BAD_REQUEST)
|
|
.entity(Map.of("error", "organisationId obligatoire pour ADMIN_ORGANISATION"))
|
|
.build();
|
|
}
|
|
|
|
java.security.Principal p2 = securityIdentity.getPrincipal();
|
|
String email = p2 != null ? p2.getName() : null;
|
|
|
|
if (email == null || email.isEmpty()) {
|
|
return Response.status(Response.Status.UNAUTHORIZED)
|
|
.entity(Map.of("error", "Utilisateur non identifié"))
|
|
.build();
|
|
}
|
|
|
|
List<UUID> userOrgIds = organisationService.listerOrganisationsPourUtilisateur(email)
|
|
.stream()
|
|
.map(dev.lions.unionflow.server.entity.Organisation::getId)
|
|
.collect(java.util.stream.Collectors.toList());
|
|
|
|
if (!userOrgIds.contains(organisationId)) {
|
|
return Response.status(Response.Status.FORBIDDEN)
|
|
.entity(Map.of("error", "Vous n'avez pas accès à cette organisation"))
|
|
.build();
|
|
}
|
|
|
|
LOG.infof("ADMIN_ORGANISATION %s : import autorisé pour organisation %s", email, organisationId);
|
|
}
|
|
|
|
InputStream fileInputStream;
|
|
try {
|
|
fileInputStream = java.nio.file.Files.newInputStream(file.uploadedFile());
|
|
} catch (java.io.IOException e) {
|
|
throw new RuntimeException("Erreur lors de la lecture du fichier: " + e.getMessage(), e);
|
|
}
|
|
dev.lions.unionflow.server.service.MembreImportExportService.ResultatImport resultat = membreService
|
|
.importerMembres(
|
|
fileInputStream, fileName, organisationId, typeMembreDefaut, mettreAJourExistants, ignorerErreurs);
|
|
|
|
Map<String, Object> response = new HashMap<>();
|
|
response.put("totalLignes", resultat.totalLignes);
|
|
response.put("lignesTraitees", resultat.lignesTraitees);
|
|
response.put("lignesErreur", resultat.lignesErreur);
|
|
response.put("erreurs", resultat.erreurs);
|
|
response.put("membresImportes", resultat.membresImportes);
|
|
|
|
return Response.ok(response).build();
|
|
}
|
|
|
|
@GET
|
|
@Path("/export")
|
|
@RolesAllowed({ "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION" })
|
|
@Produces("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
|
@Operation(summary = "Exporter des membres en Excel, CSV ou PDF")
|
|
@APIResponse(responseCode = "200", description = "Fichier exporté")
|
|
public Response exporterMembres(
|
|
@Parameter(description = "Format d'export") @QueryParam("format") @DefaultValue("EXCEL") String format,
|
|
@Parameter(description = "ID de l'organisation (optionnel)") @QueryParam("associationId") UUID associationId,
|
|
@Parameter(description = "Statut des membres") @QueryParam("statut") String statut,
|
|
@Parameter(description = "Type de membre") @QueryParam("type") String type,
|
|
@Parameter(description = "Date adhésion début") @QueryParam("dateAdhesionDebut") String dateAdhesionDebut,
|
|
@Parameter(description = "Date adhésion fin") @QueryParam("dateAdhesionFin") String dateAdhesionFin,
|
|
@Parameter(description = "Colonnes à exporter") @QueryParam("colonnes") List<String> colonnesExportList,
|
|
@Parameter(description = "Inclure les en-têtes") @QueryParam("inclureHeaders") @DefaultValue("true") boolean inclureHeaders,
|
|
@Parameter(description = "Formater les dates") @QueryParam("formaterDates") @DefaultValue("true") boolean formaterDates,
|
|
@Parameter(description = "Inclure un onglet statistiques (Excel uniquement)") @QueryParam("inclureStatistiques") @DefaultValue("false") boolean inclureStatistiques,
|
|
@Parameter(description = "Mot de passe pour chiffrer le fichier (optionnel)") @QueryParam("motDePasse") String motDePasse) {
|
|
|
|
List<MembreResponse> membres = membreService.listerMembresPourExport(
|
|
associationId, statut, type, dateAdhesionDebut, dateAdhesionFin);
|
|
|
|
byte[] exportData;
|
|
String contentType;
|
|
String extension;
|
|
|
|
List<String> colonnesExport = colonnesExportList != null ? colonnesExportList : new ArrayList<>();
|
|
|
|
if ("CSV".equalsIgnoreCase(format)) {
|
|
exportData = membreService.exporterVersCSV(membres, colonnesExport, inclureHeaders, formaterDates);
|
|
contentType = "text/csv";
|
|
extension = "csv";
|
|
} else if ("PDF".equalsIgnoreCase(format)) {
|
|
exportData = membreService.exporterVersPDF(membres, colonnesExport, inclureHeaders, formaterDates, inclureStatistiques);
|
|
contentType = "application/pdf";
|
|
extension = "pdf";
|
|
} else {
|
|
// Pour Excel, inclure les statistiques uniquement si demandé et si format Excel
|
|
boolean stats = inclureStatistiques && "EXCEL".equalsIgnoreCase(format);
|
|
exportData = membreService.exporterVersExcel(membres, colonnesExport, inclureHeaders, formaterDates, stats,
|
|
motDePasse);
|
|
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
extension = "xlsx";
|
|
}
|
|
|
|
return Response.ok(exportData)
|
|
.type(contentType)
|
|
.header("Content-Disposition", "attachment; filename=\"membres_export_" +
|
|
java.time.LocalDate.now() + "." + extension + "\"")
|
|
.build();
|
|
}
|
|
|
|
@GET
|
|
@Path("/import/modele")
|
|
@Produces("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
|
@Operation(summary = "Télécharger le modèle Excel pour l'import")
|
|
@APIResponse(responseCode = "200", description = "Modèle Excel généré")
|
|
public Response telechargerModeleImport() {
|
|
byte[] modele = membreService.genererModeleImport();
|
|
return Response.ok(modele)
|
|
.header("Content-Disposition", "attachment; filename=\"modele_import_membres.xlsx\"")
|
|
.build();
|
|
}
|
|
|
|
@PUT
|
|
@Path("/{id}/affecter-organisation")
|
|
@RolesAllowed({"ADMIN", "SUPER_ADMIN"})
|
|
@Operation(
|
|
summary = "Affecter un membre à une organisation",
|
|
description = "Crée le lien MembreOrganisation (statut EN_ATTENTE_VALIDATION) si le membre n'est pas encore rattaché à une organisation. Idempotent.")
|
|
@APIResponse(responseCode = "200", description = "Membre affecté à l'organisation")
|
|
@APIResponse(responseCode = "404", description = "Membre ou organisation non trouvé")
|
|
@APIResponse(responseCode = "403", description = "Accès réservé aux ADMIN / SUPER_ADMIN")
|
|
public Response affecterOrganisation(
|
|
@Parameter(description = "UUID du membre") @PathParam("id") UUID id,
|
|
@Parameter(description = "UUID de l'organisation") @QueryParam("organisationId") UUID organisationId) {
|
|
LOG.infof("Affectation du membre %s à l'organisation %s", id, organisationId);
|
|
|
|
if (organisationId == null) {
|
|
return Response.status(Response.Status.BAD_REQUEST)
|
|
.entity(Map.of("error", "organisationId est obligatoire"))
|
|
.build();
|
|
}
|
|
|
|
Membre membre = membreService.affecterOrganisation(id, organisationId);
|
|
return Response.ok(membreService.convertToResponse(membre)).build();
|
|
}
|
|
|
|
@PUT
|
|
@Path("/{id}/promouvoir-admin-organisation")
|
|
@RolesAllowed({"ADMIN", "SUPER_ADMIN"})
|
|
@Operation(
|
|
summary = "Promouvoir un membre en administrateur d'organisation",
|
|
description = "Passe le membre en statut ACTIF et lui assigne le rôle ADMIN_ORGANISATION dans Keycloak. "
|
|
+ "Réservé aux super administrateurs de la plateforme. "
|
|
+ "Le compte est immédiatement opérationnel sans validation intermédiaire.")
|
|
@APIResponse(responseCode = "200", description = "Membre promu administrateur d'organisation")
|
|
@APIResponse(responseCode = "404", description = "Membre non trouvé")
|
|
@APIResponse(responseCode = "403", description = "Accès réservé aux ADMIN / SUPER_ADMIN")
|
|
public Response promouvoirAdminOrganisation(
|
|
@Parameter(description = "UUID du membre à promouvoir") @PathParam("id") UUID id) {
|
|
LOG.infof("Promotion admin d'organisation pour le membre ID: %s", id);
|
|
|
|
Membre membrePromu = membreService.promouvoirAdminOrganisation(id);
|
|
|
|
// Assigner ADMIN_ORGANISATION dans Keycloak (non bloquant)
|
|
try {
|
|
keycloakSyncService.promouvoirAdminOrganisationDansKeycloak(id);
|
|
} catch (Exception e) {
|
|
LOG.warnf("Promotion Keycloak échouée pour %s (non bloquant): %s", membrePromu.getEmail(), e.getMessage());
|
|
}
|
|
|
|
return Response.ok(membreService.convertToResponse(membrePromu)).build();
|
|
}
|
|
|
|
@PUT
|
|
@Path("/{id}/activer")
|
|
@RolesAllowed({"ADMIN", "SUPER_ADMIN"})
|
|
@Operation(summary = "Activer un membre", description = "Passe le membre en statut ACTIF et lui assigne le rôle MEMBRE_ACTIF dans Keycloak.")
|
|
@APIResponse(responseCode = "200", description = "Membre activé avec succès")
|
|
@APIResponse(responseCode = "404", description = "Membre non trouvé")
|
|
public Response activerMembre(@Parameter(description = "UUID du membre") @PathParam("id") UUID id) {
|
|
LOG.infof("Activation du membre ID: %s", id);
|
|
|
|
Membre membreActive = membreService.activerMembre(id);
|
|
|
|
// Assigner le rôle MEMBRE_ACTIF dans Keycloak (non bloquant)
|
|
try {
|
|
keycloakSyncService.activerMembreDansKeycloak(id);
|
|
} catch (Exception e) {
|
|
LOG.warnf("Activation Keycloak échouée pour %s (non bloquant): %s", membreActive.getEmail(), e.getMessage());
|
|
}
|
|
|
|
return Response.ok(membreService.convertToResponse(membreActive)).build();
|
|
}
|
|
|
|
@PUT
|
|
@Path("/{id}/reinitialiser-mot-de-passe")
|
|
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"})
|
|
@Operation(summary = "Réinitialiser le mot de passe d'un membre",
|
|
description = "Génère un nouveau mot de passe temporaire et le définit dans Keycloak. Le nouveau mot de passe est retourné une seule fois dans la réponse.")
|
|
@APIResponse(responseCode = "200", description = "Mot de passe réinitialisé, retourné dans motDePasseTemporaire")
|
|
@APIResponse(responseCode = "404", description = "Membre non trouvé")
|
|
@APIResponse(responseCode = "400", description = "Le membre n'a pas de compte Keycloak")
|
|
public Response reinitialiserMotDePasse(
|
|
@Parameter(description = "UUID du membre") @PathParam("id") UUID id) {
|
|
LOG.infof("Réinitialisation mot de passe pour membre ID: %s", id);
|
|
|
|
Membre membre = membreService.trouverParId(id)
|
|
.orElseThrow(() -> new NotFoundException("Membre non trouvé avec l'ID: " + id));
|
|
|
|
String newPassword;
|
|
try {
|
|
newPassword = keycloakSyncService.reinitialiserMotDePasse(id);
|
|
} catch (IllegalStateException e) {
|
|
return Response.status(Response.Status.BAD_REQUEST)
|
|
.entity(Map.of("error", e.getMessage())).build();
|
|
}
|
|
|
|
dev.lions.unionflow.server.api.dto.membre.response.MembreResponse response =
|
|
membreService.convertToResponse(membre);
|
|
response.setMotDePasseTemporaire(newPassword);
|
|
|
|
LOG.infof("Mot de passe réinitialisé pour %s", membre.getEmail());
|
|
return Response.ok(response).build();
|
|
}
|
|
|
|
@GET
|
|
@Path("/export/count")
|
|
@Produces(MediaType.APPLICATION_JSON)
|
|
@Operation(summary = "Compter les membres selon les filtres pour l'export")
|
|
@APIResponse(responseCode = "200", description = "Nombre de membres correspondant aux critères")
|
|
public Response compterMembresPourExport(
|
|
@Parameter(description = "ID de l'organisation (optionnel)") @QueryParam("associationId") UUID associationId,
|
|
@Parameter(description = "Statut des membres") @QueryParam("statut") String statut,
|
|
@Parameter(description = "Type de membre") @QueryParam("type") String type,
|
|
@Parameter(description = "Date adhésion début") @QueryParam("dateAdhesionDebut") String dateAdhesionDebut,
|
|
@Parameter(description = "Date adhésion fin") @QueryParam("dateAdhesionFin") String dateAdhesionFin) {
|
|
|
|
List<MembreResponse> membres = membreService.listerMembresPourExport(
|
|
associationId, statut, type, dateAdhesionDebut, dateAdhesionFin);
|
|
|
|
return Response.ok(Map.of("count", membres.size())).build();
|
|
}
|
|
|
|
// =========================================================================
|
|
// Endpoints cycle de vie des adhésions (MemberLifecycleService)
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Invite un membre existant à rejoindre une organisation.
|
|
* Crée un lien MembreOrganisation au statut INVITE avec token + expiration 7j.
|
|
*/
|
|
@PUT
|
|
@Path("/{membreId}/inviter-organisation")
|
|
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"})
|
|
@Operation(summary = "Inviter un membre dans une organisation",
|
|
description = "Crée une invitation (statut INVITE) pour un membre existant. Token valable 7 jours.")
|
|
@APIResponse(responseCode = "200", description = "Invitation créée")
|
|
@APIResponse(responseCode = "404", description = "Membre ou organisation introuvable")
|
|
@APIResponse(responseCode = "409", description = "Membre déjà lié à cette organisation")
|
|
public Response inviterMembre(
|
|
@Parameter(description = "UUID du membre à inviter") @PathParam("membreId") UUID membreId,
|
|
@Parameter(description = "UUID de l'organisation") @QueryParam("organisationId") UUID organisationId,
|
|
@Parameter(description = "Rôle proposé (optionnel)") @QueryParam("roleOrg") String roleOrg) {
|
|
|
|
if (organisationId == null) {
|
|
return Response.status(Response.Status.BAD_REQUEST)
|
|
.entity(Map.of("error", "organisationId est obligatoire")).build();
|
|
}
|
|
|
|
Membre membre = membreService.trouverParId(membreId)
|
|
.orElseThrow(() -> new NotFoundException("Membre introuvable : " + membreId));
|
|
Organisation organisation = organisationService.trouverParId(organisationId)
|
|
.orElseThrow(() -> new NotFoundException("Organisation introuvable : " + organisationId));
|
|
|
|
UUID adminId = resolveCurrentAdminId();
|
|
try {
|
|
var lien = memberLifecycleService.inviterMembre(membre, organisation, adminId, roleOrg);
|
|
return Response.ok(Map.of(
|
|
"membreOrgId", lien.getId(),
|
|
"statut", lien.getStatutMembre(),
|
|
"tokenInvitation", lien.getTokenInvitation(),
|
|
"expiresAt", lien.getDateExpirationInvitation()
|
|
)).build();
|
|
} catch (IllegalStateException e) {
|
|
return Response.status(Response.Status.CONFLICT)
|
|
.entity(Map.of("error", e.getMessage())).build();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Accepte une invitation via son token (INVITE → EN_ATTENTE_VALIDATION).
|
|
* Endpoint public — le membre clique sur le lien reçu par email.
|
|
*/
|
|
@POST
|
|
@Path("/accepter-invitation/{token}")
|
|
@PermitAll
|
|
@Operation(summary = "Accepter une invitation",
|
|
description = "Valide le token d'invitation et passe l'adhésion en EN_ATTENTE_VALIDATION.")
|
|
@APIResponse(responseCode = "200", description = "Invitation acceptée")
|
|
@APIResponse(responseCode = "400", description = "Token invalide ou expiré")
|
|
public Response accepterInvitation(
|
|
@Parameter(description = "Token d'invitation") @PathParam("token") String token) {
|
|
try {
|
|
var lien = memberLifecycleService.accepterInvitation(token);
|
|
return Response.ok(Map.of(
|
|
"membreOrgId", lien.getId(),
|
|
"statut", lien.getStatutMembre(),
|
|
"organisation", lien.getOrganisation().getNom()
|
|
)).build();
|
|
} catch (IllegalArgumentException e) {
|
|
return Response.status(Response.Status.NOT_FOUND)
|
|
.entity(Map.of("error", e.getMessage())).build();
|
|
} catch (IllegalStateException e) {
|
|
return Response.status(Response.Status.BAD_REQUEST)
|
|
.entity(Map.of("error", e.getMessage())).build();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Active une adhésion (EN_ATTENTE_VALIDATION / INVITE / SUSPENDU → ACTIF).
|
|
*/
|
|
@PUT
|
|
@Path("/{membreOrgId}/activer-adhesion")
|
|
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"})
|
|
@Operation(summary = "Activer une adhésion",
|
|
description = "Transitions autorisées : EN_ATTENTE_VALIDATION, INVITE, SUSPENDU → ACTIF.")
|
|
@APIResponse(responseCode = "200", description = "Adhésion activée")
|
|
@APIResponse(responseCode = "404", description = "Lien membre-organisation introuvable")
|
|
@APIResponse(responseCode = "409", description = "Transition non autorisée depuis le statut actuel")
|
|
public Response activerAdhesion(
|
|
@Parameter(description = "UUID du lien membre-organisation") @PathParam("membreOrgId") UUID membreOrgId,
|
|
Map<String, String> body) {
|
|
|
|
String motif = body != null ? body.get("motif") : null;
|
|
UUID adminId = resolveCurrentAdminId();
|
|
try {
|
|
var lien = memberLifecycleService.activerMembre(membreOrgId, adminId, motif);
|
|
Map<String, Object> result = new HashMap<>();
|
|
result.put("membreOrgId", lien.getId());
|
|
result.put("statut", lien.getStatutMembre());
|
|
result.put("dateChangementStatut", lien.getDateChangementStatut());
|
|
return Response.ok(result).build();
|
|
} catch (IllegalArgumentException e) {
|
|
return Response.status(Response.Status.NOT_FOUND)
|
|
.entity(Map.of("error", e.getMessage())).build();
|
|
} catch (IllegalStateException e) {
|
|
return Response.status(Response.Status.CONFLICT)
|
|
.entity(Map.of("error", e.getMessage())).build();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Suspend une adhésion (ACTIF → SUSPENDU).
|
|
*/
|
|
@PUT
|
|
@Path("/{membreOrgId}/suspendre-adhesion")
|
|
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"})
|
|
@Operation(summary = "Suspendre une adhésion", description = "Transition autorisée : ACTIF → SUSPENDU.")
|
|
@APIResponse(responseCode = "200", description = "Adhésion suspendue")
|
|
@APIResponse(responseCode = "404", description = "Lien membre-organisation introuvable")
|
|
@APIResponse(responseCode = "409", description = "Transition non autorisée")
|
|
public Response suspendrAdhesion(
|
|
@Parameter(description = "UUID du lien membre-organisation") @PathParam("membreOrgId") UUID membreOrgId,
|
|
Map<String, String> body) {
|
|
|
|
String motif = body != null ? body.get("motif") : null;
|
|
UUID adminId = resolveCurrentAdminId();
|
|
try {
|
|
var lien = memberLifecycleService.suspendreMembre(membreOrgId, adminId, motif);
|
|
Map<String, Object> result = new HashMap<>();
|
|
result.put("membreOrgId", lien.getId());
|
|
result.put("statut", lien.getStatutMembre());
|
|
result.put("dateChangementStatut", lien.getDateChangementStatut());
|
|
return Response.ok(result).build();
|
|
} catch (IllegalArgumentException e) {
|
|
return Response.status(Response.Status.NOT_FOUND)
|
|
.entity(Map.of("error", e.getMessage())).build();
|
|
} catch (IllegalStateException e) {
|
|
return Response.status(Response.Status.CONFLICT)
|
|
.entity(Map.of("error", e.getMessage())).build();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Radie un membre d'une organisation (→ RADIE).
|
|
*/
|
|
@PUT
|
|
@Path("/{membreOrgId}/radier-adhesion")
|
|
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"})
|
|
@Operation(summary = "Radier un membre d'une organisation")
|
|
@APIResponse(responseCode = "200", description = "Adhésion radiée")
|
|
@APIResponse(responseCode = "404", description = "Lien membre-organisation introuvable")
|
|
public Response radierAdhesion(
|
|
@Parameter(description = "UUID du lien membre-organisation") @PathParam("membreOrgId") UUID membreOrgId,
|
|
Map<String, String> body) {
|
|
|
|
MembreOrganisation lienCheck = membreOrgRepository.findByIdOptional(membreOrgId)
|
|
.orElseThrow(() -> new NotFoundException("Lien membre-organisation introuvable: " + membreOrgId));
|
|
verifierOwnershipEtProtectionAdmin(lienCheck);
|
|
|
|
String motif = body != null ? body.get("motif") : null;
|
|
UUID adminId = resolveCurrentAdminId();
|
|
try {
|
|
var lien = memberLifecycleService.radierMembre(membreOrgId, adminId, motif);
|
|
Map<String, Object> result = new HashMap<>();
|
|
result.put("membreOrgId", lien.getId());
|
|
result.put("statut", lien.getStatutMembre());
|
|
result.put("dateChangementStatut", lien.getDateChangementStatut());
|
|
return Response.ok(result).build();
|
|
} catch (IllegalArgumentException e) {
|
|
return Response.status(Response.Status.NOT_FOUND)
|
|
.entity(Map.of("error", e.getMessage())).build();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Archive une adhésion (→ ARCHIVE) sans supprimer l'historique.
|
|
*/
|
|
@PUT
|
|
@Path("/{membreOrgId}/archiver-adhesion")
|
|
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"})
|
|
@Operation(summary = "Archiver une adhésion",
|
|
description = "Conserve l'historique sans supprimer le lien membre-organisation.")
|
|
@APIResponse(responseCode = "200", description = "Adhésion archivée")
|
|
@APIResponse(responseCode = "404", description = "Lien membre-organisation introuvable")
|
|
public Response archiverAdhesion(
|
|
@Parameter(description = "UUID du lien membre-organisation") @PathParam("membreOrgId") UUID membreOrgId,
|
|
Map<String, String> body) {
|
|
|
|
MembreOrganisation lienCheck = membreOrgRepository.findByIdOptional(membreOrgId)
|
|
.orElseThrow(() -> new NotFoundException("Lien membre-organisation introuvable: " + membreOrgId));
|
|
verifierOwnershipEtProtectionAdmin(lienCheck);
|
|
|
|
String motif = body != null ? body.get("motif") : null;
|
|
try {
|
|
var lien = memberLifecycleService.archiverMembre(membreOrgId, motif);
|
|
Map<String, Object> result = new HashMap<>();
|
|
result.put("membreOrgId", lien.getId());
|
|
result.put("statut", lien.getStatutMembre());
|
|
result.put("dateChangementStatut", lien.getDateChangementStatut());
|
|
return Response.ok(result).build();
|
|
} catch (IllegalArgumentException e) {
|
|
return Response.status(Response.Status.NOT_FOUND)
|
|
.entity(Map.of("error", e.getMessage())).build();
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Endpoints lifecycle par membreId + organisationId (sans membreOrgId)
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Retourne le statut d'adhésion d'un membre dans une organisation.
|
|
* Utilisé par le profil membre pour afficher les boutons d'action contextuels.
|
|
*/
|
|
@GET
|
|
@Path("/{membreId}/adhesion")
|
|
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MEMBRE", "USER"})
|
|
@Operation(summary = "Statut d'adhésion d'un membre dans une organisation")
|
|
@APIResponse(responseCode = "200", description = "Statut d'adhésion")
|
|
@APIResponse(responseCode = "404", description = "Aucun lien membre-organisation trouvé")
|
|
public Response getAdhesionStatut(
|
|
@Parameter(description = "UUID du membre") @PathParam("membreId") UUID membreId,
|
|
@Parameter(description = "UUID de l'organisation") @QueryParam("organisationId") UUID organisationId) {
|
|
|
|
if (organisationId == null) {
|
|
return Response.status(Response.Status.BAD_REQUEST)
|
|
.entity(Map.of("error", "organisationId requis")).build();
|
|
}
|
|
return membreOrgRepository.findByMembreIdAndOrganisationId(membreId, organisationId)
|
|
.map(lien -> Response.ok(Map.of(
|
|
"membreOrgId", lien.getId(),
|
|
"statut", lien.getStatutMembre(),
|
|
"dateInvitation", lien.getDateInvitation() != null ? lien.getDateInvitation().toString() : "",
|
|
"dateExpiration", lien.getDateExpirationInvitation() != null ? lien.getDateExpirationInvitation().toString() : "",
|
|
"roleOrg", lien.getRoleOrg() != null ? lien.getRoleOrg() : "",
|
|
"motifStatut", lien.getMotifStatut() != null ? lien.getMotifStatut() : ""
|
|
)).build())
|
|
.orElse(Response.status(Response.Status.NOT_FOUND)
|
|
.entity(Map.of("error", "Aucune adhésion trouvée")).build());
|
|
}
|
|
|
|
/**
|
|
* Active l'adhésion d'un membre (EN_ATTENTE/INVITE/SUSPENDU → ACTIF)
|
|
* en passant par membreId + organisationId plutôt que membreOrgId.
|
|
*/
|
|
@PUT
|
|
@Path("/{membreId}/adhesion/activer")
|
|
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"})
|
|
@Operation(summary = "Activer l'adhésion par membreId + organisationId")
|
|
@APIResponse(responseCode = "200", description = "Adhésion activée")
|
|
@APIResponse(responseCode = "404", description = "Lien membre-organisation introuvable")
|
|
public Response activerAdhesionParMembre(
|
|
@Parameter(description = "UUID du membre") @PathParam("membreId") UUID membreId,
|
|
@Parameter(description = "UUID de l'organisation") @QueryParam("organisationId") UUID organisationId,
|
|
Map<String, String> body) {
|
|
|
|
if (organisationId == null) {
|
|
return Response.status(Response.Status.BAD_REQUEST)
|
|
.entity(Map.of("error", "organisationId requis")).build();
|
|
}
|
|
MembreOrganisation lien = membreOrgRepository
|
|
.findByMembreIdAndOrganisationId(membreId, organisationId)
|
|
.orElse(null);
|
|
if (lien == null) {
|
|
return Response.status(Response.Status.NOT_FOUND)
|
|
.entity(Map.of("error", "Aucune adhésion trouvée")).build();
|
|
}
|
|
verifierOwnershipEtProtectionAdmin(lien);
|
|
String motif = body != null ? body.get("motif") : null;
|
|
UUID adminId = resolveCurrentAdminId();
|
|
try {
|
|
var updated = memberLifecycleService.activerMembre(lien.getId(), adminId, motif);
|
|
return Response.ok(Map.of("statut", updated.getStatutMembre())).build();
|
|
} catch (IllegalStateException e) {
|
|
return Response.status(Response.Status.CONFLICT).entity(Map.of("error", e.getMessage())).build();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Suspend l'adhésion d'un membre (ACTIF → SUSPENDU) par membreId + organisationId.
|
|
*/
|
|
@PUT
|
|
@Path("/{membreId}/adhesion/suspendre")
|
|
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"})
|
|
@Operation(summary = "Suspendre l'adhésion par membreId + organisationId")
|
|
@APIResponse(responseCode = "200", description = "Adhésion suspendue")
|
|
@APIResponse(responseCode = "404", description = "Lien membre-organisation introuvable")
|
|
public Response suspendrAdhesionParMembre(
|
|
@Parameter(description = "UUID du membre") @PathParam("membreId") UUID membreId,
|
|
@Parameter(description = "UUID de l'organisation") @QueryParam("organisationId") UUID organisationId,
|
|
Map<String, String> body) {
|
|
|
|
if (organisationId == null) {
|
|
return Response.status(Response.Status.BAD_REQUEST)
|
|
.entity(Map.of("error", "organisationId requis")).build();
|
|
}
|
|
MembreOrganisation lien = membreOrgRepository
|
|
.findByMembreIdAndOrganisationId(membreId, organisationId)
|
|
.orElse(null);
|
|
if (lien == null) {
|
|
return Response.status(Response.Status.NOT_FOUND)
|
|
.entity(Map.of("error", "Aucune adhésion trouvée")).build();
|
|
}
|
|
verifierOwnershipEtProtectionAdmin(lien);
|
|
String motif = body != null ? body.get("motif") : null;
|
|
UUID adminId = resolveCurrentAdminId();
|
|
try {
|
|
var updated = memberLifecycleService.suspendreMembre(lien.getId(), adminId, motif);
|
|
return Response.ok(Map.of("statut", updated.getStatutMembre())).build();
|
|
} catch (IllegalStateException e) {
|
|
return Response.status(Response.Status.CONFLICT).entity(Map.of("error", e.getMessage())).build();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Radie un membre d'une organisation par membreId + organisationId.
|
|
*/
|
|
@PUT
|
|
@Path("/{membreId}/adhesion/radier")
|
|
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"})
|
|
@Operation(summary = "Radier par membreId + organisationId")
|
|
@APIResponse(responseCode = "200", description = "Adhésion radiée")
|
|
public Response radierAdhesionParMembre(
|
|
@Parameter(description = "UUID du membre") @PathParam("membreId") UUID membreId,
|
|
@Parameter(description = "UUID de l'organisation") @QueryParam("organisationId") UUID organisationId,
|
|
Map<String, String> body) {
|
|
|
|
if (organisationId == null) {
|
|
return Response.status(Response.Status.BAD_REQUEST)
|
|
.entity(Map.of("error", "organisationId requis")).build();
|
|
}
|
|
MembreOrganisation lien = membreOrgRepository
|
|
.findByMembreIdAndOrganisationId(membreId, organisationId)
|
|
.orElse(null);
|
|
if (lien == null) {
|
|
return Response.status(Response.Status.NOT_FOUND)
|
|
.entity(Map.of("error", "Aucune adhésion trouvée")).build();
|
|
}
|
|
verifierOwnershipEtProtectionAdmin(lien);
|
|
String motif = body != null ? body.get("motif") : null;
|
|
var updated = memberLifecycleService.radierMembre(lien.getId(), resolveCurrentAdminId(), motif);
|
|
return Response.ok(Map.of("statut", updated.getStatutMembre())).build();
|
|
}
|
|
|
|
/**
|
|
* Trouve un membre par son numéro de membre (ex: MBR-0001).
|
|
* Utilisé notamment pour la recherche de parrain lors de l'inscription.
|
|
*/
|
|
@GET
|
|
@Path("/numero/{numeroMembre}")
|
|
@RolesAllowed({ "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MODERATEUR", "MEMBRE", "USER" })
|
|
@Operation(summary = "Trouver un membre par son numéro")
|
|
@APIResponse(responseCode = "200", description = "Membre trouvé")
|
|
@APIResponse(responseCode = "404", description = "Membre non trouvé")
|
|
public Response obtenirParNumero(@PathParam("numeroMembre") String numeroMembre) {
|
|
LOG.infof("GET /api/membres/numero/%s", numeroMembre);
|
|
Membre membre = membreService.trouverParNumeroMembre(numeroMembre)
|
|
.orElseThrow(() -> new NotFoundException("Membre non trouvé avec le numéro: " + numeroMembre));
|
|
return Response.ok(membreService.convertToResponse(membre)).build();
|
|
}
|
|
|
|
/**
|
|
* Vérifie qu'un ADMIN_ORGANISATION :
|
|
* 1. agit bien sur une organisation dont il est responsable (ownership),
|
|
* 2. ne tente pas d'agir sur un autre admin (ORGADMIN) ou super-admin (SUPERADMIN).
|
|
*
|
|
* Sans effet si l'appelant est SUPER_ADMIN ou ADMIN (ils ont accès total).
|
|
*
|
|
* @throws ForbiddenException si l'une des règles est violée
|
|
*/
|
|
private void verifierOwnershipEtProtectionAdmin(MembreOrganisation lien) {
|
|
java.util.Set<String> roles = securityIdentity.getRoles();
|
|
boolean isOrgAdminOnly = roles != null
|
|
&& roles.contains("ADMIN_ORGANISATION")
|
|
&& !roles.contains("ADMIN")
|
|
&& !roles.contains("SUPER_ADMIN");
|
|
|
|
if (!isOrgAdminOnly) {
|
|
return; // SUPER_ADMIN / ADMIN : accès total, pas de vérification supplémentaire
|
|
}
|
|
|
|
// ── 1. Ownership : l'org cible doit appartenir à l'admin connecté ──────
|
|
String email = securityIdentity.getPrincipal().getName();
|
|
java.util.List<UUID> orgIds = organisationService
|
|
.listerOrganisationsPourUtilisateur(email)
|
|
.stream()
|
|
.map(Organisation::getId)
|
|
.collect(java.util.stream.Collectors.toList());
|
|
|
|
if (lien.getOrganisation() == null || !orgIds.contains(lien.getOrganisation().getId())) {
|
|
LOG.warnf("ADMIN_ORGANISATION %s tente d'agir sur org %s qui n'est pas la sienne",
|
|
email, lien.getOrganisation() != null ? lien.getOrganisation().getId() : "null");
|
|
throw new ForbiddenException("Vous n'êtes pas autorisé à gérer les membres de cette organisation.");
|
|
}
|
|
|
|
// ── 2. Protection : interdire d'agir sur un admin ou super-admin ────────
|
|
Membre cible = lien.getMembre();
|
|
if (cible != null && cible.getId() != null) {
|
|
long adminCount = membreRoleRepository.count(
|
|
"membreOrganisation.membre.id = ?1 AND role.code IN (?2) AND actif = true",
|
|
cible.getId(), java.util.List.of("ORGADMIN", "SUPERADMIN"));
|
|
if (adminCount > 0) {
|
|
LOG.warnf("ADMIN_ORGANISATION %s tente d'agir sur l'admin/superadmin membre=%s",
|
|
email, cible.getId());
|
|
throw new ForbiddenException(
|
|
"Vous ne pouvez pas modifier le statut d'un administrateur ou super-administrateur.");
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Résout l'UUID de l'admin connecté depuis le JWT subject. */
|
|
private UUID resolveCurrentAdminId() {
|
|
try {
|
|
String sub = jwt != null ? jwt.getSubject() : null;
|
|
return sub != null ? UUID.fromString(sub) : null;
|
|
} catch (Exception e) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|