fix(security): audit RBAC complet v3.0 — rôles normalisés, lifecycle, changement mdp mobile
RBAC:
- HealthResource: @PermitAll
- RoleResource: @RolesAllowed ADMIN/SUPER_ADMIN/ADMIN_ORGANISATION class-level
- PropositionAideResource: @RolesAllowed MEMBRE/USER class-level
- AuthCallbackResource: @PermitAll
- EvenementResource: @PermitAll /publics et /test, count restreint
- BackupResource/LogsMonitoringResource/SystemResource: MODERATOR → MODERATEUR
- AnalyticsResource: MANAGER/MEMBER → ADMIN_ORGANISATION/MEMBRE
- RoleConstant.java: constantes de rôles centralisées
Cycle de vie membres:
- MemberLifecycleService: ajouterMembre()/retirerMembre() sur activation/radiation/archivage
- MembreResource: endpoint GET /numero/{numeroMembre}
- MembreService: méthode trouverParNumeroMembre()
Changement mot de passe:
- CompteAdherentResource: endpoint POST /auth/change-password (mobile)
- MembreKeycloakSyncService: changerMotDePasseDirectKeycloak() via API Admin Keycloak directe
- Fallback automatique si lions-user-manager indisponible
Workflow:
- Flyway V17-V23: rôles, types org, formules Option C, lifecycle columns, bareme cotisation
- Nouvelles classes: MemberLifecycleService, OrganisationModuleService, scheduler
- Security: OrganisationContextFilter, OrganisationContextHolder, ModuleAccessFilter
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
package dev.lions.unionflow.server.service;
|
||||
|
||||
import dev.lions.unionflow.server.entity.BaremeCotisationRole;
|
||||
import dev.lions.unionflow.server.entity.Cotisation;
|
||||
import dev.lions.unionflow.server.entity.MembreOrganisation;
|
||||
import dev.lions.unionflow.server.entity.Organisation;
|
||||
import dev.lions.unionflow.server.entity.ParametresCotisationOrganisation;
|
||||
import dev.lions.unionflow.server.repository.BaremeCotisationRoleRepository;
|
||||
import dev.lions.unionflow.server.repository.CotisationRepository;
|
||||
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
|
||||
import dev.lions.unionflow.server.repository.ParametresCotisationOrganisationRepository;
|
||||
import io.quarkus.scheduler.Scheduled;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.transaction.Transactional;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Génération automatique mensuelle des cotisations pour les organisations
|
||||
* ayant activé {@code generationAutomatiqueActivee} dans leurs paramètres.
|
||||
*
|
||||
* <p>Le job s'exécute le 1er de chaque mois à 1h00. Pour chaque membre actif,
|
||||
* il détermine le montant selon le barème du rôle fonctionnel de ce membre dans
|
||||
* l'organisation, ou utilise le montant par défaut si aucun barème n'est configuré.
|
||||
*
|
||||
* <p>Les cotisations déjà générées pour le même mois/année sont ignorées (idempotence).
|
||||
*/
|
||||
@ApplicationScoped
|
||||
@Slf4j
|
||||
public class CotisationAutoGenerationService {
|
||||
|
||||
@Inject
|
||||
ParametresCotisationOrganisationRepository parametresRepository;
|
||||
|
||||
@Inject
|
||||
MembreOrganisationRepository membreOrganisationRepository;
|
||||
|
||||
@Inject
|
||||
BaremeCotisationRoleRepository baremeRepository;
|
||||
|
||||
@Inject
|
||||
CotisationRepository cotisationRepository;
|
||||
|
||||
/**
|
||||
* Point d'entrée du job planifié : 1er de chaque mois à 01:00.
|
||||
*/
|
||||
@Scheduled(cron = "0 0 1 1 * ?")
|
||||
@Transactional
|
||||
public void genererCotisationsMensuelles() {
|
||||
LocalDate aujourd_hui = LocalDate.now();
|
||||
int annee = aujourd_hui.getYear();
|
||||
int mois = aujourd_hui.getMonthValue();
|
||||
log.info("=== Génération automatique cotisations {}/{} ===", mois, annee);
|
||||
|
||||
List<ParametresCotisationOrganisation> paramsList =
|
||||
parametresRepository.findAvecGenerationAutomatiqueActivee();
|
||||
|
||||
if (paramsList.isEmpty()) {
|
||||
log.info("Aucune organisation avec génération automatique activée.");
|
||||
return;
|
||||
}
|
||||
|
||||
int totalCrees = 0;
|
||||
int totalIgnores = 0;
|
||||
|
||||
for (ParametresCotisationOrganisation params : paramsList) {
|
||||
Organisation org = params.getOrganisation();
|
||||
int[] result = genererPourOrganisation(org, params, annee, mois);
|
||||
totalCrees += result[0];
|
||||
totalIgnores += result[1];
|
||||
}
|
||||
|
||||
log.info("=== Génération terminée : {} créées, {} ignorées (déjà existantes) ===",
|
||||
totalCrees, totalIgnores);
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère les cotisations pour une organisation donnée.
|
||||
*
|
||||
* @return tableau [nbCreees, nbIgnorees]
|
||||
*/
|
||||
int[] genererPourOrganisation(Organisation org,
|
||||
ParametresCotisationOrganisation params,
|
||||
int annee, int mois) {
|
||||
List<MembreOrganisation> membresActifs =
|
||||
membreOrganisationRepository.findMembresActifsParOrganisation(org.getId());
|
||||
|
||||
int crees = 0;
|
||||
int ignores = 0;
|
||||
String devise = params.getDevise() != null ? params.getDevise() : "XOF";
|
||||
|
||||
for (MembreOrganisation mo : membresActifs) {
|
||||
if (mo.getMembre() == null) continue;
|
||||
|
||||
// Idempotence : ne pas recréer si déjà générée ce mois
|
||||
if (cotisationRepository.existsByMembreOrganisationAnneeAndMois(
|
||||
mo.getMembre().getId(), org.getId(), annee, mois)) {
|
||||
ignores++;
|
||||
continue;
|
||||
}
|
||||
|
||||
BigDecimal montant = resoudreMontantMensuel(org.getId(), mo.getRoleOrg(), params);
|
||||
if (montant == null || montant.compareTo(BigDecimal.ZERO) == 0) {
|
||||
log.debug("Org {} membre {} : montant 0, cotisation ignorée", org.getNom(), mo.getMembre().getId());
|
||||
ignores++;
|
||||
continue;
|
||||
}
|
||||
|
||||
LocalDate echeance = LocalDate.of(annee, mois, 28);
|
||||
Cotisation cotisation = Cotisation.builder()
|
||||
.typeCotisation("MENSUELLE")
|
||||
.libelle("Cotisation mensuelle " + String.format("%02d/%d", mois, annee))
|
||||
.description("Génération automatique — " + org.getNom())
|
||||
.montantDu(montant)
|
||||
.montantPaye(BigDecimal.ZERO)
|
||||
.codeDevise(devise)
|
||||
.statut("EN_ATTENTE")
|
||||
.dateEcheance(echeance)
|
||||
.annee(annee)
|
||||
.mois(mois)
|
||||
.recurrente(true)
|
||||
.membre(mo.getMembre())
|
||||
.organisation(org)
|
||||
.build();
|
||||
cotisation.setNumeroReference(Cotisation.genererNumeroReference());
|
||||
cotisationRepository.persist(cotisation);
|
||||
crees++;
|
||||
}
|
||||
|
||||
log.info("Org '{}' [{}/{}] : {} cotisation(s) créée(s), {} ignorée(s)",
|
||||
org.getNom(), mois, annee, crees, ignores);
|
||||
return new int[]{crees, ignores};
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine le montant mensuel applicable à ce membre selon son rôle.
|
||||
* Priorité : barème du rôle → montant par défaut de l'organisation.
|
||||
*/
|
||||
private BigDecimal resoudreMontantMensuel(java.util.UUID orgId, String roleOrg,
|
||||
ParametresCotisationOrganisation params) {
|
||||
if (roleOrg != null && !roleOrg.isBlank()) {
|
||||
Optional<BaremeCotisationRole> bareme =
|
||||
baremeRepository.findByOrganisationIdAndRoleOrg(orgId, roleOrg);
|
||||
if (bareme.isPresent() && bareme.get().getMontantMensuel() != null) {
|
||||
return bareme.get().getMontantMensuel();
|
||||
}
|
||||
}
|
||||
return params.getMontantCotisationMensuelle();
|
||||
}
|
||||
}
|
||||
@@ -70,17 +70,17 @@ public class CotisationService {
|
||||
* @param size taille de la page
|
||||
* @return liste des cotisations converties en Summary Response
|
||||
*/
|
||||
public List<CotisationSummaryResponse> getAllCotisations(int page, int size) {
|
||||
public List<CotisationResponse> getAllCotisations(int page, int size) {
|
||||
log.debug("Récupération des cotisations - page: {}, size: {}", page, size);
|
||||
|
||||
jakarta.persistence.TypedQuery<Cotisation> query = cotisationRepository.getEntityManager().createQuery(
|
||||
"SELECT c FROM Cotisation c ORDER BY c.dateEcheance DESC",
|
||||
"SELECT c FROM Cotisation c LEFT JOIN FETCH c.membre LEFT JOIN FETCH c.organisation ORDER BY c.dateEcheance DESC",
|
||||
Cotisation.class);
|
||||
query.setFirstResult(page * size);
|
||||
query.setMaxResults(size);
|
||||
List<Cotisation> cotisations = query.getResultList();
|
||||
|
||||
return cotisations.stream().map(this::convertToSummaryResponse).collect(Collectors.toList());
|
||||
return cotisations.stream().map(this::convertToResponse).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -283,7 +283,7 @@ public class CotisationService {
|
||||
/**
|
||||
* Récupère les cotisations d'un membre.
|
||||
*/
|
||||
public List<CotisationSummaryResponse> getCotisationsByMembre(@NotNull UUID membreId, int page, int size) {
|
||||
public List<CotisationResponse> getCotisationsByMembre(@NotNull UUID membreId, int page, int size) {
|
||||
log.debug("Récupération des cotisations du membre: {}", membreId);
|
||||
|
||||
if (!membreRepository.findByIdOptional(membreId).isPresent()) {
|
||||
@@ -293,35 +293,35 @@ public class CotisationService {
|
||||
List<Cotisation> cotisations = cotisationRepository.findByMembreId(
|
||||
membreId, Page.of(page, size), Sort.by("dateEcheance").descending());
|
||||
|
||||
return cotisations.stream().map(this::convertToSummaryResponse).collect(Collectors.toList());
|
||||
return cotisations.stream().map(this::convertToResponse).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les cotisations par statut.
|
||||
*/
|
||||
public List<CotisationSummaryResponse> getCotisationsByStatut(@NotNull String statut, int page, int size) {
|
||||
public List<CotisationResponse> getCotisationsByStatut(@NotNull String statut, int page, int size) {
|
||||
log.debug("Récupération des cotisations avec statut: {}", statut);
|
||||
|
||||
List<Cotisation> cotisations = cotisationRepository.findByStatut(statut, Page.of(page, size));
|
||||
|
||||
return cotisations.stream().map(this::convertToSummaryResponse).collect(Collectors.toList());
|
||||
return cotisations.stream().map(this::convertToResponse).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les cotisations en retard.
|
||||
*/
|
||||
public List<CotisationSummaryResponse> getCotisationsEnRetard(int page, int size) {
|
||||
public List<CotisationResponse> getCotisationsEnRetard(int page, int size) {
|
||||
log.debug("Récupération des cotisations en retard");
|
||||
|
||||
List<Cotisation> cotisations = cotisationRepository.findCotisationsEnRetard(LocalDate.now(), Page.of(page, size));
|
||||
|
||||
return cotisations.stream().map(this::convertToSummaryResponse).collect(Collectors.toList());
|
||||
return cotisations.stream().map(this::convertToResponse).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche avancée de cotisations.
|
||||
*/
|
||||
public List<CotisationSummaryResponse> rechercherCotisations(
|
||||
public List<CotisationResponse> rechercherCotisations(
|
||||
UUID membreId,
|
||||
String statut,
|
||||
String typeCotisation,
|
||||
@@ -334,7 +334,7 @@ public class CotisationService {
|
||||
List<Cotisation> cotisations = cotisationRepository.rechercheAvancee(
|
||||
membreId, statut, typeCotisation, annee, mois, Page.of(page, size));
|
||||
|
||||
return cotisations.stream().map(this::convertToSummaryResponse).collect(Collectors.toList());
|
||||
return cotisations.stream().map(this::convertToResponse).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -699,7 +699,7 @@ public class CotisationService {
|
||||
* Toutes les cotisations du membre connecté (tous statuts), ou des organisations gérées si ADMIN/ADMIN_ORGANISATION.
|
||||
* Utilisé pour les onglets Toutes / Payées / Dues / Retard.
|
||||
*/
|
||||
public List<CotisationSummaryResponse> getMesCotisations(int page, int size) {
|
||||
public List<CotisationResponse> getMesCotisations(int page, int size) {
|
||||
String email = securiteHelper.resolveEmail();
|
||||
if (email == null || email.isBlank()) {
|
||||
return Collections.emptyList();
|
||||
@@ -714,7 +714,7 @@ public class CotisationService {
|
||||
Set<UUID> orgIds = orgs.stream().map(Organisation::getId).collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
List<Cotisation> cotisations = cotisationRepository.findByOrganisationIdIn(
|
||||
orgIds, Page.of(page, size), Sort.by("dateEcheance").descending());
|
||||
return cotisations.stream().map(this::convertToSummaryResponse).collect(Collectors.toList());
|
||||
return cotisations.stream().map(this::convertToResponse).collect(Collectors.toList());
|
||||
}
|
||||
Membre membreConnecte = membreRepository.findByEmail(email).orElse(null);
|
||||
if (membreConnecte == null) {
|
||||
@@ -731,7 +731,7 @@ public class CotisationService {
|
||||
*
|
||||
* @return Liste des cotisations en attente
|
||||
*/
|
||||
public List<CotisationSummaryResponse> getMesCotisationsEnAttente() {
|
||||
public List<CotisationResponse> getMesCotisationsEnAttente() {
|
||||
String email = securiteHelper.resolveEmail();
|
||||
if (email == null || email.isBlank()) {
|
||||
return Collections.emptyList();
|
||||
@@ -745,7 +745,7 @@ public class CotisationService {
|
||||
Set<UUID> orgIds = orgs.stream().map(Organisation::getId).collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
List<Cotisation> cotisations = cotisationRepository.findEnAttenteByOrganisationIdIn(orgIds);
|
||||
log.info("Cotisations en attente (admin): {} pour {} organisations", cotisations.size(), orgIds.size());
|
||||
return cotisations.stream().map(this::convertToSummaryResponse).collect(Collectors.toList());
|
||||
return cotisations.stream().map(this::convertToResponse).collect(Collectors.toList());
|
||||
}
|
||||
Membre membreConnecte = membreRepository.findByEmail(email).orElse(null);
|
||||
if (membreConnecte == null) {
|
||||
@@ -758,6 +758,7 @@ public class CotisationService {
|
||||
List<Cotisation> cotisations = cotisationRepository.getEntityManager()
|
||||
.createQuery(
|
||||
"SELECT c FROM Cotisation c " +
|
||||
"LEFT JOIN FETCH c.membre LEFT JOIN FETCH c.organisation " +
|
||||
"WHERE c.membre.id = :membreId " +
|
||||
"AND c.statut = 'EN_ATTENTE' " +
|
||||
"AND EXTRACT(YEAR FROM c.dateEcheance) = :annee " +
|
||||
@@ -769,7 +770,7 @@ public class CotisationService {
|
||||
log.info("Cotisations en attente trouvées: {} pour le membre {}",
|
||||
cotisations.size(), membreConnecte.getNumeroMembre());
|
||||
return cotisations.stream()
|
||||
.map(this::convertToSummaryResponse)
|
||||
.map(this::convertToResponse)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import dev.lions.unionflow.server.mapper.DemandeAideMapper;
|
||||
import dev.lions.unionflow.server.repository.DemandeAideRepository;
|
||||
import dev.lions.unionflow.server.repository.MembreRepository;
|
||||
import dev.lions.unionflow.server.repository.OrganisationRepository;
|
||||
import io.quarkus.panache.common.Page;
|
||||
import io.quarkus.panache.common.Sort;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.transaction.Transactional;
|
||||
@@ -403,9 +405,19 @@ public class DemandeAideService {
|
||||
cacheDemandesRecentes.keySet().retainAll(cacheTimestamps.keySet());
|
||||
}
|
||||
|
||||
/** Charge toutes les demandes depuis la base et les mappe en DTO. */
|
||||
/**
|
||||
* Charge les demandes depuis la base avec une limite de 1000 enregistrements.
|
||||
* Log un avertissement si le résultat dépasse 500 éléments pour anticiper les risques OOM.
|
||||
*/
|
||||
private List<DemandeAideResponse> chargerToutesLesDemandesDepuisBDD() {
|
||||
List<DemandeAide> entities = demandeAideRepository.listAll();
|
||||
int limite = 1000;
|
||||
List<DemandeAide> entities = demandeAideRepository.findAll(
|
||||
Page.ofSize(limite),
|
||||
Sort.by("dateDemande", Sort.Direction.Descending)
|
||||
);
|
||||
if (entities.size() > 500) {
|
||||
LOG.warnf("chargerToutesLesDemandesDepuisBDD : %d demandes chargées en mémoire — risque OOM si la volumétrie continue de croître", entities.size());
|
||||
}
|
||||
return entities.stream()
|
||||
.map(demandeAideMapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
@@ -122,6 +122,19 @@ public class DocumentService {
|
||||
return convertToResponse(pieceJointe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste les documents de l'utilisateur connecté
|
||||
*
|
||||
* @return Liste des documents créés par l'utilisateur connecté
|
||||
*/
|
||||
public List<DocumentResponse> listerMesDocuments() {
|
||||
String email = keycloakService.getCurrentUserEmail();
|
||||
LOG.infof("Listing des documents pour l'utilisateur: %s", email);
|
||||
return documentRepository.findByCreePar(email).stream()
|
||||
.map(this::convertToResponse)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste toutes les pièces jointes d'un document
|
||||
*
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
package dev.lions.unionflow.server.service;
|
||||
|
||||
import dev.lions.unionflow.server.api.dto.notification.request.CreateNotificationRequest;
|
||||
import dev.lions.unionflow.server.api.enums.membre.StatutMembre;
|
||||
import dev.lions.unionflow.server.entity.Membre;
|
||||
import dev.lions.unionflow.server.entity.MembreOrganisation;
|
||||
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.transaction.Transactional;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
/**
|
||||
* Service de cycle de vie des membres au sein d'une organisation.
|
||||
*
|
||||
* <p>Gère les transitions de statut automatiques et manuelles :
|
||||
* <ul>
|
||||
* <li>Invitation → EN_ATTENTE_VALIDATION (après acceptation)</li>
|
||||
* <li>EN_ATTENTE_VALIDATION → ACTIF (après validation admin)</li>
|
||||
* <li>ACTIF → SUSPENDU (suspension manuelle ou automatique)</li>
|
||||
* <li>INVITE → expiré (si la date d'expiration est dépassée)</li>
|
||||
* <li>ACTIF → ARCHIVE (archivage)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Les tâches automatiques sont déclenchées par {@link MemberLifecycleScheduler}.
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class MemberLifecycleService {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(MemberLifecycleService.class);
|
||||
|
||||
/** Durée de validité par défaut d'une invitation (7 jours). */
|
||||
public static final int INVITATION_EXPIRY_DAYS = 7;
|
||||
|
||||
/** Délai de rappel avant expiration (24h avant). */
|
||||
public static final int INVITATION_REMINDER_HOURS = 24;
|
||||
|
||||
@Inject
|
||||
MembreOrganisationRepository membreOrgRepository;
|
||||
|
||||
@Inject
|
||||
NotificationService notificationService;
|
||||
|
||||
// =========================================================================
|
||||
// Transitions manuelles
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Active un membre dans une organisation (transition → ACTIF).
|
||||
*
|
||||
* @param membreOrgId UUID du lien membre-organisation
|
||||
* @param adminId UUID de l'administrateur qui valide
|
||||
* @param motif Motif optionnel de l'activation
|
||||
*/
|
||||
@Transactional
|
||||
public MembreOrganisation activerMembre(UUID membreOrgId, UUID adminId, String motif) {
|
||||
MembreOrganisation lien = charger(membreOrgId);
|
||||
verifierTransitionAutorisee(lien.getStatutMembre(),
|
||||
StatutMembre.EN_ATTENTE_VALIDATION, StatutMembre.INVITE, StatutMembre.SUSPENDU);
|
||||
|
||||
lien.setStatutMembre(StatutMembre.ACTIF);
|
||||
lien.setDateChangementStatut(LocalDate.now());
|
||||
lien.setMotifStatut(motif != null ? motif : "Validation par l'administrateur");
|
||||
membreOrgRepository.persist(lien);
|
||||
|
||||
// Mettre à jour le compteur de membres de l'organisation
|
||||
lien.getOrganisation().ajouterMembre();
|
||||
|
||||
LOG.infof("Membre %s activé dans organisation %s par admin %s",
|
||||
lien.getMembre().getId(), lien.getOrganisation().getId(), adminId);
|
||||
|
||||
// Notifier le membre
|
||||
envoyerNotification(lien.getMembre(), "Adhésion activée",
|
||||
"Votre adhésion à " + lien.getOrganisation().getNom() + " est maintenant active.");
|
||||
|
||||
return lien;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspend un membre dans une organisation (ACTIF → SUSPENDU).
|
||||
*/
|
||||
@Transactional
|
||||
public MembreOrganisation suspendreMembre(UUID membreOrgId, UUID adminId, String motif) {
|
||||
MembreOrganisation lien = charger(membreOrgId);
|
||||
verifierTransitionAutorisee(lien.getStatutMembre(), StatutMembre.ACTIF);
|
||||
|
||||
lien.setStatutMembre(StatutMembre.SUSPENDU);
|
||||
lien.setDateChangementStatut(LocalDate.now());
|
||||
lien.setMotifStatut(motif != null ? motif : "Suspension par l'administrateur");
|
||||
membreOrgRepository.persist(lien);
|
||||
|
||||
LOG.infof("Membre %s suspendu dans organisation %s", lien.getMembre().getId(),
|
||||
lien.getOrganisation().getId());
|
||||
|
||||
envoyerNotification(lien.getMembre(), "Adhésion suspendue",
|
||||
"Votre adhésion à " + lien.getOrganisation().getNom() + " a été suspendue. Motif : " + motif);
|
||||
|
||||
return lien;
|
||||
}
|
||||
|
||||
/**
|
||||
* Radie un membre d'une organisation (→ RADIE).
|
||||
*/
|
||||
@Transactional
|
||||
public MembreOrganisation radierMembre(UUID membreOrgId, UUID adminId, String motif) {
|
||||
MembreOrganisation lien = charger(membreOrgId);
|
||||
|
||||
lien.setStatutMembre(StatutMembre.RADIE);
|
||||
lien.setDateChangementStatut(LocalDate.now());
|
||||
lien.setMotifStatut(motif != null ? motif : "Radiation par l'administrateur");
|
||||
membreOrgRepository.persist(lien);
|
||||
|
||||
// Mettre à jour le compteur de membres de l'organisation
|
||||
lien.getOrganisation().retirerMembre();
|
||||
|
||||
LOG.infof("Membre %s radié de l'organisation %s. Motif: %s",
|
||||
lien.getMembre().getId(), lien.getOrganisation().getId(), motif);
|
||||
|
||||
envoyerNotification(lien.getMembre(), "Adhésion radiée",
|
||||
"Votre adhésion à " + lien.getOrganisation().getNom() + " a été radiée.");
|
||||
|
||||
return lien;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive un membre (→ ARCHIVE) sans supprimer l'historique.
|
||||
*/
|
||||
@Transactional
|
||||
public MembreOrganisation archiverMembre(UUID membreOrgId, String motif) {
|
||||
MembreOrganisation lien = charger(membreOrgId);
|
||||
|
||||
lien.setStatutMembre(StatutMembre.ARCHIVE);
|
||||
lien.setDateChangementStatut(LocalDate.now());
|
||||
lien.setMotifArchivage(motif);
|
||||
membreOrgRepository.persist(lien);
|
||||
|
||||
// Mettre à jour le compteur de membres de l'organisation
|
||||
lien.getOrganisation().retirerMembre();
|
||||
|
||||
LOG.infof("Membre %s archivé dans l'organisation %s", lien.getMembre().getId(),
|
||||
lien.getOrganisation().getId());
|
||||
|
||||
return lien;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une invitation (statut INVITE) pour un nouveau membre.
|
||||
*
|
||||
* @param membre Le membre à inviter (doit exister en base)
|
||||
* @param organisation L'organisation cible
|
||||
* @param adminId L'administrateur qui invite
|
||||
* @param roleOrg Rôle proposé dans l'organisation (optionnel)
|
||||
* @return le lien MembreOrganisation créé avec le token d'invitation
|
||||
*/
|
||||
@Transactional
|
||||
public MembreOrganisation inviterMembre(
|
||||
dev.lions.unionflow.server.entity.Membre membre,
|
||||
dev.lions.unionflow.server.entity.Organisation organisation,
|
||||
UUID adminId,
|
||||
String roleOrg) {
|
||||
|
||||
// Vérifier s'il n'existe pas déjà un lien actif
|
||||
membreOrgRepository.findByMembreIdAndOrganisationId(membre.getId(), organisation.getId())
|
||||
.ifPresent(existing -> {
|
||||
throw new IllegalStateException("Le membre est déjà lié à cette organisation.");
|
||||
});
|
||||
|
||||
String token = UUID.randomUUID().toString().replace("-", "");
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
MembreOrganisation lien = MembreOrganisation.builder()
|
||||
.membre(membre)
|
||||
.organisation(organisation)
|
||||
.statutMembre(StatutMembre.INVITE)
|
||||
.dateInvitation(now)
|
||||
.dateExpirationInvitation(now.plusDays(INVITATION_EXPIRY_DAYS))
|
||||
.tokenInvitation(token)
|
||||
.invitePar(adminId)
|
||||
.roleOrg(roleOrg)
|
||||
.build();
|
||||
|
||||
membreOrgRepository.persist(lien);
|
||||
|
||||
LOG.infof("Invitation créée pour membre %s dans organisation %s (expire: %s)",
|
||||
membre.getId(), organisation.getId(), lien.getDateExpirationInvitation());
|
||||
|
||||
// Notifier le membre
|
||||
envoyerNotification(membre, "Invitation à rejoindre " + organisation.getNom(),
|
||||
"Vous avez été invité à rejoindre " + organisation.getNom() +
|
||||
". Votre invitation expire dans " + INVITATION_EXPIRY_DAYS + " jours.");
|
||||
|
||||
return lien;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepte une invitation via son token (INVITE → EN_ATTENTE_VALIDATION).
|
||||
*/
|
||||
@Transactional
|
||||
public MembreOrganisation accepterInvitation(String token) {
|
||||
MembreOrganisation lien = membreOrgRepository
|
||||
.find("tokenInvitation = ?1 and statutMembre = ?2", token, StatutMembre.INVITE)
|
||||
.firstResultOptional()
|
||||
.orElseThrow(() -> new IllegalArgumentException("Invitation introuvable ou déjà utilisée."));
|
||||
|
||||
if (lien.getDateExpirationInvitation() != null &&
|
||||
lien.getDateExpirationInvitation().isBefore(LocalDateTime.now())) {
|
||||
throw new IllegalStateException("Cette invitation a expiré.");
|
||||
}
|
||||
|
||||
lien.setStatutMembre(StatutMembre.EN_ATTENTE_VALIDATION);
|
||||
lien.setDateChangementStatut(LocalDate.now());
|
||||
lien.setTokenInvitation(null); // Invalider le token après usage
|
||||
membreOrgRepository.persist(lien);
|
||||
|
||||
LOG.infof("Invitation acceptée par membre %s dans organisation %s",
|
||||
lien.getMembre().getId(), lien.getOrganisation().getId());
|
||||
|
||||
return lien;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Traitements automatiques (appelés par MemberLifecycleScheduler)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Expire les invitations dont la date limite est dépassée.
|
||||
* Les membres INVITE passent au statut RADIE.
|
||||
*
|
||||
* @return nombre d'invitations expirées
|
||||
*/
|
||||
@Transactional
|
||||
public int expirerInvitations() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
List<MembreOrganisation> expiredInvitations = membreOrgRepository.findInvitationsExpirees(now);
|
||||
|
||||
int count = 0;
|
||||
for (MembreOrganisation lien : expiredInvitations) {
|
||||
lien.setStatutMembre(StatutMembre.RADIE);
|
||||
lien.setDateChangementStatut(LocalDate.now());
|
||||
lien.setMotifStatut("Invitation expirée sans réponse");
|
||||
membreOrgRepository.persist(lien);
|
||||
count++;
|
||||
|
||||
LOG.infof("Invitation expirée : membre %s dans org %s",
|
||||
lien.getMembre().getId(), lien.getOrganisation().getId());
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
LOG.infof("Expiration des invitations : %d invitation(s) expirée(s)", count);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie des rappels pour les invitations qui expirent dans les prochaines 24h.
|
||||
*
|
||||
* @return nombre de rappels envoyés
|
||||
*/
|
||||
@Transactional
|
||||
public int envoyerRappelsInvitation() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime dans24h = now.plusHours(INVITATION_REMINDER_HOURS);
|
||||
List<MembreOrganisation> aRappeler =
|
||||
membreOrgRepository.findInvitationsExpirantBientot(now, dans24h);
|
||||
|
||||
int count = 0;
|
||||
for (MembreOrganisation lien : aRappeler) {
|
||||
envoyerNotification(lien.getMembre(),
|
||||
"Rappel : votre invitation expire bientôt",
|
||||
"Votre invitation à rejoindre " + lien.getOrganisation().getNom() +
|
||||
" expire dans moins de 24h. Acceptez-la maintenant.");
|
||||
count++;
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
LOG.infof("Rappels d'invitation : %d rappel(s) envoyé(s)", count);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers privés
|
||||
// =========================================================================
|
||||
|
||||
private MembreOrganisation charger(UUID membreOrgId) {
|
||||
return membreOrgRepository.findByIdOptional(membreOrgId)
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Lien membre-organisation introuvable : " + membreOrgId));
|
||||
}
|
||||
|
||||
private void verifierTransitionAutorisee(StatutMembre current, StatutMembre... autorises) {
|
||||
for (StatutMembre autorise : autorises) {
|
||||
if (autorise.equals(current)) return;
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"Transition non autorisée depuis le statut " + current);
|
||||
}
|
||||
|
||||
private void envoyerNotification(Membre membre, String titre, String corps) {
|
||||
try {
|
||||
if (notificationService != null) {
|
||||
CreateNotificationRequest req = CreateNotificationRequest.builder()
|
||||
.typeNotification("SYSTEME")
|
||||
.priorite("NORMALE")
|
||||
.sujet(titre)
|
||||
.corps(corps)
|
||||
.membreId(membre.getId())
|
||||
.build();
|
||||
notificationService.creerNotification(req);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warnf("Impossible d'envoyer la notification à %s : %s", membre.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,19 @@ import dev.lions.unionflow.server.entity.Membre;
|
||||
import dev.lions.unionflow.server.repository.MembreRepository;
|
||||
import dev.lions.user.manager.dto.user.UserDTO;
|
||||
import dev.lions.user.manager.dto.user.UserSearchCriteriaDTO;
|
||||
import io.quarkus.oidc.client.NamedOidcClient;
|
||||
import io.quarkus.oidc.client.OidcClient;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.transaction.Transactional;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
import org.eclipse.microprofile.rest.client.inject.RestClient;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -60,6 +67,14 @@ public class MembreKeycloakSyncService {
|
||||
private static final Logger LOGGER = Logger.getLogger(MembreKeycloakSyncService.class.getName());
|
||||
private static final String DEFAULT_REALM = "unionflow";
|
||||
|
||||
/** URL du serveur Keycloak. Ex : https://security.lions.dev/realms/unionflow */
|
||||
@ConfigProperty(name = "quarkus.oidc.auth-server-url")
|
||||
String oidcAuthServerUrl;
|
||||
|
||||
@Inject
|
||||
@NamedOidcClient("admin-service")
|
||||
OidcClient adminOidcClient;
|
||||
|
||||
@Inject
|
||||
MembreRepository membreRepository;
|
||||
|
||||
@@ -518,7 +533,15 @@ public class MembreKeycloakSyncService {
|
||||
.temporary(false)
|
||||
.build();
|
||||
|
||||
userServiceClient.resetPassword(keycloakUserId, DEFAULT_REALM, resetRequest);
|
||||
// Tentative via lions-user-manager ; fallback sur l'API Admin Keycloak directe si 403
|
||||
try {
|
||||
userServiceClient.resetPassword(keycloakUserId, DEFAULT_REALM, resetRequest);
|
||||
} catch (jakarta.ws.rs.ForbiddenException | jakarta.ws.rs.ServiceUnavailableException e) {
|
||||
LOGGER.warning("lions-user-manager reset-password échoué (" + e.getMessage()
|
||||
+ "), fallback sur API Admin Keycloak directe.");
|
||||
changerMotDePasseDirectKeycloak(membre.getId(), nouveauMotDePasse);
|
||||
return; // changerMotDePasseDirectKeycloak persiste déjà les flags
|
||||
}
|
||||
|
||||
membre.setPremiereConnexion(false);
|
||||
// Auto-activation : le membre prouve son identité en changeant son mot de passe temporaire
|
||||
@@ -541,6 +564,75 @@ public class MembreKeycloakSyncService {
|
||||
LOGGER.info("Mot de passe premier login changé pour: " + membre.getEmail());
|
||||
}
|
||||
|
||||
/**
|
||||
* Change le mot de passe d'un membre en appelant directement l'API Admin Keycloak.
|
||||
* Bypass lions-user-manager (évite les erreurs 403 de service account).
|
||||
*
|
||||
* @param membreId UUID du membre UnionFlow
|
||||
* @param nouveauMotDePasse Nouveau mot de passe (en clair, transmis en HTTPS)
|
||||
*/
|
||||
@Transactional
|
||||
public void changerMotDePasseDirectKeycloak(UUID membreId, String nouveauMotDePasse) {
|
||||
LOGGER.info("Changement de mot de passe (direct Keycloak) pour membre ID: " + membreId);
|
||||
|
||||
Membre membre = membreRepository.findByIdOptional(membreId)
|
||||
.orElseThrow(() -> new NotFoundException("Membre non trouvé: " + membreId));
|
||||
|
||||
if (membre.getKeycloakId() == null) {
|
||||
throw new IllegalStateException("Le membre n'a pas de compte Keycloak: " + membre.getEmail());
|
||||
}
|
||||
|
||||
String keycloakUserId = membre.getKeycloakId().toString();
|
||||
|
||||
// Obtenir le token admin via OIDC client credentials
|
||||
String adminToken;
|
||||
try {
|
||||
adminToken = adminOidcClient.getTokens().await().indefinitely().getAccessToken();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Impossible d'obtenir le token admin Keycloak: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
// Dériver l'URL Admin Keycloak depuis l'URL OIDC
|
||||
// Ex: https://security.lions.dev/realms/unionflow → https://security.lions.dev/admin/realms/unionflow
|
||||
String adminUrl = oidcAuthServerUrl.replace("/realms/", "/admin/realms/")
|
||||
+ "/users/" + keycloakUserId + "/reset-password";
|
||||
|
||||
String body = String.format(
|
||||
"{\"type\":\"password\",\"value\":\"%s\",\"temporary\":false}",
|
||||
nouveauMotDePasse.replace("\"", "\\\""));
|
||||
|
||||
try {
|
||||
HttpClient client = HttpClient.newHttpClient();
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(adminUrl))
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.header("Content-Type", "application/json")
|
||||
.PUT(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() != 204 && response.statusCode() != 200) {
|
||||
throw new RuntimeException("Keycloak Admin API retourné " + response.statusCode()
|
||||
+ ": " + response.body());
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Erreur lors de la réinitialisation du mot de passe Keycloak: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
// Mettre à jour les flags
|
||||
membre.setPremiereConnexion(false);
|
||||
if ("EN_ATTENTE_VALIDATION".equals(membre.getStatutCompte())) {
|
||||
membre.setStatutCompte("ACTIF");
|
||||
membre.setActif(true);
|
||||
}
|
||||
membreRepository.persist(membre);
|
||||
|
||||
LOGGER.info("Mot de passe changé (direct Keycloak) pour: " + membre.getEmail());
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque le premier login comme complété.
|
||||
*
|
||||
|
||||
@@ -278,6 +278,11 @@ public class MembreService {
|
||||
return membreRepository.findByEmail(email);
|
||||
}
|
||||
|
||||
/** Trouve un membre par son numéro de membre (ex: MBR-0001) */
|
||||
public Optional<Membre> trouverParNumeroMembre(String numeroMembre) {
|
||||
return membreRepository.findByNumeroMembre(numeroMembre);
|
||||
}
|
||||
|
||||
/** Liste tous les membres actifs */
|
||||
public List<Membre> listerMembresActifs() {
|
||||
return membreRepository.findAllActifs();
|
||||
@@ -523,12 +528,14 @@ public class MembreService {
|
||||
|
||||
UUID organisationId = null;
|
||||
String organisationNom = null;
|
||||
java.time.LocalDate dateAdhesion = null;
|
||||
if (membre.getMembresOrganisations() != null && !membre.getMembresOrganisations().isEmpty()) {
|
||||
dev.lions.unionflow.server.entity.MembreOrganisation mo = membre.getMembresOrganisations().get(0);
|
||||
if (mo.getOrganisation() != null) {
|
||||
organisationId = mo.getOrganisation().getId();
|
||||
organisationNom = mo.getOrganisation().getNom();
|
||||
}
|
||||
dateAdhesion = mo.getDateAdhesion();
|
||||
}
|
||||
|
||||
return new MembreSummaryResponse(
|
||||
@@ -545,7 +552,8 @@ public class MembreService {
|
||||
membre.getActif(),
|
||||
rolesNames,
|
||||
organisationId,
|
||||
organisationNom);
|
||||
organisationNom,
|
||||
dateAdhesion);
|
||||
}
|
||||
|
||||
/** Convertit un CreateMembreRequest en entité Membre */
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package dev.lions.unionflow.server.service;
|
||||
|
||||
import dev.lions.unionflow.server.entity.Organisation;
|
||||
import dev.lions.unionflow.server.repository.OrganisationRepository;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
/**
|
||||
* Service de gestion des modules actifs par organisation.
|
||||
*
|
||||
* <p>Architecture Option C — les modules actifs sont déterminés par le TYPE
|
||||
* d'organisation, pas par le plan tarifaire. Le plan impacte uniquement la
|
||||
* profondeur fonctionnelle (reporting, API, fédération).
|
||||
*
|
||||
* <p>Les modules sont regroupés en deux catégories :
|
||||
* <ul>
|
||||
* <li>MODULES_COMMUNS — accessibles à tous les types d'org</li>
|
||||
* <li>Modules métier — spécifiques au type d'org (TONTINE, CREDIT, etc.)</li>
|
||||
* </ul>
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class OrganisationModuleService {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(OrganisationModuleService.class);
|
||||
|
||||
/** Modules présents sur toutes les organisations quelle que soit leur nature. */
|
||||
public static final Set<String> MODULES_COMMUNS = Set.of(
|
||||
"MEMBRES",
|
||||
"COTISATIONS",
|
||||
"EVENEMENTS",
|
||||
"COMMUNICATION",
|
||||
"DOCUMENTS",
|
||||
"NOTIFICATION",
|
||||
"AIDE"
|
||||
);
|
||||
|
||||
@Inject
|
||||
OrganisationRepository organisationRepository;
|
||||
|
||||
/**
|
||||
* Retourne l'ensemble des modules actifs pour une organisation donnée.
|
||||
* Combine les modules communs avec les modules métier du type d'org.
|
||||
*/
|
||||
public Set<String> getModulesActifs(UUID organisationId) {
|
||||
Optional<Organisation> opt = organisationRepository.findByIdOptional(organisationId);
|
||||
if (opt.isEmpty()) {
|
||||
LOG.warnf("Organisation introuvable : %s", organisationId);
|
||||
return MODULES_COMMUNS;
|
||||
}
|
||||
return getModulesActifs(opt.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'ensemble des modules actifs pour une organisation.
|
||||
*/
|
||||
public Set<String> getModulesActifs(Organisation organisation) {
|
||||
Set<String> modules = new LinkedHashSet<>(MODULES_COMMUNS);
|
||||
|
||||
// 1. Modules issus du champ modulesActifs persisté (calculé depuis types_reference en V18)
|
||||
String modulesActifsCsv = organisation.getModulesActifs();
|
||||
if (modulesActifsCsv != null && !modulesActifsCsv.isBlank()) {
|
||||
Arrays.stream(modulesActifsCsv.split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.forEach(modules::add);
|
||||
return modules;
|
||||
}
|
||||
|
||||
// 2. Fallback : déduction depuis le typeOrganisation si modulesActifs non renseigné
|
||||
modules.addAll(getModulesParType(organisation.getTypeOrganisation()));
|
||||
return modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un module spécifique est actif pour une organisation.
|
||||
*/
|
||||
public boolean isModuleActif(UUID organisationId, String module) {
|
||||
return getModulesActifs(organisationId).contains(module.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les modules métier associés à un type d'organisation.
|
||||
* Utilisé en fallback si la colonne modules_actifs n'est pas peuplée.
|
||||
*/
|
||||
public Set<String> getModulesParType(String typeOrganisation) {
|
||||
if (typeOrganisation == null) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
Set<String> modules = new LinkedHashSet<>();
|
||||
switch (typeOrganisation.toUpperCase()) {
|
||||
case "TONTINE" -> {
|
||||
modules.add("TONTINE");
|
||||
modules.add("FINANCE");
|
||||
}
|
||||
case "MUTUELLE_EPARGNE" -> {
|
||||
modules.add("EPARGNE");
|
||||
modules.add("FINANCE");
|
||||
modules.add("LCB_FT");
|
||||
}
|
||||
case "MUTUELLE_CREDIT" -> {
|
||||
modules.add("EPARGNE");
|
||||
modules.add("CREDIT");
|
||||
modules.add("FINANCE");
|
||||
modules.add("LCB_FT");
|
||||
}
|
||||
case "COOPERATIVE" -> {
|
||||
modules.add("AGRICULTURE");
|
||||
modules.add("FINANCE");
|
||||
}
|
||||
case "ONG", "FONDATION" -> {
|
||||
modules.add("PROJETS_ONG");
|
||||
modules.add("COLLECTE_FONDS");
|
||||
modules.add("FINANCE");
|
||||
}
|
||||
case "EGLISE", "GROUPE_PRIERE" -> {
|
||||
modules.add("CULTE_DONS");
|
||||
}
|
||||
case "SYNDICAT", "ORDRE_PROFESSIONNEL", "FEDERATION" -> {
|
||||
modules.add("VOTES");
|
||||
modules.add("REGISTRE_AGREMENT");
|
||||
}
|
||||
case "GIE" -> {
|
||||
modules.add("FINANCE");
|
||||
}
|
||||
case "ASSOCIATION", "CLUB_SERVICE", "CLUB_SPORTIF", "CLUB_CULTUREL" -> {
|
||||
modules.add("VOTES");
|
||||
}
|
||||
default -> LOG.debugf("Type d''organisation non reconnu pour module mapping : %s", typeOrganisation);
|
||||
}
|
||||
return modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne la liste des modules actifs sous forme de tableau JSON-friendly.
|
||||
* Utilisé par l'endpoint /api/organisations/{id}/modules-actifs
|
||||
*/
|
||||
public ModulesActifsResponse getModulesActifsResponse(UUID organisationId) {
|
||||
Optional<Organisation> opt = organisationRepository.findByIdOptional(organisationId);
|
||||
if (opt.isEmpty()) {
|
||||
return new ModulesActifsResponse(organisationId, Collections.emptySet(), "UNKNOWN");
|
||||
}
|
||||
Organisation org = opt.get();
|
||||
Set<String> modules = getModulesActifs(org);
|
||||
return new ModulesActifsResponse(organisationId, modules, org.getTypeOrganisation());
|
||||
}
|
||||
|
||||
/** DTO de réponse pour l'endpoint modules-actifs. */
|
||||
public record ModulesActifsResponse(UUID organisationId, Set<String> modules, String typeOrganisation) {}
|
||||
}
|
||||
@@ -333,7 +333,11 @@ public class PaiementService {
|
||||
.build();
|
||||
intentionPaiementRepository.persist(intention);
|
||||
|
||||
String successUrl = base + "/api/wave-redirect/success?ref=" + intention.getId();
|
||||
// Web (sans numéro de téléphone) → page HTML de confirmation ; Mobile → deep link app
|
||||
boolean isWebContext = request.numeroTelephone() == null || request.numeroTelephone().isBlank();
|
||||
String successUrl = base + (isWebContext
|
||||
? "/api/wave-redirect/web-success?ref=" + intention.getId()
|
||||
: "/api/wave-redirect/success?ref=" + intention.getId());
|
||||
String errorUrl = base + "/api/wave-redirect/error?ref=" + intention.getId();
|
||||
String clientRef = intention.getId().toString();
|
||||
// XOF : montant entier, pas de décimales (spec Wave)
|
||||
@@ -393,6 +397,113 @@ public class PaiementService {
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie le statut d'une IntentionPaiement Wave.
|
||||
* Si la session Wave est complétée (paiement réussi), réconcilie automatiquement
|
||||
* la cotisation (marque PAYEE) et met à jour l'intention (COMPLETEE).
|
||||
* Appelé en polling depuis le web toutes les 3 secondes.
|
||||
*/
|
||||
@Transactional
|
||||
public dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse verifierStatutIntention(UUID intentionId) {
|
||||
IntentionPaiement intention = intentionPaiementRepository.findById(intentionId);
|
||||
if (intention == null) {
|
||||
throw new NotFoundException("IntentionPaiement non trouvée: " + intentionId);
|
||||
}
|
||||
|
||||
// Déjà terminée — retourner immédiatement
|
||||
if (intention.isCompletee()) {
|
||||
return buildStatutResponse(intention, "Paiement confirmé !");
|
||||
}
|
||||
if (StatutIntentionPaiement.EXPIREE.equals(intention.getStatut())
|
||||
|| StatutIntentionPaiement.ECHOUEE.equals(intention.getStatut())) {
|
||||
return buildStatutResponse(intention, "Paiement " + intention.getStatut().name().toLowerCase());
|
||||
}
|
||||
|
||||
// Session expirée côté UnionFlow (30 min)
|
||||
if (intention.isExpiree()) {
|
||||
intention.setStatut(StatutIntentionPaiement.EXPIREE);
|
||||
intentionPaiementRepository.persist(intention);
|
||||
return buildStatutResponse(intention, "Session expirée, veuillez recommencer");
|
||||
}
|
||||
|
||||
// Vérifier le statut côté Wave si session connue
|
||||
if (intention.getWaveCheckoutSessionId() != null) {
|
||||
try {
|
||||
WaveCheckoutService.WaveSessionStatusResponse waveStatus =
|
||||
waveCheckoutService.getSession(intention.getWaveCheckoutSessionId());
|
||||
|
||||
if (waveStatus.isSucceeded()) {
|
||||
completerIntention(intention, waveStatus.transactionId);
|
||||
return buildStatutResponse(intention, "Paiement confirmé !");
|
||||
} else if (waveStatus.isExpired()) {
|
||||
intention.setStatut(StatutIntentionPaiement.EXPIREE);
|
||||
intentionPaiementRepository.persist(intention);
|
||||
return buildStatutResponse(intention, "Session Wave expirée");
|
||||
}
|
||||
} catch (WaveCheckoutService.WaveCheckoutException e) {
|
||||
LOG.warnf(e, "Impossible de vérifier la session Wave %s — retry au prochain poll",
|
||||
intention.getWaveCheckoutSessionId());
|
||||
}
|
||||
}
|
||||
|
||||
return buildStatutResponse(intention, "En attente de confirmation Wave...");
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque l'IntentionPaiement COMPLETEE et réconcilie les cotisations cibles (PAYEE).
|
||||
* Utilisé par le polling web ET par WaveRedirectResource lors du redirect success.
|
||||
*/
|
||||
@Transactional
|
||||
public void completerIntention(IntentionPaiement intention, String waveTransactionId) {
|
||||
if (intention.isCompletee()) return; // idempotent
|
||||
|
||||
intention.setStatut(StatutIntentionPaiement.COMPLETEE);
|
||||
intention.setDateCompletion(java.time.LocalDateTime.now());
|
||||
if (waveTransactionId != null) intention.setWaveTransactionId(waveTransactionId);
|
||||
intentionPaiementRepository.persist(intention);
|
||||
|
||||
// Réconcilier les cotisations listées dans objetsCibles
|
||||
String objetsCibles = intention.getObjetsCibles();
|
||||
if (objetsCibles == null || objetsCibles.isBlank()) return;
|
||||
|
||||
try {
|
||||
com.fasterxml.jackson.databind.JsonNode arr =
|
||||
new com.fasterxml.jackson.databind.ObjectMapper().readTree(objetsCibles);
|
||||
if (!arr.isArray()) return;
|
||||
for (com.fasterxml.jackson.databind.JsonNode node : arr) {
|
||||
if (!"COTISATION".equals(node.path("type").asText())) continue;
|
||||
UUID cotisationId = UUID.fromString(node.get("id").asText());
|
||||
java.math.BigDecimal montant = node.has("montant")
|
||||
? new java.math.BigDecimal(node.get("montant").asText())
|
||||
: intention.getMontantTotal();
|
||||
|
||||
Cotisation cotisation = paiementRepository.getEntityManager().find(Cotisation.class, cotisationId);
|
||||
if (cotisation == null) continue;
|
||||
|
||||
cotisation.setMontantPaye(montant);
|
||||
cotisation.setStatut("PAYEE");
|
||||
cotisation.setDatePaiement(java.time.LocalDateTime.now());
|
||||
paiementRepository.getEntityManager().merge(cotisation);
|
||||
LOG.infof("Cotisation %s marquée PAYEE — Wave txn %s", cotisationId, waveTransactionId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.errorf(e, "Erreur réconciliation cotisations pour intention %s", intention.getId());
|
||||
}
|
||||
}
|
||||
|
||||
private dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse buildStatutResponse(
|
||||
IntentionPaiement intention, String message) {
|
||||
return dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse.builder()
|
||||
.intentionId(intention.getId())
|
||||
.statut(intention.getStatut().name())
|
||||
.waveLaunchUrl(intention.getWaveLaunchUrl())
|
||||
.waveCheckoutSessionId(intention.getWaveCheckoutSessionId())
|
||||
.waveTransactionId(intention.getWaveTransactionId())
|
||||
.montant(intention.getMontantTotal())
|
||||
.message(message)
|
||||
.build();
|
||||
}
|
||||
|
||||
/** Format E.164 pour Wave (ex: 771234567 -> +225771234567). */
|
||||
private static String toE164(String numeroTelephone) {
|
||||
if (numeroTelephone == null || numeroTelephone.isBlank()) return null;
|
||||
|
||||
@@ -306,6 +306,40 @@ public class SouscriptionService {
|
||||
|
||||
// ── Validation SuperAdmin ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Liste toutes les souscriptions (SuperAdmin), avec filtre optionnel par organisation.
|
||||
*/
|
||||
public List<SouscriptionStatutResponse> listerToutes(UUID organisationId, int page, int size) {
|
||||
if (organisationId != null) {
|
||||
return souscriptionRepo
|
||||
.find("organisation.id = ?1 order by dateCreation desc", organisationId)
|
||||
.page(page, size)
|
||||
.list()
|
||||
.stream()
|
||||
.map(s -> toStatutResponse(s, null))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
return souscriptionRepo
|
||||
.findAll()
|
||||
.page(page, size)
|
||||
.list()
|
||||
.stream()
|
||||
.map(s -> toStatutResponse(s, null))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne la souscription active d'une organisation (SuperAdmin).
|
||||
*/
|
||||
public SouscriptionStatutResponse obtenirActiveParOrganisation(UUID organisationId) {
|
||||
return souscriptionRepo
|
||||
.find("organisation.id = ?1 and statut = ?2 order by dateCreation desc",
|
||||
organisationId, dev.lions.unionflow.server.api.enums.abonnement.StatutSouscription.ACTIVE)
|
||||
.firstResultOptional()
|
||||
.map(s -> toStatutResponse(s, null))
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste les souscriptions en attente de validation SuperAdmin.
|
||||
*/
|
||||
@@ -499,6 +533,7 @@ public class SouscriptionService {
|
||||
r.setDateFin(s.getDateFin());
|
||||
r.setDateValidation(s.getDateValidation());
|
||||
r.setCommentaireRejet(s.getCommentaireRejet());
|
||||
r.setStatut(s.getStatut() != null ? s.getStatut().name() : null);
|
||||
if (s.getOrganisation() != null) {
|
||||
r.setOrganisationId(s.getOrganisation().getId().toString());
|
||||
r.setOrganisationNom(s.getOrganisation().getNom());
|
||||
|
||||
@@ -58,6 +58,8 @@ public class SystemMetricsService {
|
||||
private final AtomicLong apiRequestsCount = new AtomicLong(0);
|
||||
private final AtomicLong apiRequestsLastHour = new AtomicLong(0);
|
||||
private final AtomicLong apiRequestsToday = new AtomicLong(0);
|
||||
private final AtomicLong requestCount = new AtomicLong(0);
|
||||
private final AtomicLong totalResponseTime = new AtomicLong(0);
|
||||
private long startTimeMillis;
|
||||
private LocalDateTime startTime;
|
||||
|
||||
@@ -283,11 +285,15 @@ public class SystemMetricsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Nombre de sessions actives
|
||||
* Nombre de sessions actives (proxy : membres avec compte ACTIF)
|
||||
*/
|
||||
private Integer getActiveSessionsCount() {
|
||||
// TODO: Implémenter avec vrai système de sessions Keycloak
|
||||
return 0;
|
||||
try {
|
||||
return (int) membreRepository.count("statutCompte = 'ACTIF'");
|
||||
} catch (Exception e) {
|
||||
log.warn("Impossible de compter les membres actifs", e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -303,11 +309,12 @@ public class SystemMetricsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Temps de réponse moyen API
|
||||
* Temps de réponse moyen API (basé sur les appels enregistrés via recordRequest)
|
||||
*/
|
||||
private Double getAverageResponseTime() {
|
||||
// TODO: Implémenter avec vrai système de métriques
|
||||
return 0.0;
|
||||
long count = requestCount.get();
|
||||
if (count == 0) return 0.0;
|
||||
return (double) totalResponseTime.get() / count;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -422,4 +429,14 @@ public class SystemMetricsService {
|
||||
apiRequestsLastHour.incrementAndGet();
|
||||
apiRequestsToday.incrementAndGet();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistrer une requête avec son temps de réponse (en ms)
|
||||
* Permet le calcul du temps de réponse moyen via getAverageResponseTime()
|
||||
*/
|
||||
public void recordRequest(long responseTimeMs) {
|
||||
requestCount.incrementAndGet();
|
||||
totalResponseTime.addAndGet(responseTimeMs);
|
||||
incrementApiRequestCount();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +149,62 @@ public class WaveCheckoutService {
|
||||
return HexFormat.of().formatHex(hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interroge l'état d'une session Wave Checkout (spec : GET /v1/checkout/sessions/:id).
|
||||
* Utilisé par le polling web pour détecter automatiquement la complétion du paiement.
|
||||
*
|
||||
* @param sessionId ID de session Wave (cos-xxx)
|
||||
* @return statut de la session (checkout_status, payment_status, transaction_id)
|
||||
*/
|
||||
public WaveSessionStatusResponse getSession(String sessionId) throws WaveCheckoutException {
|
||||
boolean useMock = mockEnabled || apiKey == null || apiKey.trim().isBlank();
|
||||
if (useMock) {
|
||||
// En mock, on ne peut pas vraiment vérifier — retourner EN_COURS (polling s'arrête via /web-success)
|
||||
LOG.warnf("Wave getSession en mode MOCK — session %s", sessionId);
|
||||
return new WaveSessionStatusResponse(sessionId, "open", "processing", null);
|
||||
}
|
||||
|
||||
String base = (baseUrl == null || baseUrl.endsWith("/")) ? baseUrl.replaceAll("/+$", "") : baseUrl;
|
||||
if (!base.endsWith("/v1")) base = base + "/v1";
|
||||
String url = base + "/checkout/sessions/" + sessionId;
|
||||
|
||||
try {
|
||||
long timestamp = System.currentTimeMillis() / 1000;
|
||||
java.net.http.HttpRequest.Builder requestBuilder = java.net.http.HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Authorization", "Bearer " + apiKey)
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(Duration.ofSeconds(15))
|
||||
.GET();
|
||||
|
||||
if (signingSecret != null && !signingSecret.trim().isBlank()) {
|
||||
String sig = computeWaveSignature(timestamp, "");
|
||||
requestBuilder.header("Wave-Signature", "t=" + timestamp + ",v1=" + sig);
|
||||
}
|
||||
|
||||
java.net.http.HttpClient client = java.net.http.HttpClient.newBuilder().build();
|
||||
java.net.http.HttpResponse<String> response = client.send(
|
||||
requestBuilder.build(),
|
||||
java.net.http.HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
|
||||
if (response.statusCode() >= 400) {
|
||||
throw new WaveCheckoutException("Wave API: " + response.statusCode() + " " + response.body());
|
||||
}
|
||||
|
||||
JsonNode root = objectMapper.readTree(response.body());
|
||||
String checkoutStatus = root.has("checkout_status") ? root.get("checkout_status").asText() : null;
|
||||
String paymentStatus = root.has("payment_status") ? root.get("payment_status").asText() : null;
|
||||
String transactionId = root.has("transaction_id") ? root.get("transaction_id").asText() : null;
|
||||
return new WaveSessionStatusResponse(sessionId, checkoutStatus, paymentStatus, transactionId);
|
||||
|
||||
} catch (WaveCheckoutException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
LOG.error(e.getMessage(), e);
|
||||
throw new WaveCheckoutException("Erreur vérification session Wave: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public String getRedirectBaseUrl() {
|
||||
return (redirectBaseUrl == null || redirectBaseUrl.trim().isBlank()) ? "http://localhost:8080" : redirectBaseUrl.trim();
|
||||
}
|
||||
@@ -159,6 +215,31 @@ public class WaveCheckoutService {
|
||||
return new WaveCheckoutSessionResponse(mockId, successUrl);
|
||||
}
|
||||
|
||||
public static final class WaveSessionStatusResponse {
|
||||
public final String sessionId;
|
||||
/** "open" | "complete" | "expired" */
|
||||
public final String checkoutStatus;
|
||||
/** "processing" | "cancelled" | "succeeded" */
|
||||
public final String paymentStatus;
|
||||
/** ID transaction Wave (TCN...) — non-null si succeeded */
|
||||
public final String transactionId;
|
||||
|
||||
public WaveSessionStatusResponse(String sessionId, String checkoutStatus, String paymentStatus, String transactionId) {
|
||||
this.sessionId = sessionId;
|
||||
this.checkoutStatus = checkoutStatus;
|
||||
this.paymentStatus = paymentStatus;
|
||||
this.transactionId = transactionId;
|
||||
}
|
||||
|
||||
public boolean isSucceeded() {
|
||||
return "succeeded".equals(paymentStatus) && "complete".equals(checkoutStatus);
|
||||
}
|
||||
|
||||
public boolean isExpired() {
|
||||
return "expired".equals(checkoutStatus);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class WaveCheckoutSessionResponse {
|
||||
public final String id;
|
||||
public final String waveLaunchUrl;
|
||||
|
||||
Reference in New Issue
Block a user