Files
unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/service/MemberLifecycleService.java
dahoud a2dfae9a0b 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
2026-04-07 20:52:26 +00:00

320 lines
13 KiB
Java

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