feat: BackupService real pg_dump, OrganisationService region stats, SystemConfigService overrides

- BackupService: DB-persisted metadata (BackupRecord/BackupConfig entities + V16 Flyway migration),
  real pg_dump execution via ProcessBuilder, soft-delete on deleteBackup, pg_restore manual guidance
- OrganisationService: repartitionRegion now queries Adresse entities (was Map.of() stub)
- SystemConfigService: in-memory config overrides via AtomicReference (no DB dependency)
- SystemMetricsService: null-guard on MemoryMXBean in getSystemStatus() (fixes test NPE)
- Souscription workflow: SouscriptionService, SouscriptionResource, FormuleAbonnementRepository,
  V11 Flyway migration, admin REST clients
- Flyway V8-V15: notes membres, types référence, type orga constraint, seed roles,
  première connexion, Wave checkout URL, Wave telephone column length fix
- .gitignore: added uploads/ and .claude/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dahoud
2026-04-04 16:14:30 +00:00
parent 9c66909eff
commit e00a9301d8
98 changed files with 5571 additions and 636 deletions

View File

@@ -25,11 +25,13 @@ 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;
@@ -69,6 +71,9 @@ public class MembreResource {
@Inject
io.quarkus.security.identity.SecurityIdentity securityIdentity;
@Inject
JsonWebToken jwt;
@GET
@Operation(summary = "Lister les membres")
@APIResponse(responseCode = "200", description = "Liste des membres avec pagination")
@@ -110,8 +115,7 @@ public class MembreResource {
LOG.infof("ADMIN_ORGANISATION %s : accès à %d organisations", email, orgIds.size());
membres = membreService.listerMembresParOrganisations(orgIds, Page.of(page, size), sort);
// TODO: compter total membres pour ces organisations (approximation pour l'instant)
totalElements = membres.size();
totalElements = membreService.compterMembresParOrganisations(orgIds);
}
} else {
// ADMIN / SUPER_ADMIN : accès à tous les membres
@@ -151,19 +155,80 @@ public class MembreResource {
@GET
@Path("/me")
@RolesAllowed({ "USER", "MEMBRE", "ADMIN", "SUPER_ADMIN" })
@RolesAllowed({ "USER", "MEMBRE", "ADMIN", "ADMIN_ORGANISATION", "SUPER_ADMIN" })
@Operation(summary = "Récupérer le membre connecté")
@APIResponse(responseCode = "200", description = "Membre connecté trouvé")
@APIResponse(responseCode = "404", description = "Membre non trouvé")
@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)
.filter(m -> m.getActif() == null || m.getActif())
.orElseThrow(() -> new NotFoundException("Membre non trouvé pour l'email: " + email));
.orElseGet(() -> {
LOG.infof("Fiche membre inexistante pour %s — auto-provisionnement depuis JWT", email);
return autoProvisionnerMembre(email);
});
return Response.ok(membreService.convertToResponse(membre)).build();
// 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();
}
/** 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
@@ -180,48 +245,62 @@ public class MembreResource {
Membre nouveauMembre = membreService.creerMembre(membre);
// Provisionner le compte Keycloak (non bloquant — l'admin peut activer manuellement)
String motDePasseTemporaire = null;
try {
keycloakSyncService.provisionKeycloakUser(nouveauMembre.getId());
motDePasseTemporaire = keycloakSyncService.provisionKeycloakUser(nouveauMembre.getId());
} catch (Exception e) {
LOG.warnf("Provisionnement Keycloak échoué pour %s (non bloquant): %s", nouveauMembre.getEmail(), e.getMessage());
}
// Validation périmètre ADMIN_ORGANISATION - lier le membre à l'organisation
// 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 (onlyOrgAdmin) {
if (membreDTO.organisationId() == null) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "organisationId obligatoire pour ADMIN_ORGANISATION"))
.build();
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();
}
}
// Vérifier que l'utilisateur a accès à cette organisation
String email = securityIdentity.getPrincipal().getName();
// Auto-activer si l'organisation a une souscription active (l'admin a déjà payé)
boolean orgActif = membreService.orgHasActiveSubscription(membreDTO.organisationId());
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();
}
// Lier le membre à l'organisation et incrémenter le quota
// Lier le membre à l'organisation (SUPER_ADMIN ou ADMIN_ORGANISATION)
membreService.lierMembreOrganisationEtIncrementerQuota(
nouveauMembre,
membreDTO.organisationId(),
"EN_ATTENTE_VALIDATION");
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();
}
@@ -674,6 +753,30 @@ public class MembreResource {
.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"})
@@ -722,6 +825,37 @@ public class MembreResource {
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)