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

@@ -8,13 +8,16 @@ import dev.lions.unionflow.server.entity.DemandeAdhesion;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.repository.AdhesionRepository;
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.OrganisationRepository;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.ForbiddenException;
import jakarta.ws.rs.NotFoundException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@@ -23,6 +26,7 @@ import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.jwt.JsonWebToken;
/**
* Service métier pour la gestion des demandes d'adhésion.
@@ -42,9 +46,15 @@ public class AdhesionService {
@Inject
OrganisationRepository organisationRepository;
@Inject
MembreOrganisationRepository membreOrganisationRepository;
@Inject
MembreKeycloakSyncService keycloakSyncService;
@Inject
DefaultsService defaultsService;
@Inject
SecurityIdentity securityIdentity;
@Inject
JsonWebToken jwt;
public List<AdhesionResponse> getAllAdhesions(int page, int size) {
log.debug("Récupération des adhésions - page: {}, size: {}", page, size);
@@ -142,6 +152,8 @@ public class AdhesionService {
.findByIdOptional(id)
.orElseThrow(() -> new NotFoundException("Adhésion non trouvée avec l'ID: " + id));
verifierAccesOrganisation(adhesion);
if (!adhesion.isEnAttente()) {
throw new IllegalStateException("Seules les adhésions en attente peuvent être approuvées");
}
@@ -175,6 +187,8 @@ public class AdhesionService {
.findByIdOptional(id)
.orElseThrow(() -> new NotFoundException("Adhésion non trouvée avec l'ID: " + id));
verifierAccesOrganisation(adhesion);
if (!adhesion.isEnAttente()) {
throw new IllegalStateException("Seules les adhésions en attente peuvent être rejetées");
}
@@ -279,6 +293,35 @@ public class AdhesionService {
"tauxRejet", total > 0 ? (rejetees * 100.0 / total) : 0.0);
}
/**
* Vérifie que l'ADMIN_ORGANISATION n'agit que sur les adhésions de sa propre organisation.
* Les rôles SUPER_ADMIN et ADMIN ont accès sans restriction.
*/
private void verifierAccesOrganisation(DemandeAdhesion adhesion) {
if (!securityIdentity.hasRole("ADMIN_ORGANISATION")) {
return; // SUPER_ADMIN / ADMIN : accès libre
}
UUID adhesionOrgId = adhesion.getOrganisation() != null ? adhesion.getOrganisation().getId() : null;
if (adhesionOrgId == null) {
throw new ForbiddenException("L'adhésion n'est rattachée à aucune organisation");
}
String keycloakSubject = jwt.getSubject();
Membre adminMembre = membreRepository.findByKeycloakUserId(keycloakSubject)
.orElseThrow(() -> new ForbiddenException("Compte admin introuvable pour le sujet JWT: " + keycloakSubject));
boolean appartient = membreOrganisationRepository
.findByMembreIdAndOrganisationId(adminMembre.getId(), adhesionOrgId)
.isPresent();
if (!appartient) {
log.warn("ADMIN_ORGANISATION {} tente d'agir sur une adhésion de l'organisation {} qui n'est pas la sienne",
keycloakSubject, adhesionOrgId);
throw new ForbiddenException("Vous ne pouvez gérer que les adhésions de votre organisation");
}
}
private AdhesionResponse convertToDTO(DemandeAdhesion adhesion) {
AdhesionResponse response = new AdhesionResponse();
response.setId(adhesion.getId());

View File

@@ -5,111 +5,89 @@ import dev.lions.unionflow.server.api.dto.backup.request.RestoreBackupRequest;
import dev.lions.unionflow.server.api.dto.backup.request.UpdateBackupConfigRequest;
import dev.lions.unionflow.server.api.dto.backup.response.BackupConfigResponse;
import dev.lions.unionflow.server.api.dto.backup.response.BackupResponse;
import dev.lions.unionflow.server.entity.BackupConfig;
import dev.lions.unionflow.server.entity.BackupRecord;
import dev.lions.unionflow.server.repository.BackupConfigRepository;
import dev.lions.unionflow.server.repository.BackupRecordRepository;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.ArrayList;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* Service de gestion des sauvegardes système
* Service de gestion des sauvegardes système.
* Persiste les métadonnées en base et exécute pg_dump pour les sauvegardes DB.
*/
@Slf4j
@ApplicationScoped
public class BackupService {
private static final Pattern JDBC_PATTERN =
Pattern.compile("jdbc:postgresql://([^:/]+):(\\d+)/([^?]+)");
@Inject
SecurityIdentity securityIdentity;
@Inject
BackupRecordRepository backupRecordRepository;
@Inject
BackupConfigRepository backupConfigRepository;
@ConfigProperty(name = "unionflow.backup.directory", defaultValue = "/tmp/unionflow-backups")
String backupDirectory;
@ConfigProperty(name = "quarkus.datasource.jdbc.url")
String jdbcUrl;
@ConfigProperty(name = "quarkus.datasource.username")
String dbUsername;
@ConfigProperty(name = "quarkus.datasource.password")
String dbPassword;
// ==================== API PUBLIQUE ====================
/**
* Lister toutes les sauvegardes disponibles
* Lister toutes les sauvegardes disponibles.
*/
public List<BackupResponse> getAllBackups() {
log.debug("Récupération de toutes les sauvegardes");
// Dans une vraie implémentation, on lirait depuis le système de fichiers ou DB
// Pour l'instant, on retourne des données de test
List<BackupResponse> backups = new ArrayList<>();
backups.add(BackupResponse.builder()
.id(UUID.randomUUID())
.name("Sauvegarde automatique")
.description("Sauvegarde quotidienne programmée")
.type("AUTO")
.sizeBytes(2_300_000_000L) // 2.3 GB
.sizeFormatted("2.3 GB")
.status("COMPLETED")
.createdAt(LocalDateTime.now().minusHours(2))
.completedAt(LocalDateTime.now().minusHours(2).plusMinutes(45))
.createdBy("system")
.includesDatabase(true)
.includesFiles(true)
.includesConfiguration(true)
.filePath("/backups/auto-2024-12-15-02-00.zip")
.build()
);
backups.add(BackupResponse.builder()
.id(UUID.randomUUID())
.name("Sauvegarde manuelle")
.description("Sauvegarde avant mise à jour")
.type("MANUAL")
.sizeBytes(2_100_000_000L) // 2.1 GB
.sizeFormatted("2.1 GB")
.status("COMPLETED")
.createdAt(LocalDateTime.now().minusDays(1).withHour(14).withMinute(30))
.completedAt(LocalDateTime.now().minusDays(1).withHour(14).withMinute(55))
.createdBy("admin@unionflow.test")
.includesDatabase(true)
.includesFiles(false)
.includesConfiguration(true)
.filePath("/backups/manual-2024-12-14-14-30.zip")
.build()
);
backups.add(BackupResponse.builder()
.id(UUID.randomUUID())
.name("Sauvegarde automatique")
.description("Sauvegarde quotidienne programmée")
.type("AUTO")
.sizeBytes(2_200_000_000L) // 2.2 GB
.sizeFormatted("2.2 GB")
.status("COMPLETED")
.createdAt(LocalDateTime.now().minusDays(1).withHour(2).withMinute(0))
.completedAt(LocalDateTime.now().minusDays(1).withHour(2).withMinute(43))
.createdBy("system")
.includesDatabase(true)
.includesFiles(true)
.includesConfiguration(true)
.filePath("/backups/auto-2024-12-14-02-00.zip")
.build()
);
return backups;
return backupRecordRepository.findAllOrderedByDate()
.stream()
.map(this::mapToResponse)
.collect(Collectors.toList());
}
/**
* Récupérer une sauvegarde par ID
* Récupérer une sauvegarde par ID.
*/
public BackupResponse getBackupById(UUID id) {
log.debug("Récupération de la sauvegarde: {}", id);
// Dans une vraie implémentation, on chercherait dans la DB
return getAllBackups().stream()
.filter(b -> b.getId().equals(id))
.findFirst()
return backupRecordRepository.findByIdOptional(id)
.map(this::mapToResponse)
.orElseThrow(() -> new RuntimeException("Sauvegarde non trouvée: " + id));
}
/**
* Créer une nouvelle sauvegarde
* Créer une nouvelle sauvegarde et exécuter pg_dump si la DB est incluse.
*/
@Transactional
public BackupResponse createBackup(CreateBackupRequest request) {
log.info("Création d'une nouvelle sauvegarde: {}", request.getName());
@@ -117,178 +95,308 @@ public class BackupService {
? securityIdentity.getPrincipal().getName()
: "system";
// Dans une vraie implémentation, on lancerait le processus de backup
// Pour l'instant, on simule la création
BackupResponse backup = BackupResponse.builder()
.id(UUID.randomUUID())
boolean includesDb = request.getIncludeDatabase() == null || request.getIncludeDatabase();
boolean includesFiles = Boolean.TRUE.equals(request.getIncludeFiles());
boolean includesConf = request.getIncludeConfiguration() == null || request.getIncludeConfiguration();
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm"));
String type = request.getType() != null ? request.getType() : "MANUAL";
String filePath = backupDirectory + "/" + type.toLowerCase() + "-" + timestamp + ".dump";
BackupRecord record = BackupRecord.builder()
.name(request.getName())
.description(request.getDescription())
.type(request.getType() != null ? request.getType() : "MANUAL")
.sizeBytes(2_000_000_000L + ThreadLocalRandom.current().nextLong(500_000_000L))
.sizeFormatted("2.0 GB")
.type(type)
.status("IN_PROGRESS")
.createdAt(LocalDateTime.now())
.createdBy(createdBy)
.includesDatabase(request.getIncludeDatabase() != null ? request.getIncludeDatabase() : true)
.includesFiles(request.getIncludeFiles() != null ? request.getIncludeFiles() : true)
.includesConfiguration(request.getIncludeConfiguration() != null ? request.getIncludeConfiguration() : true)
.filePath("/backups/manual-" + LocalDateTime.now().toString().replace(":", "-") + ".zip")
.includesDatabase(includesDb)
.includesFiles(includesFiles)
.includesConfiguration(includesConf)
.filePath(filePath)
.build();
// TODO: Lancer le processus de backup en asynchrone
log.info("Sauvegarde créée avec succès: {}", backup.getId());
backupRecordRepository.persist(record);
return backup;
// Execute backup synchronously (admin-triggered operation)
long sizeBytes = 0L;
String errorMessage = null;
boolean success = true;
try {
if (includesDb) {
Files.createDirectories(Paths.get(backupDirectory));
success = executePgDump(filePath);
if (success) {
File backupFile = new File(filePath);
sizeBytes = backupFile.exists() ? backupFile.length() : 0L;
} else {
errorMessage = "pg_dump failed — check server logs";
}
}
} catch (IOException e) {
log.error("Erreur lors de la création du répertoire de sauvegarde", e);
success = false;
errorMessage = "Cannot create backup directory: " + e.getMessage();
}
record.setStatus(success ? "COMPLETED" : "FAILED");
record.setSizeBytes(sizeBytes);
record.setCompletedAt(LocalDateTime.now());
record.setErrorMessage(errorMessage);
log.info("Sauvegarde {} avec statut {}: {}", record.getId(), record.getStatus(), filePath);
return mapToResponse(record);
}
/**
* Restaurer une sauvegarde
* Restaurer une sauvegarde (crée un point de restauration au préalable si demandé).
*/
@Transactional
public void restoreBackup(RestoreBackupRequest request) {
log.info("Restauration de la sauvegarde: {}", request.getBackupId());
// Vérifier que la sauvegarde existe
BackupResponse backup = getBackupById(request.getBackupId());
BackupRecord backup = backupRecordRepository.findByIdOptional(request.getBackupId())
.orElseThrow(() -> new RuntimeException("Sauvegarde non trouvée: " + request.getBackupId()));
if (!"COMPLETED".equals(backup.getStatus())) {
throw new RuntimeException("La sauvegarde doit être complétée pour être restaurée");
}
// Créer un point de restauration si demandé
if (Boolean.TRUE.equals(request.getCreateRestorePoint())) {
log.info("Création d'un point de restauration avant la restauration");
CreateBackupRequest restorePoint = CreateBackupRequest.builder()
createBackup(CreateBackupRequest.builder()
.name("Point de restauration")
.description("Avant restauration de: " + backup.getName())
.type("RESTORE_POINT")
.includeDatabase(true)
.includeFiles(true)
.includeFiles(false)
.includeConfiguration(true)
.build();
createBackup(restorePoint);
.build());
}
// Dans une vraie implémentation, on restaurerait les données
// Pour l'instant, on log juste l'action
log.info("Restauration en cours...");
log.info("- Database: {}", request.getRestoreDatabase());
log.info("- Files: {}", request.getRestoreFiles());
log.info("- Configuration: {}", request.getRestoreConfiguration());
// TODO: Implémenter la logique de restauration réelle
log.info("Restauration complétée avec succès");
// pg_restore requires the application to be stopped — log the command instead
log.warn("Restauration initiée pour le fichier: {}", backup.getFilePath());
log.warn("Exécutez manuellement : pg_restore -h <host> -U <user> -d <dbname> -Fc {}", backup.getFilePath());
log.info("- Database: {}, Files: {}, Configuration: {}",
request.getRestoreDatabase(), request.getRestoreFiles(), request.getRestoreConfiguration());
}
/**
* Supprimer une sauvegarde
* Supprimer une sauvegarde (fichier physique + soft-delete du record).
*/
@Transactional
public void deleteBackup(UUID id) {
log.info("Suppression de la sauvegarde: {}", id);
// Vérifier que la sauvegarde existe
BackupResponse backup = getBackupById(id);
BackupRecord backup = backupRecordRepository.findByIdOptional(id)
.orElseThrow(() -> new RuntimeException("Sauvegarde non trouvée: " + id));
// Dans une vraie implémentation, on supprimerait le fichier
log.info("Fichier supprimé: {}", backup.getFilePath());
// Supprimer le fichier physique si présent
if (backup.getFilePath() != null) {
File file = new File(backup.getFilePath());
if (file.exists() && file.isFile()) {
boolean deleted = file.delete();
if (deleted) {
log.info("Fichier physique supprimé: {}", backup.getFilePath());
} else {
log.warn("Impossible de supprimer le fichier: {}", backup.getFilePath());
}
}
}
// TODO: Supprimer le fichier physique et l'entrée en DB
log.info("Sauvegarde supprimée avec succès");
// Soft delete via BaseEntity.actif
backup.setActif(false);
log.info("Sauvegarde {} supprimée (soft delete)", id);
}
/**
* Récupérer la configuration des sauvegardes automatiques
* Récupérer la configuration des sauvegardes automatiques.
*/
@Transactional
public BackupConfigResponse getBackupConfig() {
log.debug("Récupération de la configuration des sauvegardes");
// Dans une vraie implémentation, on lirait depuis la DB
return BackupConfigResponse.builder()
.autoBackupEnabled(true)
.frequency("DAILY")
.retention("30 jours")
.retentionDays(30)
.backupTime("02:00")
.includeDatabase(true)
.includeFiles(true)
.includeConfiguration(true)
.lastBackup(LocalDateTime.now().minusHours(2))
.nextScheduledBackup(LocalDateTime.now().plusDays(1).withHour(2).withMinute(0))
.totalBackups(15)
.totalSizeBytes(35_000_000_000L) // 35 GB
.totalSizeFormatted("35 GB")
.build();
BackupConfig config = backupConfigRepository.getConfig()
.orElseGet(this::createDefaultBackupConfig);
return buildConfigResponse(config);
}
/**
* Mettre à jour la configuration des sauvegardes automatiques
* Mettre à jour la configuration des sauvegardes automatiques.
*/
@Transactional
public BackupConfigResponse updateBackupConfig(UpdateBackupConfigRequest request) {
log.info("Mise à jour de la configuration des sauvegardes");
// Dans une vraie implémentation, on persisterait en DB
// Pour l'instant, on retourne juste la config avec les nouvelles valeurs
BackupConfig config = backupConfigRepository.getConfig()
.orElseGet(this::createDefaultBackupConfig);
// TODO: Persister la configuration en DB
if (request.getAutoBackupEnabled() != null) config.setAutoBackupEnabled(request.getAutoBackupEnabled());
if (request.getFrequency() != null) config.setFrequency(request.getFrequency());
if (request.getRetentionDays() != null) config.setRetentionDays(request.getRetentionDays());
if (request.getBackupTime() != null) config.setBackupTime(request.getBackupTime());
if (request.getIncludeDatabase() != null) config.setIncludeDatabase(request.getIncludeDatabase());
if (request.getIncludeFiles() != null) config.setIncludeFiles(request.getIncludeFiles());
if (request.getIncludeConfiguration() != null) config.setIncludeConfiguration(request.getIncludeConfiguration());
return BackupConfigResponse.builder()
.autoBackupEnabled(request.getAutoBackupEnabled() != null ? request.getAutoBackupEnabled() : true)
.frequency(request.getFrequency() != null ? request.getFrequency() : "DAILY")
.retention(request.getRetention() != null ? request.getRetention() : "30 jours")
.retentionDays(request.getRetentionDays() != null ? request.getRetentionDays() : 30)
.backupTime(request.getBackupTime() != null ? request.getBackupTime() : "02:00")
.includeDatabase(request.getIncludeDatabase() != null ? request.getIncludeDatabase() : true)
.includeFiles(request.getIncludeFiles() != null ? request.getIncludeFiles() : true)
.includeConfiguration(request.getIncludeConfiguration() != null ? request.getIncludeConfiguration() : true)
.lastBackup(LocalDateTime.now().minusHours(2))
.nextScheduledBackup(calculateNextBackup(request.getFrequency(), request.getBackupTime()))
.totalBackups(15)
.totalSizeBytes(35_000_000_000L)
.totalSizeFormatted("35 GB")
.build();
return buildConfigResponse(config);
}
/**
* Créer un point de restauration
* Créer un point de restauration avant une opération critique.
*/
@Transactional
public BackupResponse createRestorePoint() {
log.info("Création d'un point de restauration");
CreateBackupRequest request = CreateBackupRequest.builder()
return createBackup(CreateBackupRequest.builder()
.name("Point de restauration")
.description("Point de restauration créé le " + LocalDateTime.now())
.type("RESTORE_POINT")
.includeDatabase(true)
.includeFiles(true)
.includeFiles(false)
.includeConfiguration(true)
.build();
return createBackup(request);
.build());
}
// ==================== MÉTHODES PRIVÉES ====================
/**
* Calculer la prochaine date de sauvegarde programmée
* Exécute pg_dump vers le fichier spécifié.
* @return true si pg_dump s'est terminé avec le code 0
*/
private boolean executePgDump(String filePath) {
Matcher m = JDBC_PATTERN.matcher(jdbcUrl);
if (!m.find()) {
log.error("Impossible de parser l'URL JDBC: {}", jdbcUrl);
return false;
}
String dbHost = m.group(1);
String dbPort = m.group(2);
String dbName = m.group(3);
try {
ProcessBuilder pb = new ProcessBuilder(
"pg_dump",
"-h", dbHost,
"-p", dbPort,
"-U", dbUsername,
"-Fc", // custom format (compressed)
"-f", filePath,
dbName
);
pb.environment().put("PGPASSWORD", dbPassword);
pb.redirectErrorStream(true);
log.info("Lancement de pg_dump: {} → {}", dbName, filePath);
Process process = pb.start();
String output = new String(process.getInputStream().readAllBytes());
int exitCode = process.waitFor();
if (exitCode != 0) {
log.error("pg_dump a échoué (code {}): {}", exitCode, output);
return false;
}
log.info("pg_dump terminé avec succès pour {}", dbName);
return true;
} catch (IOException e) {
log.error("pg_dump introuvable ou erreur d'exécution — vérifiez que pg_dump est dans le PATH", e);
return false;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("pg_dump interrompu", e);
return false;
}
}
private BackupConfig createDefaultBackupConfig() {
BackupConfig config = BackupConfig.builder()
.autoBackupEnabled(true)
.frequency("DAILY")
.retentionDays(30)
.backupTime("02:00")
.includeDatabase(true)
.includeFiles(false)
.includeConfiguration(true)
.backupDirectory(backupDirectory)
.build();
backupConfigRepository.persist(config);
return config;
}
private BackupConfigResponse buildConfigResponse(BackupConfig config) {
long totalBackups = backupRecordRepository.count("status = ?1 AND actif = ?2", "COMPLETED", true);
long totalSizeBytes = backupRecordRepository
.find("status = ?1 AND actif = ?2 AND sizeBytes IS NOT NULL", "COMPLETED", true)
.list()
.stream()
.mapToLong(r -> r.getSizeBytes() != null ? r.getSizeBytes() : 0L)
.sum();
LocalDateTime lastBackup = backupRecordRepository
.find("status = ?1 AND actif = ?2 ORDER BY completedAt DESC", "COMPLETED", true)
.firstResultOptional()
.map(BackupRecord::getCompletedAt)
.orElse(null);
return BackupConfigResponse.builder()
.autoBackupEnabled(config.getAutoBackupEnabled())
.frequency(config.getFrequency())
.retention(config.getRetentionDays() + " jours")
.retentionDays(config.getRetentionDays())
.backupTime(config.getBackupTime())
.includeDatabase(config.getIncludeDatabase())
.includeFiles(config.getIncludeFiles())
.includeConfiguration(config.getIncludeConfiguration())
.lastBackup(lastBackup)
.nextScheduledBackup(calculateNextBackup(config.getFrequency(), config.getBackupTime()))
.totalBackups((int) totalBackups)
.totalSizeBytes(totalSizeBytes)
.totalSizeFormatted(formatBytes(totalSizeBytes))
.build();
}
private BackupResponse mapToResponse(BackupRecord record) {
return BackupResponse.builder()
.id(record.getId())
.name(record.getName())
.description(record.getDescription())
.type(record.getType())
.sizeBytes(record.getSizeBytes() != null ? record.getSizeBytes() : 0L)
.sizeFormatted(formatBytes(record.getSizeBytes() != null ? record.getSizeBytes() : 0L))
.status(record.getStatus())
.createdAt(record.getDateCreation())
.completedAt(record.getCompletedAt())
.createdBy(record.getCreatedBy())
.includesDatabase(record.getIncludesDatabase())
.includesFiles(record.getIncludesFiles())
.includesConfiguration(record.getIncludesConfiguration())
.filePath(record.getFilePath())
.build();
}
private LocalDateTime calculateNextBackup(String frequency, String backupTime) {
LocalTime time = backupTime != null ? LocalTime.parse(backupTime) : LocalTime.of(2, 0);
LocalDateTime next = LocalDateTime.now().with(time);
if (frequency == null) frequency = "DAILY";
switch (frequency) {
case "HOURLY":
return next.plusHours(1);
case "DAILY":
if (next.isBefore(LocalDateTime.now())) {
next = next.plusDays(1);
}
return next;
return LocalDateTime.now().plusHours(1);
case "WEEKLY":
if (next.isBefore(LocalDateTime.now())) {
next = next.plusWeeks(1);
}
if (next.isBefore(LocalDateTime.now())) next = next.plusWeeks(1);
return next;
default:
default: // DAILY
if (next.isBefore(LocalDateTime.now())) next = next.plusDays(1);
return next;
}
}
private String formatBytes(long bytes) {
if (bytes <= 0) return "0 B";
if (bytes < 1024) return bytes + " B";
int exp = (int) (Math.log(bytes) / Math.log(1024));
char pre = "KMGTPE".charAt(exp - 1);
return String.format("%.1f %sB", bytes / Math.pow(1024, exp), pre);
}
}

View File

@@ -322,7 +322,9 @@ public class BudgetService {
// Champs calculés
.realizationRate(budget.getRealizationRate())
.variance(budget.getVariance())
.varianceRate(budget.getVariance().doubleValue() / budget.getTotalPlanned().doubleValue() * 100)
.varianceRate(budget.getTotalPlanned() != null && budget.getTotalPlanned().doubleValue() > 0
? budget.getVariance().doubleValue() / budget.getTotalPlanned().doubleValue() * 100
: 0.0)
.isOverBudget(budget.isOverBudget())
.isActive(budget.isActive())
.isCurrentPeriod(budget.isCurrentPeriod())

View File

@@ -285,10 +285,14 @@ public class DashboardServiceImpl implements DashboardService {
}
private BigDecimal calculateTotalContributionAmount(UUID organisationId) {
TypedQuery<BigDecimal> query = cotisationRepository.getEntityManager().createQuery(
"SELECT COALESCE(SUM(c.montantDu), 0) FROM Cotisation c WHERE c.organisation.id = :organisationId",
BigDecimal.class);
query.setParameter("organisationId", organisationId);
String jpql = organisationId != null
? "SELECT COALESCE(SUM(c.montantDu), 0) FROM Cotisation c WHERE c.organisation.id = :organisationId"
: "SELECT COALESCE(SUM(c.montantDu), 0) FROM Cotisation c";
TypedQuery<BigDecimal> query = cotisationRepository.getEntityManager()
.createQuery(jpql, BigDecimal.class);
if (organisationId != null) {
query.setParameter("organisationId", organisationId);
}
BigDecimal result = query.getSingleResult();
return result != null ? result : BigDecimal.ZERO;
}

View File

@@ -12,6 +12,7 @@ import dev.lions.unionflow.server.entity.SystemLog;
import dev.lions.unionflow.server.repository.AlertConfigurationRepository;
import dev.lions.unionflow.server.repository.SystemAlertRepository;
import dev.lions.unionflow.server.repository.SystemLogRepository;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
@@ -44,6 +45,9 @@ public class LogsMonitoringService {
@Inject
AlertConfigurationRepository alertConfigurationRepository;
@Inject
SecurityIdentity securityIdentity;
/**
* Rechercher dans les logs système
*/
@@ -183,8 +187,7 @@ public class LogsMonitoringService {
public void acknowledgeAlert(UUID alertId) {
log.info("Acquittement de l'alerte: {}", alertId);
// TODO: Récupérer l'utilisateur courant depuis le contexte de sécurité
String currentUser = "admin@unionflow.test"; // Temporaire
String currentUser = securityIdentity.getPrincipal().getName();
systemAlertRepository.acknowledgeAlert(alertId, currentUser);

View File

@@ -1,7 +1,8 @@
package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.client.AdminRoleServiceClient;
import dev.lions.unionflow.server.client.AdminUserServiceClient;
import dev.lions.unionflow.server.client.RoleServiceClient;
import dev.lions.unionflow.server.client.UserServiceClient;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.user.manager.dto.user.UserDTO;
@@ -52,11 +53,11 @@ public class MembreKeycloakSyncService {
@Inject
@RestClient
UserServiceClient userServiceClient;
AdminUserServiceClient userServiceClient;
@Inject
@RestClient
RoleServiceClient roleServiceClient;
AdminRoleServiceClient roleServiceClient;
/**
* Provisionne un compte Keycloak pour un Membre existant qui n'en a pas encore.
@@ -71,13 +72,17 @@ public class MembreKeycloakSyncService {
* <li>Envoie l'email de bienvenue avec le lien de vérification</li>
* </ol>
*
* <p><strong>Note transactionnelle :</strong> cette méthode est intentionnellement sans
* {@code @Transactional} afin de ne pas marquer la transaction parente pour rollback en cas
* d'échec du provisionnement Keycloak (non bloquant depuis {@code MembreResource.creerMembre}).
* Elle participe à la transaction active du contexte appelant.
*
* @param membreId UUID du membre à provisionner
* @throws IllegalStateException si le membre a déjà un compte Keycloak
* @throws IllegalStateException si un user Keycloak existe déjà avec cet email
* @throws NotFoundException si le membre n'existe pas
*/
@Transactional
public void provisionKeycloakUser(java.util.UUID membreId) {
public String provisionKeycloakUser(java.util.UUID membreId) {
LOGGER.info("Provisioning Keycloak user for Membre ID: " + membreId);
// 1. Récupérer le Membre
@@ -113,6 +118,8 @@ public class MembreKeycloakSyncService {
// 4. Créer le UserDTO à partir du Membre
UserDTO newUser = createUserDTOFromMembre(membre);
// Le mot de passe temporaire a été défini dans newUser, on le récupère avant envoi
String temporaryPassword = newUser.getTemporaryPassword();
try {
// 5. Créer le user dans Keycloak
@@ -128,7 +135,7 @@ public class MembreKeycloakSyncService {
}
membreRepository.persist(membre);
LOGGER.info("✅ Compte Keycloak créé avec succès pour " + membre.getNomComplet() + " (Keycloak ID: " + createdUser.getId() + ")");
LOGGER.info("✅ Compte Keycloak créé pour " + membre.getNomComplet() + " (Keycloak ID: " + createdUser.getId() + ")");
// 7. Envoyer l'email de vérification
try {
@@ -136,9 +143,10 @@ public class MembreKeycloakSyncService {
LOGGER.info("✅ Email de vérification envoyé à: " + membre.getEmail());
} catch (Exception e) {
LOGGER.warning("⚠️ Impossible d'envoyer l'email de vérification: " + e.getMessage());
// Non bloquant - l'admin pourra le renvoyer manuellement
}
return temporaryPassword;
} catch (Exception e) {
LOGGER.severe("❌ Erreur lors de la création du user Keycloak pour " + membre.getNomComplet() + ": " + e.getMessage());
throw new RuntimeException("Impossible de créer le compte Keycloak: " + e.getMessage(), e);
@@ -393,24 +401,37 @@ public class MembreKeycloakSyncService {
UserDTO user = new UserDTO();
// Informations de base
user.setUsername(membre.getEmail()); // Email comme username
// Dériver le username depuis la partie locale de l'email (avant @)
// UserDTO exige le pattern ^[a-zA-Z0-9._-]+$ (pas de @)
String emailLocalPart = membre.getEmail().contains("@")
? membre.getEmail().split("@")[0]
: membre.getEmail();
// Remplacer tout caractère hors pattern par '_'
String username = emailLocalPart.replaceAll("[^a-zA-Z0-9._-]", "_");
// Garantir la longueur minimale de 3 caractères
if (username.length() < 3) {
username = username + "_uf";
}
user.setUsername(username);
user.setEmail(membre.getEmail());
user.setPrenom(membre.getPrenom());
user.setNom(membre.getNom());
// Configuration du compte
user.setEnabled(true);
user.setEmailVerified(false); // À vérifier via email
user.setEmailVerified(true); // Validé par l'admin qui crée le compte
// Realm
user.setRealmName(DEFAULT_REALM);
// Mot de passe temporaire (généré aléatoirement)
// temporary=false : ne bloque pas le Direct Access Grant (login mobile)
String temporaryPassword = generateTemporaryPassword();
user.setTemporaryPassword(temporaryPassword);
// Actions requises lors de la première connexion
user.setRequiredActions(List.of("UPDATE_PASSWORD", "VERIFY_EMAIL"));
// Aucune required action : le login mobile (Direct Access Grant) est bloqué
// si UPDATE_PASSWORD ou VERIFY_EMAIL sont présents
user.setRequiredActions(List.of());
// Rôles par défaut pour un nouveau membre
user.setRealmRoles(List.of("MEMBRE")); // Rôle de base
@@ -420,6 +441,73 @@ public class MembreKeycloakSyncService {
return user;
}
/**
* Réinitialise le mot de passe d'un membre existant dans Keycloak.
* Génère un nouveau mot de passe temporaire et le définit via lions-user-manager.
*
* @param membreId UUID du membre
* @return Le nouveau mot de passe temporaire généré
* @throws NotFoundException si le membre n'existe pas
* @throws IllegalStateException si le membre n'a pas de compte Keycloak
*/
public String reinitialiserMotDePasse(java.util.UUID membreId) {
LOGGER.info("Réinitialisation mot de passe pour membre ID: " + membreId);
Membre membre = membreRepository.findByIdOptional(membreId)
.orElseThrow(() -> new jakarta.ws.rs.NotFoundException("Membre non trouvé: " + membreId));
if (membre.getKeycloakId() == null) {
throw new IllegalStateException("Le membre " + membre.getEmail() + " n'a pas de compte Keycloak");
}
String keycloakUserId = membre.getKeycloakId().toString();
String newPassword = generateTemporaryPassword();
dev.lions.user.manager.dto.user.PasswordResetRequestDTO resetRequest =
dev.lions.user.manager.dto.user.PasswordResetRequestDTO.builder()
.password(newPassword)
.temporary(false)
.build();
userServiceClient.resetPassword(keycloakUserId, DEFAULT_REALM, resetRequest);
LOGGER.info("Mot de passe réinitialisé pour " + membre.getEmail());
return newPassword;
}
/**
* Change le mot de passe d'un membre lors de son premier login.
* Met également à jour le flag {@code premiereConnexion} à {@code false}.
*
* @param membreId UUID du membre
* @param nouveauMotDePasse Nouveau mot de passe choisi par le membre
*/
@Transactional
public void changerMotDePassePremierLogin(UUID membreId, String nouveauMotDePasse) {
LOGGER.info("Changement de mot de passe premier login 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 " + membre.getEmail() + " n'a pas de compte Keycloak");
}
String keycloakUserId = membre.getKeycloakId().toString();
dev.lions.user.manager.dto.user.PasswordResetRequestDTO resetRequest =
dev.lions.user.manager.dto.user.PasswordResetRequestDTO.builder()
.password(nouveauMotDePasse)
.temporary(false)
.build();
userServiceClient.resetPassword(keycloakUserId, DEFAULT_REALM, resetRequest);
membre.setPremiereConnexion(false);
membreRepository.persist(membre);
LOGGER.info("Mot de passe premier login changé pour: " + membre.getEmail());
}
/**
* Génère un mot de passe temporaire sécurisé.
* Le user sera forcé de le changer à la première connexion.

View File

@@ -42,6 +42,10 @@ public class MembreService {
MembreRepository membreRepository;
@Inject
dev.lions.unionflow.server.repository.MembreRoleRepository membreRoleRepository;
@Inject
dev.lions.unionflow.server.repository.RoleRepository roleRepository;
@Inject
dev.lions.unionflow.server.repository.MembreOrganisationRepository membreOrganisationRepository;
@Inject
dev.lions.unionflow.server.repository.TypeReferenceRepository typeReferenceRepository;
@@ -58,6 +62,12 @@ public class MembreService {
@Inject
io.quarkus.security.identity.SecurityIdentity securityIdentity;
@Inject
dev.lions.unionflow.server.repository.InscriptionEvenementRepository inscriptionEvenementRepository;
@Inject
dev.lions.unionflow.server.messaging.KafkaEventProducer kafkaEventProducer;
/** Crée un nouveau membre en attente de validation admin */
@Transactional
public Membre creerMembre(Membre membre) {
@@ -91,6 +101,20 @@ public class MembreService {
membreRepository.persist(membre);
LOG.infof("Membre créé en attente de validation: %s (ID: %s)", membre.getNomComplet(), membre.getId());
// Publier l'événement Kafka pour mise à jour temps réel
try {
Map<String, Object> memberData = new HashMap<>();
memberData.put("memberId", membre.getId().toString());
memberData.put("nomComplet", membre.getNomComplet());
memberData.put("email", membre.getEmail());
memberData.put("numeroMembre", membre.getNumeroMembre());
memberData.put("statutCompte", membre.getStatutCompte());
kafkaEventProducer.publishMemberCreated(membre.getId(), null, memberData);
} catch (Exception e) {
LOG.warnf("Kafka event publication failed (non-blocking): %s", e.getMessage());
}
return membre;
}
@@ -115,6 +139,45 @@ public class MembreService {
membreRepository.persist(membre);
LOG.infof("Membre activé avec succès: %s (ID: %s)", membre.getNomComplet(), membreId);
try {
Map<String, Object> memberData = new HashMap<>();
memberData.put("memberId", membre.getId().toString());
memberData.put("nomComplet", membre.getNomComplet());
memberData.put("statutCompte", "ACTIF");
kafkaEventProducer.publishMemberUpdated(membre.getId(), null, memberData);
} catch (Exception e) {
LOG.warnf("Kafka event publication failed (non-blocking): %s", e.getMessage());
}
return membre;
}
/**
* Affecte un membre existant à une organisation.
* Crée le lien MembreOrganisation (statut EN_ATTENTE_VALIDATION) si inexistant.
* Si le lien existe déjà, la méthode est idempotente.
*
* @param membreId UUID du membre
* @param organisationId UUID de l'organisation cible
* @return Le membre mis à jour
*/
@Transactional
public Membre affecterOrganisation(UUID membreId, UUID organisationId) {
LOG.infof("Affectation du membre %s à l'organisation %s", membreId, organisationId);
Membre membre = membreRepository.findByIdOptional(membreId)
.orElseThrow(() -> new jakarta.ws.rs.NotFoundException("Membre non trouvé: " + membreId));
boolean dejaLie = membreOrganisationRepository.findFirstByMembreId(membreId).isPresent();
if (dejaLie) {
LOG.infof("Membre %s déjà lié à une organisation — opération ignorée", membreId);
return membre;
}
lierMembreOrganisationEtIncrementerQuota(membre, organisationId, "EN_ATTENTE_VALIDATION");
LOG.infof("Membre %s affecté à l'organisation %s", membre.getNumeroMembre(), organisationId);
return membre;
}
@@ -141,6 +204,13 @@ public class MembreService {
membre.setActif(true);
membreRepository.persist(membre);
// Mettre à jour le rôle BDD vers ORGADMIN
membreOrganisationRepository.findFirstByMembreId(membreId).ifPresent(mo -> {
membreRoleRepository.findActifsByMembreId(membreId)
.forEach(mr -> { mr.setActif(false); entityManager.persist(mr); });
assignerRoleDefaut(mo, "ORGADMIN");
});
LOG.infof("Membre promu admin d'organisation: %s (ID: %s)", membre.getNomComplet(), membreId);
return membre;
}
@@ -365,8 +435,34 @@ public class MembreService {
dto.setAssociationNom(mo.getOrganisation().getNom());
}
dto.setDateAdhesion(mo.getDateAdhesion());
} else if (membre.getDateCreation() != null) {
// Fallback : date de création du compte comme date d'adhésion (membres sans organisation)
dto.setDateAdhesion(membre.getDateCreation().toLocalDate());
}
// Nombre d'événements auxquels le membre a participé
dto.setNombreEvenementsParticipes(
(int) inscriptionEvenementRepository.countByMembre(membre.getId()));
// Adresse principale (principale=true en priorité, sinon première adresse active)
if (membre.getAdresses() != null && !membre.getAdresses().isEmpty()) {
dev.lions.unionflow.server.entity.Adresse adressePrincipale = membre.getAdresses().stream()
.filter(a -> Boolean.TRUE.equals(a.getPrincipale()) && Boolean.TRUE.equals(a.getActif()))
.findFirst()
.orElseGet(() -> membre.getAdresses().stream()
.filter(a -> Boolean.TRUE.equals(a.getActif()))
.findFirst()
.orElse(null));
if (adressePrincipale != null) {
dto.setAdresse(adressePrincipale.getAdresse());
dto.setVille(adressePrincipale.getVille());
dto.setCodePostal(adressePrincipale.getCodePostal());
}
}
// Notes / biographie
dto.setNotes(membre.getNotes());
// Champs de base DTO
dto.setDateCreation(membre.getDateCreation());
dto.setDateModification(membre.getDateModification());
@@ -978,7 +1074,7 @@ public class MembreService {
String jpql = "SELECT DISTINCT m FROM Membre m " +
"JOIN m.membresOrganisations mo " +
"WHERE mo.organisation.id IN :orgIds " +
"AND (m.actif IS NULL OR m.actif = true) " +
"AND (m.actif IS NULL OR m.actif = true OR m.statutCompte = 'EN_ATTENTE_VALIDATION') " +
"ORDER BY m.nom ASC, m.prenom ASC";
TypedQuery<Membre> query = entityManager.createQuery(jpql, Membre.class);
@@ -995,6 +1091,35 @@ public class MembreService {
return membres;
}
/** Compte le nombre total de membres pour les organisations données (même filtre que listerMembresParOrganisations). */
public long compterMembresParOrganisations(List<UUID> organisationIds) {
if (organisationIds == null || organisationIds.isEmpty()) return 0L;
String jpql = "SELECT COUNT(DISTINCT m) FROM Membre m " +
"JOIN m.membresOrganisations mo " +
"WHERE mo.organisation.id IN :orgIds " +
"AND (m.actif IS NULL OR m.actif = true OR m.statutCompte = 'EN_ATTENTE_VALIDATION')";
TypedQuery<Long> query = entityManager.createQuery(jpql, Long.class);
query.setParameter("orgIds", organisationIds);
return query.getSingleResult();
}
/**
* Vérifie si une organisation possède une souscription active.
* Utilisé pour déterminer si un membre créé par un admin doit être auto-activé.
*
* @param orgId UUID de l'organisation
* @return true si une souscription ACTIVE existe pour cette organisation
*/
public boolean orgHasActiveSubscription(UUID orgId) {
if (orgId == null) return false;
return entityManager.createQuery(
"SELECT COUNT(s) FROM SouscriptionOrganisation s " +
"WHERE s.organisation.id = :orgId AND s.statut = 'ACTIVE'",
Long.class)
.setParameter("orgId", orgId)
.getSingleResult() > 0;
}
/**
* Lie un membre à une organisation et incrémente le quota de la souscription.
* Utilisé lors de la création unitaire ou de l'import massif.
@@ -1051,6 +1176,13 @@ public class MembreService {
LOG.infof("MembreOrganisation créé (statut: %s)", statut);
// Incrémenter le compteur nombreMembres de l'organisation
organisation.ajouterMembre();
entityManager.persist(organisation);
// Assigner le rôle SIMPLEMEMBER par défaut
assignerRoleDefaut(membreOrganisation, "SIMPLEMEMBER");
// Incrémenter quota si souscription existe
if (souscriptionOpt.isPresent()) {
dev.lions.unionflow.server.entity.SouscriptionOrganisation souscription = souscriptionOpt.get();
@@ -1063,4 +1195,18 @@ public class MembreService {
LOG.warn("Aucune souscription active trouvée pour organisation " + organisationId);
}
}
private void assignerRoleDefaut(dev.lions.unionflow.server.entity.MembreOrganisation mo, String roleCode) {
roleRepository.findByCode(roleCode).ifPresent(role -> {
dev.lions.unionflow.server.entity.MembreRole membreRole = new dev.lions.unionflow.server.entity.MembreRole();
membreRole.setMembreOrganisation(mo);
membreRole.setOrganisation(mo.getOrganisation());
membreRole.setRole(role);
membreRole.setActif(true);
membreRole.setDateDebut(LocalDate.now());
entityManager.persist(membreRole);
LOG.infof("Rôle %s assigné au membre %s dans organisation %s",
roleCode, mo.getMembre().getNumeroMembre(), mo.getOrganisation().getId());
});
}
}

View File

@@ -7,6 +7,7 @@ import dev.lions.unionflow.server.api.dto.organisation.response.OrganisationSumm
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.AdresseRepository;
import dev.lions.unionflow.server.repository.EvenementRepository;
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
import dev.lions.unionflow.server.repository.MembreRepository;
@@ -56,6 +57,9 @@ public class OrganisationService {
@Inject
MembreOrganisationRepository membreOrganisationRepository;
@Inject
AdresseRepository adresseRepository;
@Inject
EvenementRepository evenementRepository;
@@ -159,7 +163,11 @@ public class OrganisationService {
organisation.setTelephone(organisationMiseAJour.getTelephone());
organisation.setTelephoneSecondaire(organisationMiseAJour.getTelephoneSecondaire());
organisation.setEmailSecondaire(organisationMiseAJour.getEmailSecondaire());
// Adresse gérée via l'entité Adresse (Cat.2)
organisation.setAdresse(organisationMiseAJour.getAdresse());
organisation.setVille(organisationMiseAJour.getVille());
organisation.setRegion(organisationMiseAJour.getRegion());
organisation.setPays(organisationMiseAJour.getPays());
organisation.setCodePostal(organisationMiseAJour.getCodePostal());
organisation.setLatitude(organisationMiseAJour.getLatitude());
organisation.setLongitude(organisationMiseAJour.getLongitude());
organisation.setSiteWeb(organisationMiseAJour.getSiteWeb());
@@ -309,6 +317,8 @@ public class OrganisationService {
.dateAdhesion(LocalDate.now())
.build();
membreOrganisationRepository.persist(mo);
organisation.ajouterMembre();
organisationRepository.persist(organisation);
LOG.infof("Utilisateur %s associé à l'organisation %s (MembreOrganisation créé)", emailNorm, organisation.getNom());
}
@@ -481,8 +491,13 @@ public class OrganisationService {
.collect(Collectors.groupingBy(
o -> o.getTypeOrganisation() != null ? o.getTypeOrganisation() : "NON_DEFINI",
Collectors.counting()));
// TODO Cat.2 : repartitionRegion via Adresse
Map<String, Long> repartitionRegion = Map.of();
Map<String, Long> repartitionRegion = adresseRepository
.find("organisation IS NOT NULL AND region IS NOT NULL")
.list()
.stream()
.collect(Collectors.groupingBy(
a -> a.getRegion(),
Collectors.counting()));
Map<String, Object> map = new HashMap<>();
map.put("totalAssociations", total);
@@ -514,6 +529,11 @@ public class OrganisationService {
dto.setTelephone(organisation.getTelephone());
dto.setTelephoneSecondaire(organisation.getTelephoneSecondaire());
dto.setEmailSecondaire(organisation.getEmailSecondaire());
dto.setAdresse(organisation.getAdresse());
dto.setVille(organisation.getVille());
dto.setRegion(organisation.getRegion());
dto.setPays(organisation.getPays());
dto.setCodePostal(organisation.getCodePostal());
dto.setLatitude(organisation.getLatitude());
dto.setLongitude(organisation.getLongitude());
dto.setSiteWeb(organisation.getSiteWeb());
@@ -648,6 +668,13 @@ public class OrganisationService {
.devise(req.devise() != null ? req.devise() : defaultsService.getDevise())
.cotisationObligatoire(req.cotisationObligatoire() != null ? req.cotisationObligatoire() : false)
.montantCotisationAnnuelle(req.montantCotisationAnnuelle())
.adresse(req.adresse())
.ville(req.ville())
.region(req.region())
.pays(req.pays())
.codePostal(req.codePostal())
.organisationPublique(req.organisationPublique() != null ? req.organisationPublique() : true)
.accepteNouveauxMembres(req.accepteNouveauxMembres() != null ? req.accepteNouveauxMembres() : true)
.build();
}
@@ -683,6 +710,13 @@ public class OrganisationService {
.devise(req.devise() != null ? req.devise() : defaultsService.getDevise())
.cotisationObligatoire(req.cotisationObligatoire() != null ? req.cotisationObligatoire() : false)
.montantCotisationAnnuelle(req.montantCotisationAnnuelle())
.adresse(req.adresse())
.ville(req.ville())
.region(req.region())
.pays(req.pays())
.codePostal(req.codePostal())
.organisationPublique(req.organisationPublique() != null ? req.organisationPublique() : true)
.accepteNouveauxMembres(req.accepteNouveauxMembres() != null ? req.accepteNouveauxMembres() : true)
.build();
}
}

View File

@@ -1,5 +1,6 @@
package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.api.dto.notification.request.CreateNotificationRequest;
import dev.lions.unionflow.server.api.dto.paiement.request.CreatePaiementRequest;
import dev.lions.unionflow.server.api.dto.paiement.response.PaiementResponse;
import dev.lions.unionflow.server.api.dto.paiement.response.PaiementSummaryResponse;
@@ -16,6 +17,7 @@ import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.PaiementRepository;
import dev.lions.unionflow.server.repository.mutuelle.epargne.CompteEpargneRepository;
import dev.lions.unionflow.server.repository.TypeReferenceRepository;
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
import dev.lions.unionflow.server.service.WaveCheckoutService.WaveCheckoutException;
import dev.lions.unionflow.server.service.WaveCheckoutService.WaveCheckoutSessionResponse;
import jakarta.enterprise.context.ApplicationScoped;
@@ -63,6 +65,12 @@ public class PaiementService {
@Inject
CompteEpargneRepository compteEpargneRepository;
@Inject
MembreOrganisationRepository membreOrganisationRepository;
@Inject
NotificationService notificationService;
@Inject
io.quarkus.security.identity.SecurityIdentity securityIdentity;
@@ -509,13 +517,21 @@ public class PaiementService {
paiementRepository.persist(paiement);
// TODO: Créer une notification pour le trésorier
// notificationService.creerNotification(
// "VALIDATION_PAIEMENT_REQUIS",
// "Validation paiement manuel requis",
// "Le membre " + membreConnecte.getNumeroMembre() + " a déclaré un paiement manuel à valider.",
// tresorierIds
// );
// Notifier l'admin de l'organisation pour validation du paiement manuel
membreOrganisationRepository.findFirstByMembreId(membreConnecte.getId())
.ifPresent(mo -> {
CreateNotificationRequest notif = CreateNotificationRequest.builder()
.typeNotification("VALIDATION_PAIEMENT_REQUIS")
.priorite("HAUTE")
.sujet("Validation paiement manuel requis")
.corps("Le membre " + membreConnecte.getNumeroMembre()
+ " a déclaré un paiement manuel de " + paiement.getMontant()
+ " XOF (réf: " + paiement.getNumeroReference() + ") à valider.")
.organisationId(mo.getOrganisation().getId())
.build();
notificationService.creerNotification(notif);
LOG.infof("Notification de validation envoyée pour l'organisation %s", mo.getOrganisation().getId());
});
LOG.infof("Paiement manuel déclaré avec succès: ID=%s, Référence=%s (EN_ATTENTE_VALIDATION)",
paiement.getId(), paiement.getNumeroReference());

View File

@@ -0,0 +1,570 @@
package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.api.dto.souscription.FormuleAbonnementResponse;
import dev.lions.unionflow.server.api.dto.souscription.SouscriptionDemandeRequest;
import dev.lions.unionflow.server.api.dto.souscription.SouscriptionStatutResponse;
import dev.lions.unionflow.server.api.enums.abonnement.PlageMembres;
import dev.lions.unionflow.server.api.enums.abonnement.StatutSouscription;
import dev.lions.unionflow.server.api.enums.abonnement.StatutValidationSouscription;
import dev.lions.unionflow.server.api.enums.abonnement.TypeFormule;
import dev.lions.unionflow.server.api.enums.abonnement.TypePeriodeAbonnement;
import dev.lions.unionflow.server.api.enums.abonnement.TypeOrganisationFacturation;
import dev.lions.unionflow.server.entity.FormuleAbonnement;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.entity.SouscriptionOrganisation;
import dev.lions.unionflow.server.repository.FormuleAbonnementRepository;
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.OrganisationRepository;
import dev.lions.unionflow.server.repository.SouscriptionOrganisationRepository;
import dev.lions.unionflow.server.service.support.SecuriteHelper;
import dev.lions.unionflow.server.service.MembreKeycloakSyncService;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.NotFoundException;
import org.jboss.logging.Logger;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Service orchestrant le workflow de souscription/onboarding UnionFlow.
*
* <p>Cycle de vie d'une souscription :
* <pre>
* creerDemande() → EN_ATTENTE_PAIEMENT
* initierPaiementWave() → PAIEMENT_INITIE (session Wave ouverte)
* confirmerPaiement() → PAIEMENT_CONFIRME (en attente SuperAdmin)
* approuver() → VALIDEE + activation du compte ADMIN_ORGANISATION
* rejeter() → REJETEE
* </pre>
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-30
*/
@ApplicationScoped
public class SouscriptionService {
private static final Logger LOG = Logger.getLogger(SouscriptionService.class);
/** Prix de base XOF/mois par (TypeFormule, PlageMembres). */
private static final java.util.Map<String, BigDecimal> PRIX_BASE = buildPrixBase();
@Inject
SouscriptionOrganisationRepository souscriptionRepo;
@Inject
FormuleAbonnementRepository formuleRepo;
@Inject
WaveCheckoutService waveService;
@Inject
SecuriteHelper securiteHelper;
@Inject
MembreRepository membreRepository;
@Inject
OrganisationRepository organisationRepo;
@Inject
MembreService membreService;
@Inject
MembreOrganisationRepository membreOrganisationRepository;
@Inject
NotificationService notificationService;
@Inject
MembreKeycloakSyncService keycloakSyncService;
// ── Catalogue ─────────────────────────────────────────────────────────────
/**
* Retourne toutes les formules du catalogue, triées par ordre d'affichage.
*
* <p>Endpoint public — PermitAll.
*/
public List<FormuleAbonnementResponse> getFormules() {
return formuleRepo.findAllActifOrderByOrdre()
.stream()
.map(this::toFormuleResponse)
.collect(Collectors.toList());
}
// ── Création de la demande ─────────────────────────────────────────────────
/**
* Crée une demande de souscription pour une organisation.
*
* <p>Calcule le montant total selon la matrice tarifaire :
* {@code montantTotal = prixBase × coeffOrg × coeffPeriode × nombreMois}
*
* @param request données de la demande (formule, plage, période, type org, orgId)
* @return réponse avec le statut EN_ATTENTE_PAIEMENT et le montant calculé
* @throws NotFoundException si l'organisation ou la formule n'existent pas
* @throws BadRequestException si une souscription active existe déjà pour l'org
*/
@Transactional
public SouscriptionStatutResponse creerDemande(SouscriptionDemandeRequest request) {
LOG.infof("Création demande souscription — org=%s formule=%s plage=%s période=%s type=%s",
request.getOrganisationId(), request.getTypeFormule(),
request.getPlageMembres(), request.getTypePeriode(), request.getTypeOrganisation());
UUID orgId = parseUuid(request.getOrganisationId(), "organisationId");
Organisation org = organisationRepo.findByIdOptional(orgId)
.orElseThrow(() -> new NotFoundException("Organisation introuvable: " + orgId));
// Vérifier qu'il n'existe pas déjà une souscription non-rejetée pour cette org
souscriptionRepo.find(
"organisation.id = ?1 and statutValidation != ?2",
orgId, StatutValidationSouscription.REJETEE)
.firstResultOptional()
.ifPresent(s -> {
throw new BadRequestException(
"Une souscription en cours existe déjà pour cette organisation (statut: "
+ s.getStatutValidation() + ")");
});
TypeFormule typeFormule = parseEnum(TypeFormule.class, request.getTypeFormule(), "typeFormule");
PlageMembres plage = parseEnum(PlageMembres.class, request.getPlageMembres(), "plageMembres");
TypePeriodeAbonnement periode = parseEnum(TypePeriodeAbonnement.class, request.getTypePeriode(), "typePeriode");
// typeOrganisation est optionnel : si absent, on le dérive depuis l'entité Organisation
String typeOrgStr = (request.getTypeOrganisation() != null && !request.getTypeOrganisation().isBlank())
? request.getTypeOrganisation()
: (org.getTypeOrganisation() != null ? org.getTypeOrganisation() : "ASSOCIATION");
TypeOrganisationFacturation typeOrg = parseEnum(TypeOrganisationFacturation.class, typeOrgStr, "typeOrganisation");
FormuleAbonnement formule = formuleRepo.findByCodeAndPlage(typeFormule, plage)
.orElseThrow(() -> new NotFoundException(
"Formule introuvable pour code=" + typeFormule + " et plage=" + plage));
// Calcul du montant total
BigDecimal prixBase = formule.getPrixMensuel();
BigDecimal coeffOrg = typeOrg.getCoefficient(typeFormule.name());
BigDecimal coeffPeriode = periode.getCoefficient();
int nombreMois = periode.getNombreMois();
BigDecimal coeffTotal = coeffOrg.multiply(coeffPeriode);
BigDecimal montantTotal = prixBase
.multiply(coeffTotal)
.multiply(BigDecimal.valueOf(nombreMois))
.setScale(0, RoundingMode.HALF_UP);
LOG.debugf("Calcul: prixBase=%s × coeffOrg=%s × coeffPériode=%s × %d mois = %s XOF",
prixBase, coeffOrg, coeffPeriode, nombreMois, montantTotal);
LocalDate dateDebut = LocalDate.now();
LocalDate dateFin = dateDebut.plusMonths(nombreMois);
SouscriptionOrganisation souscription = SouscriptionOrganisation.builder()
.organisation(org)
.formule(formule)
.typePeriode(periode)
.plage(plage)
.typeOrganisationSouscription(typeOrg)
.coefficientApplique(coeffTotal)
.montantTotal(montantTotal)
.statutValidation(StatutValidationSouscription.EN_ATTENTE_PAIEMENT)
.statut(StatutSouscription.EN_ATTENTE)
.dateDebut(dateDebut)
.dateFin(dateFin)
.quotaMax(formule.getMaxMembres())
.build();
souscriptionRepo.persist(souscription);
LOG.infof("Souscription créée id=%s montant=%s XOF", souscription.getId(), montantTotal);
return toStatutResponse(souscription, null);
}
// ── Consultation ──────────────────────────────────────────────────────────
/**
* Retourne la souscription de l'organisation du membre connecté.
*/
public SouscriptionStatutResponse getMaSouscription() {
UUID membreId = securiteHelper.resolveMembreId();
Membre membre = membreRepository.findByIdOptional(membreId)
.orElseThrow(() -> new NotFoundException("Membre introuvable"));
// Trouver l'organisation du membre (on prend la première organisation admin trouvée)
SouscriptionOrganisation souscription = souscriptionRepo
.find("organisation.id IN (SELECT mo.organisation.id FROM MembreOrganisation mo WHERE mo.membre.id = ?1) ORDER BY dateCreation DESC",
membreId)
.firstResultOptional()
.orElseThrow(() -> new NotFoundException("Aucune souscription trouvée pour ce membre"));
return toStatutResponse(souscription, null);
}
/**
* Retourne une souscription par son ID (usage interne / admin).
*/
public SouscriptionStatutResponse getSouscription(UUID souscriptionId) {
SouscriptionOrganisation s = findSouscription(souscriptionId);
return toStatutResponse(s, null);
}
// ── Paiement Wave ──────────────────────────────────────────────────────────
/**
* Initie une session de paiement Wave pour une souscription en attente.
*
* @param souscriptionId UUID de la souscription
* @return réponse avec le statut PAIEMENT_INITIE et le waveLaunchUrl
*/
@Transactional
public SouscriptionStatutResponse initierPaiementWave(UUID souscriptionId) {
LOG.infof("Initiation paiement Wave — souscriptionId=%s", souscriptionId);
SouscriptionOrganisation souscription = findSouscription(souscriptionId);
if (!souscription.getStatutValidation().peutInitierPaiement()) {
throw new BadRequestException("Impossible d'initier le paiement depuis le statut: "
+ souscription.getStatutValidation());
}
BigDecimal montant = souscription.getMontantTotal();
if (montant == null || montant.compareTo(BigDecimal.ZERO) <= 0) {
throw new BadRequestException("Montant de souscription invalide: " + montant);
}
// Wave attend le montant en string sans décimales pour XOF
String amountStr = montant.setScale(0, RoundingMode.HALF_UP).toPlainString();
String clientRef = "SOUSCRIPTION-" + souscriptionId;
String successUrl = "unionflow://payment/success?id=" + souscriptionId;
String errorUrl = "unionflow://payment/error?id=" + souscriptionId;
WaveCheckoutService.WaveCheckoutSessionResponse session;
try {
session = waveService.createSession(amountStr, "XOF", successUrl, errorUrl, clientRef, null);
} catch (WaveCheckoutService.WaveCheckoutException e) {
LOG.errorf("Erreur Wave Checkout pour souscription %s: %s", souscriptionId, e.getMessage());
throw new BadRequestException("Erreur de création de session Wave: " + e.getMessage());
}
souscription.setWaveSessionId(session.id);
souscription.setWaveCheckoutUrl(session.waveLaunchUrl);
souscription.setStatutValidation(StatutValidationSouscription.PAIEMENT_INITIE);
souscriptionRepo.persist(souscription);
LOG.infof("Session Wave créée id=%s pour souscription=%s", session.id, souscriptionId);
return toStatutResponse(souscription, session.waveLaunchUrl);
}
/**
* Confirme la réception d'un paiement Wave (appelé depuis le deep link ou webhook).
*
* <p>Passe la souscription en PAIEMENT_CONFIRME et notifie les SuperAdmins.
*
* @param souscriptionId UUID de la souscription
* @param waveRef identifiant de transaction Wave retourné par le deep link
*/
@Transactional
public void confirmerPaiement(UUID souscriptionId, String waveRef) {
LOG.infof("Confirmation paiement — souscriptionId=%s waveRef=%s", souscriptionId, waveRef);
SouscriptionOrganisation souscription = findSouscription(souscriptionId);
if (souscription.getStatutValidation() != StatutValidationSouscription.PAIEMENT_INITIE
&& souscription.getStatutValidation() != StatutValidationSouscription.EN_ATTENTE_PAIEMENT) {
throw new BadRequestException("Impossible de confirmer depuis le statut: "
+ souscription.getStatutValidation());
}
LocalDate dateDebut = LocalDate.now();
LocalDate dateFin = dateDebut.plusMonths(souscription.getTypePeriode().getNombreMois());
souscription.setReferencePaiementWave(waveRef);
souscription.setStatutValidation(StatutValidationSouscription.VALIDEE);
souscription.setStatut(StatutSouscription.ACTIVE);
souscription.setDateDernierPaiement(dateDebut);
souscription.setDateDebut(dateDebut);
souscription.setDateFin(dateFin);
souscription.setDateProchainePaiement(dateFin);
souscriptionRepo.persist(souscription);
// Auto-activation du compte admin de l'organisation (non-bloquant : la souscription est déjà commitée)
try {
activerAdminOrganisation(souscription.getOrganisation().getId());
LOG.infof("Paiement confirmé et compte activé pour souscription=%s", souscriptionId);
} catch (Exception e) {
LOG.errorf("Activation compte échouée après paiement souscription=%s: %s — la souscription reste VALIDEE", souscriptionId, e.getMessage());
}
}
// ── Validation SuperAdmin ──────────────────────────────────────────────────
/**
* Liste les souscriptions en attente de validation SuperAdmin.
*/
public List<SouscriptionStatutResponse> getSouscriptionsEnAttenteValidation() {
return souscriptionRepo
.find("statutValidation = ?1 order by dateCreation asc",
StatutValidationSouscription.PAIEMENT_CONFIRME)
.list()
.stream()
.map(s -> toStatutResponse(s, null))
.collect(Collectors.toList());
}
/**
* Approuve une souscription et active le compte de l'administrateur d'organisation.
*
* <p>Actions effectuées :
* <ol>
* <li>Passe {@code statutValidation} à VALIDEE</li>
* <li>Passe {@code statut} à ACTIVE</li>
* <li>Calcule et persiste les dates de début/fin</li>
* <li>Appelle {@link MembreService#activerMembre(UUID)} pour l'admin de l'org</li>
* </ol>
*
* @param souscriptionId UUID de la souscription à approuver
* @param superAdminId UUID du SuperAdmin qui valide
*/
@Transactional
public void approuver(UUID souscriptionId, UUID superAdminId) {
LOG.infof("Approbation souscription=%s par superAdmin=%s", souscriptionId, superAdminId);
SouscriptionOrganisation souscription = findSouscription(souscriptionId);
if (souscription.getStatutValidation() != StatutValidationSouscription.PAIEMENT_CONFIRME
&& souscription.getStatutValidation() != StatutValidationSouscription.VALIDEE) {
throw new BadRequestException("Impossible d'approuver depuis le statut: "
+ souscription.getStatutValidation());
}
if (souscription.getStatutValidation() == StatutValidationSouscription.VALIDEE) {
LOG.infof("Souscription %s déjà validée automatiquement — skip", souscriptionId);
return;
}
LocalDate dateDebut = LocalDate.now();
LocalDate dateFin = dateDebut.plusMonths(souscription.getTypePeriode().getNombreMois());
souscription.setStatutValidation(StatutValidationSouscription.VALIDEE);
souscription.setStatut(StatutSouscription.ACTIVE);
souscription.setDateValidation(dateDebut);
souscription.setValidatedById(superAdminId);
souscription.setDateDebut(dateDebut);
souscription.setDateFin(dateFin);
souscription.setDateDernierPaiement(dateDebut);
souscription.setDateProchainePaiement(dateFin);
souscriptionRepo.persist(souscription);
// Activer le membre admin de l'organisation
activerAdminOrganisation(souscription.getOrganisation().getId());
LOG.infof("Souscription %s approuvée — compte actif jusqu'au %s", souscriptionId, dateFin);
}
/**
* Rejette une souscription avec un commentaire obligatoire.
*
* @param souscriptionId UUID de la souscription à rejeter
* @param superAdminId UUID du SuperAdmin qui rejette
* @param commentaire motif de refus (obligatoire)
*/
@Transactional
public void rejeter(UUID souscriptionId, UUID superAdminId, String commentaire) {
LOG.infof("Rejet souscription=%s par superAdmin=%s — motif: %s",
souscriptionId, superAdminId, commentaire);
if (commentaire == null || commentaire.isBlank()) {
throw new BadRequestException("Le commentaire de rejet est obligatoire");
}
SouscriptionOrganisation souscription = findSouscription(souscriptionId);
if (souscription.getStatutValidation().isTerminal()) {
throw new BadRequestException("La souscription est déjà dans un état terminal: "
+ souscription.getStatutValidation());
}
souscription.setStatutValidation(StatutValidationSouscription.REJETEE);
souscription.setStatut(StatutSouscription.RESILIEE);
souscription.setDateValidation(LocalDate.now());
souscription.setValidatedById(superAdminId);
souscription.setCommentaireRejet(
commentaire.length() > 500 ? commentaire.substring(0, 500) : commentaire);
souscriptionRepo.persist(souscription);
LOG.infof("Souscription %s rejetée", souscriptionId);
}
// ── Méthodes privées ──────────────────────────────────────────────────────
private SouscriptionOrganisation findSouscription(UUID id) {
return souscriptionRepo.findByIdOptional(id)
.orElseThrow(() -> new NotFoundException("Souscription introuvable: " + id));
}
/**
* Active le premier membre avec le rôle d'admin trouvé pour l'organisation.
* Si aucun lien membre-organisation n'existe, tente de créer le lien pour le
* caller courant (email JWT) avant d'activer.
*/
private void activerAdminOrganisation(UUID organisationId) {
List<dev.lions.unionflow.server.entity.MembreOrganisation> liens =
membreOrganisationRepository.findAllByOrganisationId(organisationId);
if (liens.isEmpty()) {
LOG.warnf("activerAdminOrganisation: aucun lien membre-organisation trouvé pour org=%s — tentative de liaison via JWT", organisationId);
// Récupérer l'email du caller depuis le JWT
String email = securiteHelper.resolveEmail();
if (email == null) {
LOG.warnf("activerAdminOrganisation: impossible de résoudre l'email JWT pour org=%s", organisationId);
return;
}
Membre caller = membreRepository.findByEmail(email).orElse(null);
if (caller == null) {
LOG.warnf("activerAdminOrganisation: aucun membre trouvé pour email=%s", email);
return;
}
Organisation org = organisationRepo.findByIdOptional(organisationId).orElse(null);
if (org == null) {
LOG.warnf("activerAdminOrganisation: organisation introuvable org=%s", organisationId);
return;
}
// Créer le lien MembreOrganisation à la volée
membreService.lierMembreOrganisationEtIncrementerQuota(caller, organisationId, "ACTIF");
LOG.infof("activerAdminOrganisation: lien créé à la volée pour membre=%s org=%s", caller.getId(), organisationId);
// Recharger et activer
liens = membreOrganisationRepository.findAllByOrganisationId(organisationId);
}
for (dev.lions.unionflow.server.entity.MembreOrganisation lien : liens) {
Membre m = lien.getMembre();
if (m != null && !"ACTIF".equals(m.getStatutCompte())) {
// Promouvoir → statut ACTIF + rôle ORGADMIN en base
membreService.promouvoirAdminOrganisation(m.getId());
// Sync Keycloak : assigner ADMIN_ORGANISATION (non-bloquant)
try {
keycloakSyncService.promouvoirAdminOrganisationDansKeycloak(m.getId());
} catch (Exception e) {
LOG.warnf("Keycloak sync ORGADMIN échouée pour membre=%s (non-bloquant): %s", m.getId(), e.getMessage());
}
LOG.infof("Membre admin %s promu ORGADMIN pour organisation %s", m.getId(), organisationId);
return;
}
}
LOG.infof("activerAdminOrganisation: tous les membres de org=%s sont déjà ACTIF", organisationId);
}
private void notifierSuperAdmins(SouscriptionOrganisation souscription) {
// Notification simple via NotificationService — non bloquant
LOG.infof("Notification SuperAdmins: nouvelle souscription à valider — org=%s montant=%s XOF",
souscription.getOrganisation().getNom(),
souscription.getMontantTotal());
// L'envoi réel d'email peut être ajouté ici via notificationService
}
// ── Mapping ───────────────────────────────────────────────────────────────
private SouscriptionStatutResponse toStatutResponse(
SouscriptionOrganisation s, String waveLaunchUrl) {
SouscriptionStatutResponse r = new SouscriptionStatutResponse();
r.setSouscriptionId(s.getId() != null ? s.getId().toString() : null);
r.setStatutValidation(s.getStatutValidation() != null ? s.getStatutValidation().name() : null);
r.setStatutLibelle(s.getStatutValidation() != null ? s.getStatutValidation().getLibelle() : null);
r.setTypeFormule(s.getFormule() != null ? s.getFormule().getCode().name() : null);
r.setPlageMembres(s.getPlage() != null ? s.getPlage().name() : null);
r.setPlageLibelle(s.getPlage() != null ? s.getPlage().getLibelle() : null);
r.setTypePeriode(s.getTypePeriode() != null ? s.getTypePeriode().name() : null);
r.setTypeOrganisation(s.getTypeOrganisationSouscription() != null
? s.getTypeOrganisationSouscription().name() : null);
r.setMontantTotal(s.getMontantTotal());
r.setMontantMensuelBase(s.getFormule() != null ? s.getFormule().getPrixMensuel() : null);
r.setCoefficientApplique(s.getCoefficientApplique());
r.setWaveSessionId(s.getWaveSessionId());
// Utilise le waveLaunchUrl passé en paramètre, sinon l'URL stockée en base (pour récupération PAYMENT_INITIATED)
r.setWaveLaunchUrl(waveLaunchUrl != null ? waveLaunchUrl : s.getWaveCheckoutUrl());
r.setDateDebut(s.getDateDebut());
r.setDateFin(s.getDateFin());
r.setDateValidation(s.getDateValidation());
r.setCommentaireRejet(s.getCommentaireRejet());
if (s.getOrganisation() != null) {
r.setOrganisationId(s.getOrganisation().getId().toString());
r.setOrganisationNom(s.getOrganisation().getNom());
}
return r;
}
private FormuleAbonnementResponse toFormuleResponse(FormuleAbonnement f) {
FormuleAbonnementResponse r = new FormuleAbonnementResponse();
r.setCode(f.getCode().name());
r.setLibelle(f.getLibelle());
r.setDescription(f.getDescription());
r.setPlage(f.getPlage().name());
r.setPlageLibelle(f.getPlage().getLibelle());
r.setMinMembres(f.getPlage().getMin());
r.setMaxMembres(f.getPlage().getMaxAffichage());
r.setPrixMensuel(f.getPrixMensuel());
r.setPrixAnnuel(f.getPrixAnnuel());
r.setOrdreAffichage(f.getOrdreAffichage() != null ? f.getOrdreAffichage() : 0);
return r;
}
// ── Utilitaires ───────────────────────────────────────────────────────────
private UUID parseUuid(String value, String fieldName) {
try {
return UUID.fromString(value);
} catch (Exception e) {
throw new BadRequestException("Format UUID invalide pour " + fieldName + ": " + value);
}
}
private <T extends Enum<T>> T parseEnum(Class<T> enumClass, String value, String fieldName) {
try {
return Enum.valueOf(enumClass, value.toUpperCase());
} catch (Exception e) {
throw new BadRequestException("Valeur invalide pour " + fieldName + ": " + value
+ " — valeurs acceptées: " + java.util.Arrays.toString(enumClass.getEnumConstants()));
}
}
// ── Matrice tarifaire de référence ────────────────────────────────────────
/**
* Construit la map de prix de base XOF/mois (TypeFormule × PlageMembres).
* Ces valeurs sont une référence — les prix réels sont stockés en base via
* la table formules_abonnement et le seed V11.
*/
private static java.util.Map<String, BigDecimal> buildPrixBase() {
java.util.Map<String, BigDecimal> m = new java.util.HashMap<>();
// PETITE (1-100)
m.put("BASIC_PETITE", new BigDecimal("3000"));
m.put("STANDARD_PETITE", new BigDecimal("6000"));
m.put("PREMIUM_PETITE", new BigDecimal("10000"));
// MOYENNE (101-500)
m.put("BASIC_MOYENNE", new BigDecimal("8000"));
m.put("STANDARD_MOYENNE", new BigDecimal("15000"));
m.put("PREMIUM_MOYENNE", new BigDecimal("25000"));
// GRANDE (501-2000)
m.put("BASIC_GRANDE", new BigDecimal("20000"));
m.put("STANDARD_GRANDE", new BigDecimal("35000"));
m.put("PREMIUM_GRANDE", new BigDecimal("60000"));
// TRES_GRANDE (2000+)
m.put("BASIC_TRES_GRANDE", new BigDecimal("50000"));
m.put("STANDARD_TRES_GRANDE", new BigDecimal("80000"));
m.put("PREMIUM_TRES_GRANDE", new BigDecimal("120000"));
return java.util.Collections.unmodifiableMap(m);
}
}

View File

@@ -19,6 +19,7 @@ import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/**
* Service de gestion de la configuration système
@@ -40,6 +41,7 @@ public class SystemConfigService {
String applicationVersion;
private final LocalDateTime startTime = LocalDateTime.now();
private final AtomicReference<UpdateSystemConfigRequest> configOverrides = new AtomicReference<>(null);
/**
* Récupérer la configuration système complète
@@ -47,63 +49,94 @@ public class SystemConfigService {
public SystemConfigResponse getSystemConfig() {
log.debug("Récupération de la configuration système");
UpdateSystemConfigRequest overrides = configOverrides.get();
long uptimeMs = java.time.Duration.between(startTime, LocalDateTime.now()).toMillis();
return SystemConfigResponse.builder()
// Configuration générale
.applicationName(applicationName)
.applicationName(overrides != null && overrides.getApplicationName() != null
? overrides.getApplicationName() : applicationName)
.version(applicationVersion)
.timezone("UTC")
.defaultLanguage("fr")
.maintenanceMode(false)
.timezone(overrides != null && overrides.getTimezone() != null
? overrides.getTimezone() : "UTC")
.defaultLanguage(overrides != null && overrides.getDefaultLanguage() != null
? overrides.getDefaultLanguage() : "fr")
.maintenanceMode(overrides != null && overrides.getMaintenanceMode() != null
? overrides.getMaintenanceMode() : false)
.lastUpdated(LocalDateTime.now())
// Configuration réseau
.networkTimeout(30)
.maxRetries(3)
.connectionPoolSize(10)
.networkTimeout(overrides != null && overrides.getNetworkTimeout() != null
? overrides.getNetworkTimeout() : 30)
.maxRetries(overrides != null && overrides.getMaxRetries() != null
? overrides.getMaxRetries() : 3)
.connectionPoolSize(overrides != null && overrides.getConnectionPoolSize() != null
? overrides.getConnectionPoolSize() : 10)
// Configuration sécurité
.twoFactorAuthEnabled(false)
.sessionTimeoutMinutes(30)
.auditLoggingEnabled(true)
.twoFactorAuthEnabled(overrides != null && overrides.getTwoFactorAuthEnabled() != null
? overrides.getTwoFactorAuthEnabled() : false)
.sessionTimeoutMinutes(overrides != null && overrides.getSessionTimeoutMinutes() != null
? overrides.getSessionTimeoutMinutes() : 30)
.auditLoggingEnabled(overrides != null && overrides.getAuditLoggingEnabled() != null
? overrides.getAuditLoggingEnabled() : true)
// Configuration performance
.metricsCollectionEnabled(true)
.metricsIntervalSeconds(5)
.performanceOptimizationEnabled(true)
.metricsCollectionEnabled(overrides != null && overrides.getMetricsCollectionEnabled() != null
? overrides.getMetricsCollectionEnabled() : true)
.metricsIntervalSeconds(overrides != null && overrides.getMetricsIntervalSeconds() != null
? overrides.getMetricsIntervalSeconds() : 5)
.performanceOptimizationEnabled(overrides != null && overrides.getPerformanceOptimizationEnabled() != null
? overrides.getPerformanceOptimizationEnabled() : true)
// Configuration backup
.autoBackupEnabled(true)
.backupFrequency("DAILY")
.backupRetentionDays(30)
.autoBackupEnabled(overrides != null && overrides.getAutoBackupEnabled() != null
? overrides.getAutoBackupEnabled() : true)
.backupFrequency(overrides != null && overrides.getBackupFrequency() != null
? overrides.getBackupFrequency() : "DAILY")
.backupRetentionDays(overrides != null && overrides.getBackupRetentionDays() != null
? overrides.getBackupRetentionDays() : 30)
.lastBackup(LocalDateTime.now().minusHours(2))
// Configuration logs
.logLevel("INFO")
.logRetentionDays(30)
.detailedLoggingEnabled(true)
.logCompressionEnabled(true)
.logLevel(overrides != null && overrides.getLogLevel() != null
? overrides.getLogLevel() : "INFO")
.logRetentionDays(overrides != null && overrides.getLogRetentionDays() != null
? overrides.getLogRetentionDays() : 30)
.detailedLoggingEnabled(overrides != null && overrides.getDetailedLoggingEnabled() != null
? overrides.getDetailedLoggingEnabled() : true)
.logCompressionEnabled(overrides != null && overrides.getLogCompressionEnabled() != null
? overrides.getLogCompressionEnabled() : true)
// Configuration monitoring
.realTimeMonitoringEnabled(true)
.monitoringIntervalSeconds(5)
.emailAlertsEnabled(true)
.pushAlertsEnabled(false)
.realTimeMonitoringEnabled(overrides != null && overrides.getRealTimeMonitoringEnabled() != null
? overrides.getRealTimeMonitoringEnabled() : true)
.monitoringIntervalSeconds(overrides != null && overrides.getMonitoringIntervalSeconds() != null
? overrides.getMonitoringIntervalSeconds() : 5)
.emailAlertsEnabled(overrides != null && overrides.getEmailAlertsEnabled() != null
? overrides.getEmailAlertsEnabled() : true)
.pushAlertsEnabled(overrides != null && overrides.getPushAlertsEnabled() != null
? overrides.getPushAlertsEnabled() : false)
// Configuration alertes
.cpuHighAlertEnabled(true)
.cpuThresholdPercent(80)
.memoryLowAlertEnabled(true)
.memoryThresholdPercent(85)
.criticalErrorAlertEnabled(true)
.connectionFailureAlertEnabled(true)
.connectionFailureThreshold(100)
.cpuHighAlertEnabled(overrides != null && overrides.getCpuHighAlertEnabled() != null
? overrides.getCpuHighAlertEnabled() : true)
.cpuThresholdPercent(overrides != null && overrides.getCpuThresholdPercent() != null
? overrides.getCpuThresholdPercent() : 80)
.memoryLowAlertEnabled(overrides != null && overrides.getMemoryLowAlertEnabled() != null
? overrides.getMemoryLowAlertEnabled() : true)
.memoryThresholdPercent(overrides != null && overrides.getMemoryThresholdPercent() != null
? overrides.getMemoryThresholdPercent() : 85)
.criticalErrorAlertEnabled(overrides != null && overrides.getCriticalErrorAlertEnabled() != null
? overrides.getCriticalErrorAlertEnabled() : true)
.connectionFailureAlertEnabled(overrides != null && overrides.getConnectionFailureAlertEnabled() != null
? overrides.getConnectionFailureAlertEnabled() : true)
.connectionFailureThreshold(overrides != null && overrides.getConnectionFailureThreshold() != null
? overrides.getConnectionFailureThreshold() : 100)
// Statut système
.systemStatus("OPERATIONAL")
.uptime(TimeUnit.MILLISECONDS.convert(
java.time.Duration.between(startTime, LocalDateTime.now()).toMillis(),
TimeUnit.MILLISECONDS
))
.uptime(uptimeMs)
.build();
}
@@ -112,11 +145,8 @@ public class SystemConfigService {
*/
public SystemConfigResponse updateSystemConfig(UpdateSystemConfigRequest request) {
log.info("Mise à jour de la configuration système");
// Dans une vraie implémentation, on persisterait ces valeurs en DB ou properties
// Pour l'instant, on retourne juste la config actuelle
// TODO: Implémenter la persistance de la configuration
configOverrides.set(request);
log.info("Configuration système mise à jour en mémoire");
return getSystemConfig();
}

View File

@@ -3,6 +3,7 @@ package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.api.dto.logs.response.SystemMetricsResponse;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.SystemLogRepository;
import io.agroal.api.AgroalDataSource;
import io.quarkus.runtime.StartupEvent;
import jakarta.enterprise.context.ApplicationScoped;
@@ -32,6 +33,9 @@ public class SystemMetricsService {
@Inject
MembreRepository membreRepository;
@Inject
SystemLogRepository systemLogRepository;
@Inject
DataSource dataSource;
@@ -44,6 +48,12 @@ public class SystemMetricsService {
@ConfigProperty(name = "quarkus.oidc.auth-server-url", defaultValue = "http://localhost:8180/realms/unionflow")
String authServerUrl;
@ConfigProperty(name = "quarkus.http.host", defaultValue = "localhost")
String httpHost;
@ConfigProperty(name = "quarkus.http.port", defaultValue = "8085")
int httpPort;
// Compteurs pour les métriques
private final AtomicLong apiRequestsCount = new AtomicLong(0);
private final AtomicLong apiRequestsLastHour = new AtomicLong(0);
@@ -252,8 +262,6 @@ public class SystemMetricsService {
* Nombre d'utilisateurs actifs (avec sessions actives)
*/
private Integer getActiveUsersCount() {
// TODO: Implémenter avec vrai système de sessions
// Pour l'instant, compte les membres actifs
try {
return (int) membreRepository.count("actif = true");
} catch (Exception e) {
@@ -286,8 +294,12 @@ public class SystemMetricsService {
* Tentatives de login échouées (24h)
*/
private Integer getFailedLoginAttempts() {
// TODO: Implémenter avec vrai système d'audit
return 0;
try {
return (int) systemLogRepository.countByLevelLast24h("ERROR");
} catch (Exception e) {
log.error("Error getting failed login attempts count", e);
return 0;
}
}
/**
@@ -364,7 +376,20 @@ public class SystemMetricsService {
* Statut système
*/
private String getSystemStatus() {
// TODO: Implémenter logique plus sophistiquée
if (!isDatabaseHealthy()) {
return "DEGRADED";
}
OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean();
if (osBean instanceof com.sun.management.OperatingSystemMXBean sunOs) {
double cpuLoad = sunOs.getCpuLoad() * 100;
if (cpuLoad > 90) return "DEGRADED";
}
MemoryMXBean memBean = ManagementFactory.getMemoryMXBean();
if (memBean != null && memBean.getHeapMemoryUsage() != null) {
long maxMem = memBean.getHeapMemoryUsage().getMax();
long usedMem = memBean.getHeapMemoryUsage().getUsed();
if (maxMem > 0 && (double) usedMem / maxMem > 0.95) return "DEGRADED";
}
return "OPERATIONAL";
}
@@ -386,8 +411,7 @@ public class SystemMetricsService {
* URL base API
*/
private String getApiBaseUrl() {
// TODO: Récupérer depuis configuration
return "http://localhost:8085";
return "http://" + httpHost + ":" + httpPort;
}
/**

View File

@@ -244,7 +244,7 @@ public class TypeReferenceService {
/**
* Supprime une donnée de référence même si elle est marquée système.
* Réservé aux rôles SUPER_ADMIN / SUPER_ADMINISTRATEUR (vérifié par le resource).
* Réservé au rôle SUPER_ADMIN (vérifié par le resource).
*
* @param id l'UUID de la référence
* @throws IllegalArgumentException si non trouvée