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 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 roles = securityIdentity.getRoles(); boolean onlyOrgAdmin = roles != null && roles.contains("ADMIN_ORGANISATION") && !roles.contains("ADMIN") && !roles.contains("SUPER_ADMIN"); List 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 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 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 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 keycloakRoles = securityIdentity.getRoles(); // Filtrer les rôles internes Keycloak (offline_access, uma_authorization, etc.) java.util.List 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 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 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 membres = membreService.listerMembresActifs(); List 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 liens = membreOrgRepository.findMembresActifsParOrganisation(organisationId); List 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 membres = membreService.rechercherMembres(recherche.trim(), Page.of(page, size), sort); List 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 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 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 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 membres = membreService.rechercheAvancee( recherche, actif, dateMin, dateMax, Page.of(page, size), sort); List 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 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 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 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 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 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 membres = membreService.listerMembresPourExport( associationId, statut, type, dateAdhesionDebut, dateAdhesionFin); byte[] exportData; String contentType; String extension; List 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 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 body) { String motif = body != null ? body.get("motif") : null; UUID adminId = resolveCurrentAdminId(); try { var lien = memberLifecycleService.activerMembre(membreOrgId, adminId, motif); Map 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 body) { String motif = body != null ? body.get("motif") : null; UUID adminId = resolveCurrentAdminId(); try { var lien = memberLifecycleService.suspendreMembre(membreOrgId, adminId, motif); Map 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 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 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 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 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 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 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 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 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 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; } } }