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
320 lines
13 KiB
Java
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());
|
|
}
|
|
}
|
|
}
|