Files
unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java
2026-04-24 16:19:25 +00:00

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;
}
}
}