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:
@@ -0,0 +1,50 @@
|
||||
package dev.lions.unionflow.server.client;
|
||||
|
||||
import dev.lions.user.manager.dto.role.RoleDTO;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import org.eclipse.microprofile.rest.client.annotation.RegisterClientHeaders;
|
||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* REST Client admin pour l'API rôles de lions-user-manager (Keycloak).
|
||||
*
|
||||
* <p>Utilise {@link AdminServiceTokenHeadersFactory} pour injecter le token
|
||||
* du service account "admin-service" (client credentials grant).
|
||||
*/
|
||||
@Path("/api/roles")
|
||||
@RegisterRestClient(configKey = "lions-user-manager-api")
|
||||
@RegisterClientHeaders(AdminServiceTokenHeadersFactory.class)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public interface AdminRoleServiceClient {
|
||||
|
||||
@GET
|
||||
@Path("/realm")
|
||||
List<RoleDTO> getRealmRoles(@QueryParam("realm") String realmName);
|
||||
|
||||
@GET
|
||||
@Path("/user/realm/{userId}")
|
||||
List<RoleDTO> getUserRealmRoles(
|
||||
@PathParam("userId") String userId,
|
||||
@QueryParam("realm") String realmName
|
||||
);
|
||||
|
||||
@POST
|
||||
@Path("/assign/realm/{userId}")
|
||||
void assignRealmRoles(
|
||||
@PathParam("userId") String userId,
|
||||
@QueryParam("realm") String realmName,
|
||||
RoleServiceClient.RoleNamesRequest request
|
||||
);
|
||||
|
||||
@POST
|
||||
@Path("/revoke/realm/{userId}")
|
||||
void revokeRealmRoles(
|
||||
@PathParam("userId") String userId,
|
||||
@QueryParam("realm") String realmName,
|
||||
RoleServiceClient.RoleNamesRequest request
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package dev.lions.unionflow.server.client;
|
||||
|
||||
import io.quarkus.oidc.client.NamedOidcClient;
|
||||
import io.quarkus.oidc.client.OidcClient;
|
||||
import io.quarkus.oidc.client.Tokens;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.core.MultivaluedHashMap;
|
||||
import jakarta.ws.rs.core.MultivaluedMap;
|
||||
import org.eclipse.microprofile.rest.client.ext.ClientHeadersFactory;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
/**
|
||||
* Injecte le token du service account "admin-service" (client credentials grant)
|
||||
* dans tous les appels faits via {@link AdminUserServiceClient} et {@link AdminRoleServiceClient}.
|
||||
*
|
||||
* <p>Utilise directement l'API {@link OidcClient} pour récupérer/rafraîchir le token.
|
||||
* Cette approche explicite évite toute ambiguïté avec {@code @OidcClientFilter} quand
|
||||
* plusieurs interfaces REST partagent le même configKey.
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class AdminServiceTokenHeadersFactory implements ClientHeadersFactory {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(AdminServiceTokenHeadersFactory.class);
|
||||
|
||||
@Inject
|
||||
@NamedOidcClient("admin-service")
|
||||
OidcClient adminOidcClient;
|
||||
|
||||
@Override
|
||||
public MultivaluedMap<String, String> update(
|
||||
MultivaluedMap<String, String> incomingHeaders,
|
||||
MultivaluedMap<String, String> clientOutgoingHeaders) {
|
||||
|
||||
MultivaluedMap<String, String> result = new MultivaluedHashMap<>();
|
||||
try {
|
||||
Tokens tokens = adminOidcClient.getTokens().await().indefinitely();
|
||||
result.add("Authorization", "Bearer " + tokens.getAccessToken());
|
||||
LOG.debugf("Token service account injecté pour admin-service (longueur: %d)",
|
||||
tokens.getAccessToken().length());
|
||||
} catch (Exception e) {
|
||||
LOG.errorf("Impossible d'obtenir le token service account 'admin-service': %s", e.getMessage());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package dev.lions.unionflow.server.client;
|
||||
|
||||
import dev.lions.user.manager.dto.user.UserDTO;
|
||||
import dev.lions.user.manager.dto.user.UserSearchCriteriaDTO;
|
||||
import dev.lions.user.manager.dto.user.UserSearchResultDTO;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import org.eclipse.microprofile.rest.client.annotation.RegisterClientHeaders;
|
||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
|
||||
|
||||
/**
|
||||
* REST Client admin pour le service de gestion des utilisateurs Keycloak
|
||||
* via lions-user-manager API.
|
||||
*
|
||||
* <p>Utilise {@link AdminServiceTokenHeadersFactory} pour injecter le token
|
||||
* du service account "admin-service" (client credentials grant) de façon
|
||||
* explicite, sans ambiguïté avec les autres clients partageant le même configKey.
|
||||
*/
|
||||
@Path("/api/users")
|
||||
@RegisterRestClient(configKey = "lions-user-manager-api")
|
||||
@RegisterClientHeaders(AdminServiceTokenHeadersFactory.class)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public interface AdminUserServiceClient {
|
||||
|
||||
@POST
|
||||
@Path("/search")
|
||||
UserSearchResultDTO searchUsers(UserSearchCriteriaDTO criteria);
|
||||
|
||||
@GET
|
||||
@Path("/{userId}")
|
||||
UserDTO getUserById(
|
||||
@PathParam("userId") String userId,
|
||||
@QueryParam("realm") String realmName);
|
||||
|
||||
@POST
|
||||
UserDTO createUser(
|
||||
UserDTO user,
|
||||
@QueryParam("realm") String realmName);
|
||||
|
||||
@PUT
|
||||
@Path("/{userId}")
|
||||
UserDTO updateUser(
|
||||
@PathParam("userId") String userId,
|
||||
UserDTO user,
|
||||
@QueryParam("realm") String realmName);
|
||||
|
||||
@DELETE
|
||||
@Path("/{userId}")
|
||||
void deleteUser(
|
||||
@PathParam("userId") String userId,
|
||||
@QueryParam("realm") String realmName);
|
||||
|
||||
@POST
|
||||
@Path("/{userId}/send-verification-email")
|
||||
void sendVerificationEmail(
|
||||
@PathParam("userId") String userId,
|
||||
@QueryParam("realm") String realmName);
|
||||
|
||||
@POST
|
||||
@Path("/{userId}/reset-password")
|
||||
void resetPassword(
|
||||
@PathParam("userId") String userId,
|
||||
@QueryParam("realm") String realmName,
|
||||
dev.lions.user.manager.dto.user.PasswordResetRequestDTO request);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package dev.lions.unionflow.server.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Entity
|
||||
@Table(name = "backup_config")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class BackupConfig extends BaseEntity {
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private Boolean autoBackupEnabled = true;
|
||||
|
||||
/** HOURLY, DAILY, WEEKLY */
|
||||
@Column(nullable = false, length = 20)
|
||||
@Builder.Default
|
||||
private String frequency = "DAILY";
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private Integer retentionDays = 30;
|
||||
|
||||
/** HH:mm format, e.g. "02:00" */
|
||||
@Column(nullable = false, length = 10)
|
||||
@Builder.Default
|
||||
private String backupTime = "02:00";
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private Boolean includeDatabase = true;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private Boolean includeFiles = false;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private Boolean includeConfiguration = true;
|
||||
|
||||
/** Absolute path where backup files are stored */
|
||||
@Column(length = 500)
|
||||
private String backupDirectory;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package dev.lions.unionflow.server.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "backup_records")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class BackupRecord extends BaseEntity {
|
||||
|
||||
@Column(nullable = false, length = 200)
|
||||
private String name;
|
||||
|
||||
@Column(length = 500)
|
||||
private String description;
|
||||
|
||||
/** AUTO, MANUAL, RESTORE_POINT */
|
||||
@Column(nullable = false, length = 50)
|
||||
private String type;
|
||||
|
||||
private Long sizeBytes;
|
||||
|
||||
/** IN_PROGRESS, COMPLETED, FAILED */
|
||||
@Column(nullable = false, length = 50)
|
||||
private String status;
|
||||
|
||||
private LocalDateTime completedAt;
|
||||
|
||||
@Column(length = 200)
|
||||
private String createdBy;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private Boolean includesDatabase = true;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private Boolean includesFiles = false;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private Boolean includesConfiguration = true;
|
||||
|
||||
@Column(length = 500)
|
||||
private String filePath;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String errorMessage;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package dev.lions.unionflow.server.entity;
|
||||
|
||||
import dev.lions.unionflow.server.api.enums.abonnement.PlageMembres;
|
||||
import dev.lions.unionflow.server.api.enums.abonnement.TypeFormule;
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.*;
|
||||
@@ -18,8 +19,10 @@ import lombok.*;
|
||||
@Table(
|
||||
name = "formules_abonnement",
|
||||
indexes = {
|
||||
@Index(name = "idx_formule_code", columnList = "code", unique = true),
|
||||
@Index(name = "idx_formule_actif", columnList = "actif")
|
||||
@Index(name = "idx_formule_code_plage", columnList = "code, plage", unique = true),
|
||||
@Index(name = "idx_formule_code", columnList = "code"),
|
||||
@Index(name = "idx_formule_plage", columnList = "plage"),
|
||||
@Index(name = "idx_formule_actif", columnList = "actif")
|
||||
})
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@@ -30,9 +33,18 @@ public class FormuleAbonnement extends BaseEntity {
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@NotNull
|
||||
@Column(name = "code", unique = true, nullable = false, length = 20)
|
||||
@Column(name = "code", nullable = false, length = 20)
|
||||
private TypeFormule code;
|
||||
|
||||
/**
|
||||
* Plage de taille d'organisation à laquelle cette formule s'applique.
|
||||
* Combinée avec le code de formule, forme une clé unique dans le catalogue.
|
||||
*/
|
||||
@Enumerated(EnumType.STRING)
|
||||
@NotNull
|
||||
@Column(name = "plage", nullable = false, length = 20)
|
||||
private PlageMembres plage;
|
||||
|
||||
@NotBlank
|
||||
@Column(name = "libelle", nullable = false, length = 100)
|
||||
private String libelle;
|
||||
|
||||
@@ -59,8 +59,8 @@ public class Membre extends BaseEntity {
|
||||
@Column(name = "telephone", length = 20)
|
||||
private String telephone;
|
||||
|
||||
@Pattern(regexp = "^\\+225[0-9]{8}$", message = "Le numéro Wave doit être au format +225XXXXXXXX")
|
||||
@Column(name = "telephone_wave", length = 13)
|
||||
@Pattern(regexp = "^\\+[1-9][0-9]{6,14}$", message = "Le numéro Wave doit être au format international E.164 (ex: +22507XXXXXXXX)")
|
||||
@Column(name = "telephone_wave", length = 20)
|
||||
private String telephoneWave;
|
||||
|
||||
@NotNull
|
||||
@@ -77,6 +77,11 @@ public class Membre extends BaseEntity {
|
||||
@Column(name = "statut_compte", nullable = false, length = 30)
|
||||
private String statutCompte = "EN_ATTENTE_VALIDATION";
|
||||
|
||||
/** Vrai si le membre n'a jamais changé son mot de passe généré par l'admin. */
|
||||
@Builder.Default
|
||||
@Column(name = "premiere_connexion", nullable = false)
|
||||
private Boolean premiereConnexion = true;
|
||||
|
||||
/**
|
||||
* Statut matrimonial (domaine
|
||||
* {@code STATUT_MATRIMONIAL} dans
|
||||
@@ -101,6 +106,10 @@ public class Membre extends BaseEntity {
|
||||
@Column(name = "numero_identite", length = 100)
|
||||
private String numeroIdentite;
|
||||
|
||||
/** Notes / biographie libre du membre. */
|
||||
@Column(name = "notes", length = 1000)
|
||||
private String notes;
|
||||
|
||||
/** Niveau de vigilance KYC LCB-FT (SIMPLIFIE, RENFORCE). */
|
||||
@Column(name = "niveau_vigilance_kyc", length = 20)
|
||||
private String niveauVigilanceKyc;
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package dev.lions.unionflow.server.entity;
|
||||
|
||||
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.TypePeriodeAbonnement;
|
||||
import dev.lions.unionflow.server.api.enums.abonnement.TypeOrganisationFacturation;
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
@@ -76,12 +81,57 @@ public class SouscriptionOrganisation extends BaseEntity {
|
||||
@Column(name = "wave_session_id", length = 255)
|
||||
private String waveSessionId;
|
||||
|
||||
@Column(name = "wave_checkout_url", length = 1024)
|
||||
private String waveCheckoutUrl;
|
||||
|
||||
@Column(name = "date_dernier_paiement")
|
||||
private LocalDate dateDernierPaiement;
|
||||
|
||||
@Column(name = "date_prochain_paiement")
|
||||
private LocalDate dateProchainePaiement;
|
||||
|
||||
// ── Champs workflow de validation (onboarding) ────────────────────────────
|
||||
|
||||
/** Plage de membres choisie lors de la souscription. */
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "plage", length = 20)
|
||||
private PlageMembres plage;
|
||||
|
||||
/** Type d'organisation déclaré, utilisé pour le coefficient tarifaire. */
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "type_organisation", length = 30)
|
||||
private TypeOrganisationFacturation typeOrganisationSouscription;
|
||||
|
||||
/** Coefficient multiplicateur effectivement appliqué (org × période). */
|
||||
@Column(name = "coefficient_applique", precision = 4, scale = 2)
|
||||
private BigDecimal coefficientApplique;
|
||||
|
||||
/** État du workflow de validation SuperAdmin. */
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Builder.Default
|
||||
@Column(name = "statut_validation", nullable = false, length = 40)
|
||||
private StatutValidationSouscription statutValidation = StatutValidationSouscription.EN_ATTENTE_PAIEMENT;
|
||||
|
||||
/** Montant total facturé pour la période choisie (en XOF). */
|
||||
@Column(name = "montant_total", precision = 12, scale = 2)
|
||||
private BigDecimal montantTotal;
|
||||
|
||||
/** Date à laquelle le SuperAdmin a approuvé ou rejeté la souscription. */
|
||||
@Column(name = "date_validation")
|
||||
private LocalDate dateValidation;
|
||||
|
||||
/** UUID du SuperAdmin ayant validé ou rejeté. */
|
||||
@Column(name = "validated_by_id")
|
||||
private UUID validatedById;
|
||||
|
||||
/** Motif de rejet renseigné par le SuperAdmin. */
|
||||
@Column(name = "commentaire_rejet", length = 500)
|
||||
private String commentaireRejet;
|
||||
|
||||
/** Mot de passe temporaire généré à l'activation du compte. */
|
||||
@Column(name = "mot_de_passe_temporaire", length = 100)
|
||||
private String motDePasseTemporaire;
|
||||
|
||||
// ── Méthodes métier ────────────────────────────────────────────────────────
|
||||
|
||||
public boolean isActive() {
|
||||
@@ -112,9 +162,10 @@ public class SouscriptionOrganisation extends BaseEntity {
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
super.onCreate();
|
||||
if (statut == null) statut = StatutSouscription.ACTIVE;
|
||||
if (typePeriode == null) typePeriode = TypePeriodeAbonnement.MENSUEL;
|
||||
if (quotaUtilise == null) quotaUtilise = 0;
|
||||
if (statut == null) statut = StatutSouscription.ACTIVE;
|
||||
if (typePeriode == null) typePeriode = TypePeriodeAbonnement.MENSUEL;
|
||||
if (quotaUtilise == null) quotaUtilise = 0;
|
||||
if (statutValidation == null) statutValidation = StatutValidationSouscription.EN_ATTENTE_PAIEMENT;
|
||||
if (formule != null && quotaMax == null) quotaMax = formule.getMaxMembres();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package dev.lions.unionflow.server.exception;
|
||||
import dev.lions.unionflow.server.service.SystemLoggingService;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.ForbiddenException;
|
||||
import jakarta.ws.rs.NotAllowedException;
|
||||
import jakarta.ws.rs.NotAuthorizedException;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
@@ -64,6 +65,8 @@ public class GlobalExceptionMapper implements ExceptionMapper<Throwable> {
|
||||
String stacktrace = getStackTrace(exception);
|
||||
|
||||
// Persister dans system_logs (ne pas laisser ça crasher le mapper)
|
||||
// Note: systemLoggingService est @Transactional → ne peut pas être appelé depuis
|
||||
// le thread IO de Vert.x (ex: exceptions levées avant le dispatch vers worker thread)
|
||||
try {
|
||||
systemLoggingService.logError(
|
||||
determineSource(exception),
|
||||
@@ -74,6 +77,10 @@ public class GlobalExceptionMapper implements ExceptionMapper<Throwable> {
|
||||
"/" + endpoint,
|
||||
statusCode
|
||||
);
|
||||
} catch (IllegalStateException e) {
|
||||
// BlockingOperationNotAllowedException est une IllegalStateException :
|
||||
// le mapper est appelé depuis le thread IO, on ne peut pas démarrer une transaction JTA.
|
||||
log.debug("Cannot persist error log from IO thread ({}): {}", e.getClass().getSimpleName(), message);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to log error to system_logs", e);
|
||||
}
|
||||
@@ -90,7 +97,8 @@ public class GlobalExceptionMapper implements ExceptionMapper<Throwable> {
|
||||
private boolean isExpectedClientError(Throwable exception) {
|
||||
return exception instanceof NotFoundException
|
||||
|| exception instanceof ForbiddenException
|
||||
|| exception instanceof NotAuthorizedException;
|
||||
|| exception instanceof NotAuthorizedException
|
||||
|| exception instanceof NotAllowedException;
|
||||
}
|
||||
|
||||
private int determineStatusCode(Throwable exception) {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package dev.lions.unionflow.server.repository;
|
||||
|
||||
import dev.lions.unionflow.server.entity.BackupConfig;
|
||||
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@ApplicationScoped
|
||||
public class BackupConfigRepository implements PanacheRepositoryBase<BackupConfig, UUID> {
|
||||
|
||||
/** Returns the single config row, or empty if not yet initialised. */
|
||||
public Optional<BackupConfig> getConfig() {
|
||||
return find("ORDER BY dateCreation ASC").firstResultOptional();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package dev.lions.unionflow.server.repository;
|
||||
|
||||
import dev.lions.unionflow.server.entity.BackupRecord;
|
||||
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
|
||||
import io.quarkus.panache.common.Sort;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@ApplicationScoped
|
||||
public class BackupRecordRepository implements PanacheRepositoryBase<BackupRecord, UUID> {
|
||||
|
||||
public List<BackupRecord> findAllOrderedByDate() {
|
||||
return findAll(Sort.by("dateCreation", Sort.Direction.Descending)).list();
|
||||
}
|
||||
|
||||
public void updateStatus(UUID id, String status, Long sizeBytes, LocalDateTime completedAt, String errorMessage) {
|
||||
update("status = ?1, sizeBytes = ?2, completedAt = ?3, errorMessage = ?4 WHERE id = ?5",
|
||||
status, sizeBytes, completedAt, errorMessage, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package dev.lions.unionflow.server.repository;
|
||||
|
||||
import dev.lions.unionflow.server.api.enums.abonnement.PlageMembres;
|
||||
import dev.lions.unionflow.server.api.enums.abonnement.TypeFormule;
|
||||
import dev.lions.unionflow.server.entity.FormuleAbonnement;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Repository pour le catalogue des formules d'abonnement UnionFlow.
|
||||
*
|
||||
* <p>La combinaison (code, plage) est unique : 12 formules au total
|
||||
* (3 niveaux × 4 tranches de taille).
|
||||
*
|
||||
* @author UnionFlow Team
|
||||
* @version 1.0
|
||||
* @since 2026-03-30
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class FormuleAbonnementRepository extends BaseRepository<FormuleAbonnement> {
|
||||
|
||||
public FormuleAbonnementRepository() {
|
||||
super(FormuleAbonnement.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve une formule par son code et sa plage de membres.
|
||||
*
|
||||
* @param code niveau de formule (BASIC, STANDARD, PREMIUM)
|
||||
* @param plage tranche de membres
|
||||
* @return la formule correspondante
|
||||
*/
|
||||
public Optional<FormuleAbonnement> findByCodeAndPlage(TypeFormule code, PlageMembres plage) {
|
||||
return find("code = ?1 and plage = ?2 and actif = true", code, plage)
|
||||
.firstResultOptional();
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste toutes les formules actives, triées par ordre d'affichage.
|
||||
*/
|
||||
public List<FormuleAbonnement> findAllActifOrderByOrdre() {
|
||||
return find("actif = true order by ordreAffichage asc").list();
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste toutes les formules d'une plage donnée.
|
||||
*/
|
||||
public List<FormuleAbonnement> findByPlage(PlageMembres plage) {
|
||||
return find("plage = ?1 and actif = true order by ordreAffichage asc", plage).list();
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste toutes les formules d'un niveau donné (BASIC, STANDARD ou PREMIUM).
|
||||
*/
|
||||
public List<FormuleAbonnement> findByCode(TypeFormule code) {
|
||||
return find("code = ?1 and actif = true order by ordreAffichage asc", code).list();
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,16 @@ public class InscriptionEvenementRepository
|
||||
> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compte le nombre d'inscriptions actives d'un membre
|
||||
*
|
||||
* @param membreId UUID du membre
|
||||
* @return Nombre d'inscriptions actives
|
||||
*/
|
||||
public long countByMembre(UUID membreId) {
|
||||
return count("membre.id = ?1 and actif = true", membreId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime logiquement une inscription
|
||||
*
|
||||
|
||||
@@ -2,6 +2,7 @@ package dev.lions.unionflow.server.repository;
|
||||
|
||||
import dev.lions.unionflow.server.entity.MembreOrganisation;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -21,4 +22,18 @@ public class MembreOrganisationRepository extends BaseRepository<MembreOrganisat
|
||||
public Optional<MembreOrganisation> findByMembreIdAndOrganisationId(UUID membreId, UUID organisationId) {
|
||||
return find("membre.id = ?1 and organisation.id = ?2", membreId, organisationId).firstResultOptional();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve la première organisation d'un membre (utile pour l'OrgAdmin).
|
||||
*/
|
||||
public Optional<MembreOrganisation> findFirstByMembreId(UUID membreId) {
|
||||
return find("membre.id = ?1", membreId).firstResultOptional();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve tous les liens actifs pour une organisation (pour l'activation du compte admin).
|
||||
*/
|
||||
public List<MembreOrganisation> findAllByOrganisationId(UUID organisationId) {
|
||||
return find("organisation.id = ?1 and membre.actif = true", organisationId).list();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,4 +25,17 @@ public class SouscriptionOrganisationRepository extends BaseRepository<Souscript
|
||||
return find("organisation.id = ?1 and statut = 'ACTIVE'", organisationId)
|
||||
.firstResultOptional();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve n'importe quelle souscription d'une organisation (y compris EN_ATTENTE),
|
||||
* triée par date de création décroissante.
|
||||
* Utilisé pour le workflow d'onboarding.
|
||||
*/
|
||||
public Optional<SouscriptionOrganisation> findLatestByOrganisationId(UUID organisationId) {
|
||||
if (organisationId == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return find("organisation.id = ?1 order by dateCreation desc", organisationId)
|
||||
.firstResultOptional();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Tag(name = "Adhésions", description = "Gestion des demandes d'adhésion des membres")
|
||||
@Slf4j
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE", "USER" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE", "USER" })
|
||||
public class AdhesionResource {
|
||||
|
||||
@Inject
|
||||
@@ -99,7 +99,7 @@ public class AdhesionResource {
|
||||
}
|
||||
|
||||
@POST
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
@Operation(summary = "Créer une nouvelle adhésion", description = "Crée une nouvelle demande d'adhésion pour un membre")
|
||||
@APIResponses({
|
||||
@APIResponse(responseCode = "201", description = "Adhésion créée avec succès", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = AdhesionResponse.class))),
|
||||
@@ -127,7 +127,7 @@ public class AdhesionResource {
|
||||
|
||||
/** Met à jour une adhésion existante */
|
||||
@PUT
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
@Path("/{id}")
|
||||
@Operation(summary = "Mettre à jour une adhésion", description = "Met à jour les données d'une adhésion existante")
|
||||
@APIResponses({
|
||||
@@ -168,7 +168,7 @@ public class AdhesionResource {
|
||||
|
||||
/** Approuve une adhésion */
|
||||
@POST
|
||||
@RolesAllowed({ "SUPER_ADMIN", "ADMIN" })
|
||||
@RolesAllowed({ "SUPER_ADMIN", "ADMIN", "ADMIN_ORGANISATION" })
|
||||
@Path("/{id}/approuver")
|
||||
@Operation(summary = "Approuver une adhésion", description = "Approuve une demande d'adhésion en attente")
|
||||
@APIResponses({
|
||||
@@ -189,7 +189,7 @@ public class AdhesionResource {
|
||||
|
||||
/** Rejette une adhésion */
|
||||
@POST
|
||||
@RolesAllowed({ "SUPER_ADMIN", "ADMIN" })
|
||||
@RolesAllowed({ "SUPER_ADMIN", "ADMIN", "ADMIN_ORGANISATION" })
|
||||
@Path("/{id}/rejeter")
|
||||
@Operation(summary = "Rejeter une adhésion", description = "Rejette une demande d'adhésion en attente")
|
||||
@APIResponses({
|
||||
@@ -210,7 +210,7 @@ public class AdhesionResource {
|
||||
|
||||
/** Enregistre un paiement pour une adhésion */
|
||||
@POST
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
@Path("/{id}/paiement")
|
||||
@Operation(summary = "Enregistrer un paiement", description = "Enregistre un paiement pour une adhésion approuvée")
|
||||
@APIResponses({
|
||||
|
||||
@@ -38,7 +38,7 @@ public class AlerteLcbFtResource {
|
||||
* Récupère les alertes LCB-FT avec filtres et pagination.
|
||||
*/
|
||||
@GET
|
||||
@RolesAllowed({"ORG_ADMIN", "SUPER_ADMIN"})
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "SUPER_ADMIN"})
|
||||
@Operation(summary = "Liste des alertes LCB-FT", description = "Récupère les alertes avec filtrage et pagination")
|
||||
public Response getAlertes(
|
||||
@QueryParam("organisationId") String organisationId,
|
||||
@@ -84,7 +84,7 @@ public class AlerteLcbFtResource {
|
||||
*/
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
@RolesAllowed({"ORG_ADMIN", "SUPER_ADMIN"})
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "SUPER_ADMIN"})
|
||||
@Operation(summary = "Détails d'une alerte", description = "Récupère une alerte par son ID")
|
||||
public Response getAlerteById(@PathParam("id") String id) {
|
||||
AlerteLcbFt alerte = alerteLcbFtRepository.findById(UUID.fromString(id));
|
||||
@@ -100,7 +100,7 @@ public class AlerteLcbFtResource {
|
||||
*/
|
||||
@POST
|
||||
@Path("/{id}/traiter")
|
||||
@RolesAllowed({"ORG_ADMIN", "SUPER_ADMIN"})
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "SUPER_ADMIN"})
|
||||
@Operation(summary = "Traiter une alerte", description = "Marque une alerte comme traitée avec un commentaire")
|
||||
public Response traiterAlerte(
|
||||
@PathParam("id") String id,
|
||||
@@ -133,7 +133,7 @@ public class AlerteLcbFtResource {
|
||||
*/
|
||||
@GET
|
||||
@Path("/stats/non-traitees")
|
||||
@RolesAllowed({"ORG_ADMIN", "SUPER_ADMIN"})
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "SUPER_ADMIN"})
|
||||
@Operation(summary = "Statistiques alertes", description = "Nombre d'alertes non traitées")
|
||||
public Response getStatsNonTraitees(@QueryParam("organisationId") String organisationId) {
|
||||
UUID orgId = organisationId != null && !organisationId.isBlank() ? UUID.fromString(organisationId) : null;
|
||||
|
||||
@@ -38,7 +38,7 @@ public class ApprovalResource {
|
||||
ApprovalService approvalService;
|
||||
|
||||
@POST
|
||||
@RolesAllowed({"ORG_ADMIN", "SUPER_ADMIN", "MEMBRE"})
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "SUPER_ADMIN", "MEMBRE"})
|
||||
@Operation(summary = "Demande une approbation de transaction",
|
||||
description = "Crée une demande d'approbation pour une transaction financière")
|
||||
public Response requestApproval(Map<String, Object> request) {
|
||||
@@ -76,7 +76,7 @@ public class ApprovalResource {
|
||||
|
||||
@GET
|
||||
@Path("/pending")
|
||||
@RolesAllowed({"ORG_ADMIN", "SUPER_ADMIN"})
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "SUPER_ADMIN"})
|
||||
@Operation(summary = "Récupère les approbations en attente",
|
||||
description = "Liste toutes les approbations de transactions en attente pour une organisation")
|
||||
public Response getPendingApprovals(@QueryParam("organizationId") UUID organizationId) {
|
||||
@@ -95,7 +95,7 @@ public class ApprovalResource {
|
||||
|
||||
@GET
|
||||
@Path("/{approvalId}")
|
||||
@RolesAllowed({"ORG_ADMIN", "SUPER_ADMIN"})
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "SUPER_ADMIN"})
|
||||
@Operation(summary = "Récupère une approbation par ID",
|
||||
description = "Retourne les détails d'une approbation spécifique")
|
||||
public Response getApprovalById(@PathParam("approvalId") UUID approvalId) {
|
||||
@@ -118,7 +118,7 @@ public class ApprovalResource {
|
||||
|
||||
@POST
|
||||
@Path("/{approvalId}/approve")
|
||||
@RolesAllowed({"ORG_ADMIN", "SUPER_ADMIN"})
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "SUPER_ADMIN"})
|
||||
@Operation(summary = "Approuve une transaction",
|
||||
description = "Approuve une demande de transaction avec un commentaire optionnel")
|
||||
public Response approveTransaction(
|
||||
@@ -147,7 +147,7 @@ public class ApprovalResource {
|
||||
|
||||
@POST
|
||||
@Path("/{approvalId}/reject")
|
||||
@RolesAllowed({"ORG_ADMIN", "SUPER_ADMIN"})
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "SUPER_ADMIN"})
|
||||
@Operation(summary = "Rejette une transaction",
|
||||
description = "Rejette une demande de transaction avec une raison obligatoire")
|
||||
public Response rejectTransaction(
|
||||
@@ -176,7 +176,7 @@ public class ApprovalResource {
|
||||
|
||||
@GET
|
||||
@Path("/history")
|
||||
@RolesAllowed({"ORG_ADMIN", "SUPER_ADMIN"})
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "SUPER_ADMIN"})
|
||||
@Operation(summary = "Récupère l'historique des approbations",
|
||||
description = "Liste l'historique des approbations avec filtres optionnels")
|
||||
public Response getApprovalsHistory(
|
||||
@@ -209,7 +209,7 @@ public class ApprovalResource {
|
||||
|
||||
@GET
|
||||
@Path("/count/pending")
|
||||
@RolesAllowed({"ORG_ADMIN", "SUPER_ADMIN"})
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "SUPER_ADMIN"})
|
||||
@Operation(summary = "Compte les approbations en attente",
|
||||
description = "Retourne le nombre d'approbations en attente pour une organisation")
|
||||
public Response countPendingApprovals(@QueryParam("organizationId") UUID organizationId) {
|
||||
|
||||
@@ -27,7 +27,7 @@ import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Tag(name = "Audit", description = "Gestion des logs d'audit")
|
||||
@Slf4j
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE", "USER" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE", "USER" })
|
||||
public class AuditResource {
|
||||
|
||||
@Inject
|
||||
|
||||
@@ -36,7 +36,7 @@ public class BudgetResource {
|
||||
BudgetService budgetService;
|
||||
|
||||
@GET
|
||||
@RolesAllowed({"ORG_ADMIN", "SUPER_ADMIN"})
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "SUPER_ADMIN"})
|
||||
@Operation(summary = "Récupère les budgets",
|
||||
description = "Liste tous les budgets d'une organisation avec filtres optionnels")
|
||||
public Response getBudgets(
|
||||
@@ -63,7 +63,7 @@ public class BudgetResource {
|
||||
|
||||
@GET
|
||||
@Path("/{budgetId}")
|
||||
@RolesAllowed({"ORG_ADMIN", "SUPER_ADMIN"})
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "SUPER_ADMIN"})
|
||||
@Operation(summary = "Récupère un budget par ID",
|
||||
description = "Retourne les détails complets d'un budget")
|
||||
public Response getBudgetById(@PathParam("budgetId") UUID budgetId) {
|
||||
@@ -85,7 +85,7 @@ public class BudgetResource {
|
||||
}
|
||||
|
||||
@POST
|
||||
@RolesAllowed({"ORG_ADMIN", "SUPER_ADMIN"})
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "SUPER_ADMIN"})
|
||||
@Operation(summary = "Crée un nouveau budget",
|
||||
description = "Crée un budget avec ses lignes budgétaires")
|
||||
public Response createBudget(@Valid CreateBudgetRequest request) {
|
||||
@@ -114,7 +114,7 @@ public class BudgetResource {
|
||||
|
||||
@GET
|
||||
@Path("/{budgetId}/tracking")
|
||||
@RolesAllowed({"ORG_ADMIN", "SUPER_ADMIN"})
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "SUPER_ADMIN"})
|
||||
@Operation(summary = "Récupère le suivi budgétaire",
|
||||
description = "Retourne les statistiques de suivi et réalisation du budget")
|
||||
public Response getBudgetTracking(@PathParam("budgetId") UUID budgetId) {
|
||||
@@ -137,7 +137,7 @@ public class BudgetResource {
|
||||
|
||||
@PUT
|
||||
@Path("/{budgetId}")
|
||||
@RolesAllowed({"ORG_ADMIN", "SUPER_ADMIN"})
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "SUPER_ADMIN"})
|
||||
@Operation(summary = "Met à jour un budget",
|
||||
description = "Modifie un budget existant (nom, description, lignes, statut)")
|
||||
public Response updateBudget(
|
||||
@@ -166,7 +166,7 @@ public class BudgetResource {
|
||||
|
||||
@DELETE
|
||||
@Path("/{budgetId}")
|
||||
@RolesAllowed({"ORG_ADMIN", "SUPER_ADMIN"})
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "SUPER_ADMIN"})
|
||||
@Operation(summary = "Supprime un budget",
|
||||
description = "Supprime logiquement un budget (soft delete)")
|
||||
public Response deleteBudget(@PathParam("budgetId") UUID budgetId) {
|
||||
|
||||
@@ -24,7 +24,7 @@ import org.jboss.logging.Logger;
|
||||
@Path("/api/comptabilite")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE", "USER" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE", "USER" })
|
||||
@Tag(name = "Comptabilité", description = "Gestion comptable : comptes, journaux et écritures comptables")
|
||||
public class ComptabiliteResource {
|
||||
|
||||
@@ -44,7 +44,7 @@ public class ComptabiliteResource {
|
||||
* @return Compte créé
|
||||
*/
|
||||
@POST
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
@Path("/comptes")
|
||||
public Response creerCompteComptable(@Valid CreateCompteComptableRequest request) {
|
||||
try {
|
||||
@@ -116,7 +116,7 @@ public class ComptabiliteResource {
|
||||
* @return Journal créé
|
||||
*/
|
||||
@POST
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
@Path("/journaux")
|
||||
public Response creerJournalComptable(@Valid CreateJournalComptableRequest request) {
|
||||
try {
|
||||
@@ -188,7 +188,7 @@ public class ComptabiliteResource {
|
||||
* @return Écriture créée
|
||||
*/
|
||||
@POST
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
@Path("/ecritures")
|
||||
public Response creerEcritureComptable(@Valid CreateEcritureComptableRequest request) {
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
package dev.lions.unionflow.server.resource;
|
||||
|
||||
import dev.lions.unionflow.server.api.dto.membre.CompteAdherentResponse;
|
||||
import dev.lions.unionflow.server.entity.Membre;
|
||||
import dev.lions.unionflow.server.entity.MembreOrganisation;
|
||||
import dev.lions.unionflow.server.entity.SouscriptionOrganisation;
|
||||
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
|
||||
import dev.lions.unionflow.server.repository.MembreRepository;
|
||||
import dev.lions.unionflow.server.repository.SouscriptionOrganisationRepository;
|
||||
import dev.lions.unionflow.server.service.CompteAdherentService;
|
||||
import dev.lions.unionflow.server.service.MembreKeycloakSyncService;
|
||||
import dev.lions.unionflow.server.service.MembreService;
|
||||
import dev.lions.unionflow.server.service.support.SecuriteHelper;
|
||||
import io.quarkus.security.Authenticated;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.*;
|
||||
@@ -9,6 +19,12 @@ import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Endpoint REST pour le compte adhérent du membre connecté.
|
||||
@@ -28,16 +44,36 @@ import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
||||
@Tag(name = "Compte Adhérent", description = "Vue financière unifiée du membre connecté")
|
||||
public class CompteAdherentResource {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(CompteAdherentResource.class);
|
||||
|
||||
@Inject
|
||||
CompteAdherentService compteAdherentService;
|
||||
|
||||
@Inject
|
||||
SecuriteHelper securiteHelper;
|
||||
|
||||
@Inject
|
||||
MembreRepository membreRepository;
|
||||
|
||||
@Inject
|
||||
MembreOrganisationRepository membreOrganisationRepo;
|
||||
|
||||
@Inject
|
||||
SouscriptionOrganisationRepository souscriptionRepo;
|
||||
|
||||
@Inject
|
||||
MembreKeycloakSyncService membreKeycloakSyncService;
|
||||
|
||||
@Inject
|
||||
MembreService membreService;
|
||||
|
||||
/**
|
||||
* Retourne le compte adhérent complet du membre connecté :
|
||||
* numéro de membre, soldes (cotisations + épargne), capacité d'emprunt, taux d'engagement.
|
||||
*/
|
||||
@GET
|
||||
@Path("/mon-compte")
|
||||
@RolesAllowed({ "USER", "MEMBRE", "ADMIN", "SUPER_ADMIN" })
|
||||
@RolesAllowed({ "USER", "MEMBRE", "ADMIN", "ADMIN_ORGANISATION", "SUPER_ADMIN" })
|
||||
@Operation(
|
||||
summary = "Compte adhérent du membre connecté",
|
||||
description = "Agrège cotisations, épargne et crédit en une vue financière unifiée."
|
||||
@@ -46,4 +82,144 @@ public class CompteAdherentResource {
|
||||
CompteAdherentResponse compte = compteAdherentService.getMonCompte();
|
||||
return Response.ok(compte).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le statut du compte du membre connecté.
|
||||
*
|
||||
* <p>Endpoint léger appelé par le mobile juste après le login Keycloak
|
||||
* pour détecter les comptes en attente/suspendus/désactivés avant d'accorder l'accès.
|
||||
*
|
||||
* <p>Si aucun enregistrement membre n'existe (ex. : administrateur créé directement
|
||||
* dans Keycloak sans fiche membre), retourne {@code ACTIF} pour ne pas bloquer les admins.
|
||||
*/
|
||||
@GET
|
||||
@Path("/mon-statut")
|
||||
@Authenticated
|
||||
@Operation(
|
||||
summary = "Statut du compte du membre connecté",
|
||||
description = "Retourne statutCompte : ACTIF, EN_ATTENTE_VALIDATION, SUSPENDU ou DESACTIVE."
|
||||
)
|
||||
public Response getMonStatut() {
|
||||
String email = securiteHelper.resolveEmail();
|
||||
if (email == null || email.isBlank()) {
|
||||
return Response.status(Response.Status.UNAUTHORIZED).build();
|
||||
}
|
||||
|
||||
Optional<Membre> membreOpt = membreRepository.findByEmail(email.trim())
|
||||
.or(() -> membreRepository.findByEmail(email.trim().toLowerCase()));
|
||||
|
||||
// Pas de fiche membre → administrateur pur → accès autorisé
|
||||
String statutCompte = membreOpt
|
||||
.map(Membre::getStatutCompte)
|
||||
.filter(s -> s != null && !s.isBlank())
|
||||
.orElse("ACTIF");
|
||||
|
||||
// Auto-activer si le membre a été créé par un admin dont l'org a une souscription active.
|
||||
// Couvre les membres créés avant l'auto-activation à la création ET les cas limites futurs.
|
||||
if ("EN_ATTENTE_VALIDATION".equals(statutCompte) && membreOpt.isPresent()) {
|
||||
Membre m = membreOpt.get();
|
||||
UUID orgId = membreOrganisationRepo.findFirstByMembreId(m.getId())
|
||||
.map(mo -> mo.getOrganisation().getId())
|
||||
.orElse(null);
|
||||
if (membreService.orgHasActiveSubscription(orgId)) {
|
||||
LOG.infof("Auto-activation au login de %s (org %s a souscription active)", m.getEmail(), orgId);
|
||||
membreService.activerMembre(m.getId());
|
||||
try {
|
||||
membreKeycloakSyncService.activerMembreDansKeycloak(m.getId());
|
||||
} catch (Exception e) {
|
||||
LOG.warnf("Activation Keycloak au login échouée pour %s (non bloquant): %s",
|
||||
m.getEmail(), e.getMessage());
|
||||
}
|
||||
statutCompte = "ACTIF";
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("statutCompte", statutCompte);
|
||||
|
||||
// Signaler si le membre doit changer son mot de passe (premier login)
|
||||
boolean changerMotDePasseRequis = membreOpt
|
||||
.map(m -> Boolean.TRUE.equals(m.getPremiereConnexion()))
|
||||
.orElse(false);
|
||||
response.put("changerMotDePasseRequis", changerMotDePasseRequis);
|
||||
|
||||
// Enrichir avec l'état d'onboarding pour les comptes en attente
|
||||
if ("EN_ATTENTE_VALIDATION".equals(statutCompte)) {
|
||||
membreOpt.flatMap(m -> membreOrganisationRepo.findFirstByMembreId(m.getId())
|
||||
.map(MembreOrganisation::getOrganisation))
|
||||
.ifPresent(org -> {
|
||||
response.put("organisationId", org.getId().toString());
|
||||
Optional<SouscriptionOrganisation> souscOpt =
|
||||
souscriptionRepo.findLatestByOrganisationId(org.getId());
|
||||
|
||||
if (souscOpt.isEmpty()) {
|
||||
response.put("onboardingState", "NO_SUBSCRIPTION");
|
||||
} else {
|
||||
SouscriptionOrganisation sosc = souscOpt.get();
|
||||
String valState = sosc.getStatutValidation() != null
|
||||
? sosc.getStatutValidation().name()
|
||||
: "EN_ATTENTE_PAIEMENT";
|
||||
String onboardingState = switch (valState) {
|
||||
case "EN_ATTENTE_PAIEMENT" -> "AWAITING_PAYMENT";
|
||||
case "PAIEMENT_INITIE" -> "PAYMENT_INITIATED";
|
||||
case "PAIEMENT_CONFIRME" -> "AWAITING_VALIDATION";
|
||||
case "VALIDEE" -> "VALIDATED";
|
||||
case "REJETEE" -> "REJECTED";
|
||||
default -> "AWAITING_PAYMENT";
|
||||
};
|
||||
response.put("onboardingState", onboardingState);
|
||||
response.put("souscriptionId", sosc.getId().toString());
|
||||
if (sosc.getWaveSessionId() != null) {
|
||||
response.put("waveSessionId", sosc.getWaveSessionId());
|
||||
}
|
||||
}
|
||||
response.put("typeOrganisation", org.getTypeOrganisation() != null
|
||||
? org.getTypeOrganisation() : "ASSOCIATION");
|
||||
});
|
||||
if (!response.containsKey("onboardingState")) {
|
||||
response.put("onboardingState", "NO_SUBSCRIPTION");
|
||||
}
|
||||
}
|
||||
|
||||
return Response.ok(response).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Permet au membre connecté de changer son mot de passe lors du premier login.
|
||||
* Appelle Keycloak via lions-user-manager et marque {@code premiereConnexion = false}.
|
||||
*
|
||||
* <p>Body attendu : {@code { "nouveauMotDePasse": "..." }}
|
||||
*/
|
||||
@PUT
|
||||
@Path("/mon-compte/mot-de-passe")
|
||||
@Authenticated
|
||||
@Operation(
|
||||
summary = "Changer le mot de passe au premier login",
|
||||
description = "Met à jour le mot de passe Keycloak et lève le flag premiereConnexion."
|
||||
)
|
||||
public Response changerMotDePasse(Map<String, String> body) {
|
||||
String email = securiteHelper.resolveEmail();
|
||||
if (email == null || email.isBlank()) {
|
||||
return Response.status(Response.Status.UNAUTHORIZED).build();
|
||||
}
|
||||
|
||||
String nouveauMotDePasse = body == null ? null : body.get("nouveauMotDePasse");
|
||||
if (nouveauMotDePasse == null || nouveauMotDePasse.isBlank()) {
|
||||
return Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity(Map.of("message", "Le champ 'nouveauMotDePasse' est requis."))
|
||||
.build();
|
||||
}
|
||||
|
||||
Optional<Membre> membreOpt = membreRepository.findByEmail(email.trim())
|
||||
.or(() -> membreRepository.findByEmail(email.trim().toLowerCase()));
|
||||
|
||||
if (membreOpt.isEmpty()) {
|
||||
return Response.status(Response.Status.NOT_FOUND)
|
||||
.entity(Map.of("message", "Aucun membre trouvé pour ce compte."))
|
||||
.build();
|
||||
}
|
||||
|
||||
membreKeycloakSyncService.changerMotDePassePremierLogin(membreOpt.get().getId(), nouveauMotDePasse);
|
||||
return Response.ok(Map.of("message", "Mot de passe mis à jour avec succès.")).build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Tag(name = "Cotisations", description = "Gestion des cotisations des membres")
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE", "USER" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE", "USER" })
|
||||
@Slf4j
|
||||
public class CotisationResource {
|
||||
|
||||
@@ -165,7 +165,7 @@ public class CotisationResource {
|
||||
* Crée une nouvelle cotisation.
|
||||
*/
|
||||
@POST
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
@Operation(summary = "Créer une cotisation")
|
||||
@APIResponses({
|
||||
@APIResponse(responseCode = "201", description = "Créée", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = CotisationResponse.class))),
|
||||
@@ -189,7 +189,7 @@ public class CotisationResource {
|
||||
*/
|
||||
@PUT
|
||||
@Path("/{id}")
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
@Operation(summary = "Mettre à jour une cotisation")
|
||||
public Response updateCotisation(@PathParam("id") @NotNull UUID id, @Valid UpdateCotisationRequest request) {
|
||||
try {
|
||||
@@ -344,7 +344,7 @@ public class CotisationResource {
|
||||
* Enregistrer le paiement.
|
||||
*/
|
||||
@PUT
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE", "TRESORIER" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE", "TRESORIER" })
|
||||
@Path("/{id}/payer")
|
||||
@Operation(summary = "Payer une cotisation")
|
||||
public Response enregistrerPaiement(@PathParam("id") UUID id, Map<String, Object> paiementData) {
|
||||
@@ -373,7 +373,7 @@ public class CotisationResource {
|
||||
* Envoyer rappels groupés.
|
||||
*/
|
||||
@POST
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
@Path("/rappels/groupes")
|
||||
@Operation(summary = "Rappels groupés")
|
||||
public Response envoyerRappelsGroupes(List<UUID> membreIds) {
|
||||
|
||||
@@ -36,7 +36,7 @@ import java.util.Map;
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Tag(name = "Dashboard", description = "APIs pour la gestion du dashboard")
|
||||
@RolesAllowed({"ADMIN", "MEMBRE", "USER"})
|
||||
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE", "USER"})
|
||||
public class DashboardResource {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(DashboardResource.class);
|
||||
|
||||
@@ -32,7 +32,7 @@ import org.jboss.logging.Logger;
|
||||
@Path("/api/documents")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE", "USER" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE", "USER" })
|
||||
@Tag(name = "Documents", description = "Gestion documentaire : documents et pièces jointes")
|
||||
public class DocumentResource {
|
||||
|
||||
@@ -54,7 +54,7 @@ public class DocumentResource {
|
||||
* @return Document créé
|
||||
*/
|
||||
@POST
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
public Response creerDocument(@Valid CreateDocumentRequest request) {
|
||||
try {
|
||||
DocumentResponse result = documentService.creerDocument(request);
|
||||
@@ -77,7 +77,7 @@ public class DocumentResource {
|
||||
@POST
|
||||
@Path("/upload")
|
||||
@Consumes(MediaType.MULTIPART_FORM_DATA)
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
@jakarta.transaction.Transactional
|
||||
public Response uploadFile(
|
||||
@org.jboss.resteasy.reactive.RestForm("file") FileUpload file,
|
||||
@@ -176,7 +176,7 @@ public class DocumentResource {
|
||||
* @return Succès
|
||||
*/
|
||||
@POST
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
@Path("/{id}/telechargement")
|
||||
public Response enregistrerTelechargement(@PathParam("id") UUID id) {
|
||||
try {
|
||||
@@ -203,7 +203,7 @@ public class DocumentResource {
|
||||
* @return Pièce jointe créée
|
||||
*/
|
||||
@POST
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
@Path("/pieces-jointes")
|
||||
public Response creerPieceJointe(@Valid CreatePieceJointeRequest request) {
|
||||
try {
|
||||
|
||||
@@ -81,7 +81,7 @@ public class EvenementResource {
|
||||
@Operation(summary = "Lister tous les événements actifs", description = "Récupère la liste paginée des événements actifs")
|
||||
@APIResponse(responseCode = "200", description = "Liste des événements actifs")
|
||||
@APIResponse(responseCode = "401", description = "Non authentifié")
|
||||
@RolesAllowed({ "ADMIN", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT", "MEMBRE", "USER" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT", "MEMBRE", "USER" })
|
||||
public PagedResponse<EvenementMobileDTO> listerEvenements(
|
||||
@Parameter(description = "Numéro de page (0-based)", example = "0") @QueryParam("page") @DefaultValue("0") @Min(0) int page,
|
||||
@Parameter(description = "Taille de la page", example = "20") @QueryParam("size") @DefaultValue("20") @Min(1) int size,
|
||||
@@ -121,7 +121,7 @@ public class EvenementResource {
|
||||
@Operation(summary = "Récupérer un événement par ID")
|
||||
@APIResponse(responseCode = "200", description = "Événement trouvé")
|
||||
@APIResponse(responseCode = "404", description = "Événement non trouvé")
|
||||
@RolesAllowed({ "ADMIN", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT", "MEMBRE" })
|
||||
public Response obtenirEvenement(
|
||||
@Parameter(description = "UUID de l'événement", required = true) @PathParam("id") UUID id) {
|
||||
|
||||
@@ -138,7 +138,7 @@ public class EvenementResource {
|
||||
@Operation(summary = "Créer un nouvel événement")
|
||||
@APIResponse(responseCode = "201", description = "Événement créé avec succès")
|
||||
@APIResponse(responseCode = "400", description = "Données invalides")
|
||||
@RolesAllowed({ "ADMIN", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT" })
|
||||
public Response creerEvenement(
|
||||
@Parameter(description = "Données de l'événement à créer", required = true) @Valid Evenement evenement) {
|
||||
|
||||
@@ -153,7 +153,7 @@ public class EvenementResource {
|
||||
@Operation(summary = "Mettre à jour un événement")
|
||||
@APIResponse(responseCode = "200", description = "Événement mis à jour avec succès")
|
||||
@APIResponse(responseCode = "404", description = "Événement non trouvé")
|
||||
@RolesAllowed({ "ADMIN", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT" })
|
||||
public Response mettreAJourEvenement(@PathParam("id") UUID id, @Valid Evenement evenement) {
|
||||
|
||||
LOG.infof("PUT /api/evenements/%s", id);
|
||||
@@ -166,7 +166,7 @@ public class EvenementResource {
|
||||
@Path("/{id}")
|
||||
@Operation(summary = "Supprimer un événement")
|
||||
@APIResponse(responseCode = "204", description = "Événement supprimé avec succès")
|
||||
@RolesAllowed({ "ADMIN", "PRESIDENT", "ORGANISATEUR_EVENEMENT" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "PRESIDENT", "ORGANISATEUR_EVENEMENT" })
|
||||
public Response supprimerEvenement(@PathParam("id") UUID id) {
|
||||
|
||||
LOG.infof("DELETE /api/evenements/%s", id);
|
||||
@@ -180,7 +180,7 @@ public class EvenementResource {
|
||||
@GET
|
||||
@Path("/a-venir")
|
||||
@Operation(summary = "Événements à venir")
|
||||
@RolesAllowed({ "ADMIN", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT", "MEMBRE" })
|
||||
public Response evenementsAVenir(
|
||||
@QueryParam("page") @DefaultValue("0") int page,
|
||||
@QueryParam("size") @DefaultValue("10") int size) {
|
||||
@@ -209,7 +209,7 @@ public class EvenementResource {
|
||||
@GET
|
||||
@Path("/recherche")
|
||||
@Operation(summary = "Rechercher des événements")
|
||||
@RolesAllowed({ "ADMIN", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT", "MEMBRE" })
|
||||
public Response rechercherEvenements(
|
||||
@QueryParam("q") String recherche,
|
||||
@QueryParam("page") @DefaultValue("0") int page,
|
||||
@@ -231,7 +231,7 @@ public class EvenementResource {
|
||||
@GET
|
||||
@Path("/type/{type}")
|
||||
@Operation(summary = "Événements par type")
|
||||
@RolesAllowed({ "ADMIN", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT", "MEMBRE" })
|
||||
public Response evenementsParType(
|
||||
@PathParam("type") String type,
|
||||
@QueryParam("page") @DefaultValue("0") int page,
|
||||
@@ -247,7 +247,7 @@ public class EvenementResource {
|
||||
@PATCH
|
||||
@Path("/{id}/statut")
|
||||
@Operation(summary = "Changer le statut d'un événement")
|
||||
@RolesAllowed({ "ADMIN", "PRESIDENT", "ORGANISATEUR_EVENEMENT" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "PRESIDENT", "ORGANISATEUR_EVENEMENT" })
|
||||
public Response changerStatut(
|
||||
@PathParam("id") UUID id, @QueryParam("statut") String nouveauStatut) {
|
||||
|
||||
@@ -266,7 +266,7 @@ public class EvenementResource {
|
||||
@GET
|
||||
@Path("/statistiques")
|
||||
@Operation(summary = "Statistiques des événements")
|
||||
@RolesAllowed({ "ADMIN", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT" })
|
||||
public Response obtenirStatistiques() {
|
||||
|
||||
Map<String, Object> statistiques = evenementService.obtenirStatistiques();
|
||||
@@ -278,7 +278,7 @@ public class EvenementResource {
|
||||
@Path("/{id}/me/inscrit")
|
||||
@Operation(summary = "Statut d'inscription de l'utilisateur connecté")
|
||||
@APIResponse(responseCode = "200", description = "Statut d'inscription")
|
||||
@RolesAllowed({ "ADMIN", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT", "MEMBRE", "USER" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT", "MEMBRE", "USER" })
|
||||
public Response meInscrit(
|
||||
@Parameter(description = "UUID de l'événement", required = true) @PathParam("id") UUID id) {
|
||||
boolean inscrit = evenementService.isUserInscrit(id);
|
||||
@@ -294,7 +294,7 @@ public class EvenementResource {
|
||||
@APIResponse(responseCode = "201", description = "Inscription créée")
|
||||
@APIResponse(responseCode = "400", description = "Déjà inscrit ou événement complet")
|
||||
@APIResponse(responseCode = "404", description = "Événement non trouvé")
|
||||
@RolesAllowed({ "ADMIN", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT", "MEMBRE", "USER" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT", "MEMBRE", "USER" })
|
||||
@Transactional
|
||||
public Response inscrireEvenement(@PathParam("id") UUID evenementId) {
|
||||
try {
|
||||
@@ -313,7 +313,7 @@ public class EvenementResource {
|
||||
@Operation(summary = "Se désinscrire d'un événement")
|
||||
@APIResponse(responseCode = "204", description = "Désinscription effectuée")
|
||||
@APIResponse(responseCode = "404", description = "Inscription non trouvée")
|
||||
@RolesAllowed({ "ADMIN", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT", "MEMBRE", "USER" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT", "MEMBRE", "USER" })
|
||||
@Transactional
|
||||
public Response desinscrireEvenement(@PathParam("id") UUID evenementId) {
|
||||
evenementService.desinscrireEvenement(evenementId);
|
||||
@@ -325,7 +325,7 @@ public class EvenementResource {
|
||||
@Path("/{id}/participants")
|
||||
@Operation(summary = "Liste des participants")
|
||||
@APIResponse(responseCode = "200", description = "Liste des participants")
|
||||
@RolesAllowed({ "ADMIN", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT", "MEMBRE" })
|
||||
public Response getParticipants(@PathParam("id") UUID evenementId) {
|
||||
List<InscriptionEvenement> participants = evenementService.getParticipants(evenementId);
|
||||
return Response.ok(participants).build();
|
||||
@@ -336,7 +336,7 @@ public class EvenementResource {
|
||||
@Path("/mes-inscriptions")
|
||||
@Operation(summary = "Mes inscriptions aux événements")
|
||||
@APIResponse(responseCode = "200", description = "Liste de mes inscriptions")
|
||||
@RolesAllowed({ "ADMIN", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT", "MEMBRE", "USER" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT", "MEMBRE", "USER" })
|
||||
public Response getMesInscriptions() {
|
||||
List<InscriptionEvenement> inscriptions = evenementService.getMesInscriptions();
|
||||
return Response.ok(inscriptions).build();
|
||||
@@ -351,7 +351,7 @@ public class EvenementResource {
|
||||
@APIResponse(responseCode = "201", description = "Feedback créé")
|
||||
@APIResponse(responseCode = "400", description = "Données invalides ou feedback déjà soumis")
|
||||
@APIResponse(responseCode = "404", description = "Événement non trouvé")
|
||||
@RolesAllowed({ "ADMIN", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT", "MEMBRE", "USER" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT", "MEMBRE", "USER" })
|
||||
@Transactional
|
||||
public Response soumetteFeedback(
|
||||
@PathParam("id") UUID evenementId, Map<String, Object> requestBody) {
|
||||
|
||||
@@ -18,7 +18,7 @@ import org.jboss.logging.Logger;
|
||||
@Path("/api/export")
|
||||
@ApplicationScoped
|
||||
@Tag(name = "Export", description = "API d'export des données")
|
||||
@RolesAllowed({"ADMIN", "MEMBRE", "USER"})
|
||||
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE", "USER"})
|
||||
public class ExportResource {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(ExportResource.class);
|
||||
@@ -45,7 +45,7 @@ public class ExportResource {
|
||||
}
|
||||
|
||||
@POST
|
||||
@RolesAllowed({"ADMIN", "MEMBRE"})
|
||||
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE"})
|
||||
@Path("/cotisations/csv")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces("text/csv")
|
||||
@@ -79,7 +79,7 @@ public class ExportResource {
|
||||
}
|
||||
|
||||
@POST
|
||||
@RolesAllowed({"ADMIN", "MEMBRE"})
|
||||
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE"})
|
||||
@Path("/cotisations/recus")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces("text/plain")
|
||||
|
||||
@@ -29,7 +29,7 @@ import java.util.UUID;
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Tag(name = "Favoris", description = "Gestion des favoris utilisateur")
|
||||
@Slf4j
|
||||
@RolesAllowed({ "USER", "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "USER", "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
public class FavorisResource {
|
||||
|
||||
@Inject
|
||||
|
||||
@@ -24,7 +24,7 @@ import java.util.UUID;
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Tag(name = "Feedback", description = "Commentaires et suggestions utilisateur")
|
||||
@RolesAllowed({ "USER", "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "USER", "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
public class FeedbackResource {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(FeedbackResource.class);
|
||||
|
||||
@@ -31,7 +31,7 @@ public class FinanceWorkflowResource {
|
||||
|
||||
@GET
|
||||
@Path("/stats")
|
||||
@RolesAllowed({"ORG_ADMIN", "SUPER_ADMIN"})
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "SUPER_ADMIN"})
|
||||
@Operation(summary = "Statistiques du workflow financier",
|
||||
description = "Retourne les statistiques globales du workflow financier")
|
||||
public Response getWorkflowStats(
|
||||
@@ -57,7 +57,7 @@ public class FinanceWorkflowResource {
|
||||
|
||||
@GET
|
||||
@Path("/audit-logs")
|
||||
@RolesAllowed({"ORG_ADMIN", "SUPER_ADMIN"})
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "SUPER_ADMIN"})
|
||||
@Operation(summary = "Récupère les logs d'audit financier",
|
||||
description = "Liste les logs d'audit avec filtres optionnels")
|
||||
public Response getAuditLogs(
|
||||
@@ -76,7 +76,7 @@ public class FinanceWorkflowResource {
|
||||
|
||||
@GET
|
||||
@Path("/audit-logs/anomalies")
|
||||
@RolesAllowed({"ORG_ADMIN", "SUPER_ADMIN"})
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "SUPER_ADMIN"})
|
||||
@Operation(summary = "Récupère les anomalies financières détectées",
|
||||
description = "Liste les anomalies et transactions suspectes")
|
||||
public Response getAnomalies(
|
||||
@@ -91,7 +91,7 @@ public class FinanceWorkflowResource {
|
||||
|
||||
@POST
|
||||
@Path("/audit-logs/export")
|
||||
@RolesAllowed({"ORG_ADMIN", "SUPER_ADMIN"})
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "SUPER_ADMIN"})
|
||||
@Operation(summary = "Exporte les logs d'audit",
|
||||
description = "Génère un export des logs d'audit au format spécifié (CSV/PDF)")
|
||||
public Response exportAuditLogs(Map<String, Object> request) {
|
||||
|
||||
@@ -24,7 +24,7 @@ public class MembreDashboardResource {
|
||||
|
||||
@GET
|
||||
@Path("/me")
|
||||
@RolesAllowed({ "USER", "MEMBRE", "ADMIN", "SUPER_ADMIN" })
|
||||
@RolesAllowed({ "USER", "MEMBRE", "ADMIN", "ADMIN_ORGANISATION", "SUPER_ADMIN" })
|
||||
@Operation(summary = "Récupérer la synthèse du dashboard pour le membre connecté")
|
||||
public Response getMonDashboard() {
|
||||
MembreDashboardSyntheseResponse data = dashboardService.getDashboardData();
|
||||
|
||||
@@ -25,11 +25,13 @@ import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.io.InputStream;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.eclipse.microprofile.jwt.JsonWebToken;
|
||||
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||
import org.eclipse.microprofile.openapi.annotations.media.Content;
|
||||
import org.eclipse.microprofile.openapi.annotations.media.ExampleObject;
|
||||
@@ -69,6 +71,9 @@ public class MembreResource {
|
||||
@Inject
|
||||
io.quarkus.security.identity.SecurityIdentity securityIdentity;
|
||||
|
||||
@Inject
|
||||
JsonWebToken jwt;
|
||||
|
||||
@GET
|
||||
@Operation(summary = "Lister les membres")
|
||||
@APIResponse(responseCode = "200", description = "Liste des membres avec pagination")
|
||||
@@ -110,8 +115,7 @@ public class MembreResource {
|
||||
LOG.infof("ADMIN_ORGANISATION %s : accès à %d organisations", email, orgIds.size());
|
||||
|
||||
membres = membreService.listerMembresParOrganisations(orgIds, Page.of(page, size), sort);
|
||||
// TODO: compter total membres pour ces organisations (approximation pour l'instant)
|
||||
totalElements = membres.size();
|
||||
totalElements = membreService.compterMembresParOrganisations(orgIds);
|
||||
}
|
||||
} else {
|
||||
// ADMIN / SUPER_ADMIN : accès à tous les membres
|
||||
@@ -151,19 +155,80 @@ public class MembreResource {
|
||||
|
||||
@GET
|
||||
@Path("/me")
|
||||
@RolesAllowed({ "USER", "MEMBRE", "ADMIN", "SUPER_ADMIN" })
|
||||
@RolesAllowed({ "USER", "MEMBRE", "ADMIN", "ADMIN_ORGANISATION", "SUPER_ADMIN" })
|
||||
@Operation(summary = "Récupérer le membre connecté")
|
||||
@APIResponse(responseCode = "200", description = "Membre connecté trouvé")
|
||||
@APIResponse(responseCode = "404", description = "Membre non trouvé")
|
||||
@APIResponse(responseCode = "200", description = "Membre connecté trouvé ou auto-provisionné")
|
||||
public Response obtenirMembreConnecte() {
|
||||
String email = securityIdentity.getPrincipal().getName();
|
||||
LOG.infof("Récupération du membre connecté: %s", email);
|
||||
|
||||
Membre membre = membreService.trouverParEmail(email)
|
||||
.filter(m -> m.getActif() == null || m.getActif())
|
||||
.orElseThrow(() -> new NotFoundException("Membre non trouvé pour l'email: " + email));
|
||||
.orElseGet(() -> {
|
||||
LOG.infof("Fiche membre inexistante pour %s — auto-provisionnement depuis JWT", email);
|
||||
return autoProvisionnerMembre(email);
|
||||
});
|
||||
|
||||
return Response.ok(membreService.convertToResponse(membre)).build();
|
||||
// Si la fiche existe mais est inactive (provisionnement précédent incomplet), on l'active
|
||||
if (membre.getActif() != null && !membre.getActif()) {
|
||||
LOG.infof("Fiche inactive pour %s — activation automatique", email);
|
||||
membre = membreService.activerMembre(membre.getId());
|
||||
}
|
||||
|
||||
MembreResponse response = membreService.convertToResponse(membre);
|
||||
|
||||
// Enrichir avec les rôles Keycloak si la table membres_roles est vide
|
||||
if (response.getRoles() == null || response.getRoles().isEmpty()) {
|
||||
java.util.Set<String> keycloakRoles = securityIdentity.getRoles();
|
||||
// Filtrer les rôles internes Keycloak (offline_access, uma_authorization, etc.)
|
||||
java.util.List<String> rolesFiltres = keycloakRoles.stream()
|
||||
.filter(r -> !r.equals("offline_access") && !r.equals("uma_authorization")
|
||||
&& !r.startsWith("default-roles-"))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
response.setRoles(rolesFiltres);
|
||||
}
|
||||
|
||||
return Response.ok(response).build();
|
||||
}
|
||||
|
||||
/** Crée et active une fiche membre depuis les claims JWT lors du premier accès. */
|
||||
private Membre autoProvisionnerMembre(String email) {
|
||||
String prenom = "Utilisateur";
|
||||
String nom = "UnionFlow";
|
||||
UUID keycloakId = null;
|
||||
|
||||
if (jwt != null) {
|
||||
String givenName = jwt.getClaim("given_name");
|
||||
String familyName = jwt.getClaim("family_name");
|
||||
String sub = jwt.getSubject();
|
||||
if (givenName != null && !givenName.isBlank()) prenom = givenName;
|
||||
if (familyName != null && !familyName.isBlank()) nom = familyName;
|
||||
if (sub != null) {
|
||||
try { keycloakId = UUID.fromString(sub); } catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
CreateMembreRequest req = CreateMembreRequest.builder()
|
||||
.prenom(prenom)
|
||||
.nom(nom)
|
||||
.email(email)
|
||||
.dateNaissance(LocalDate.of(1900, 1, 1))
|
||||
.build();
|
||||
|
||||
Membre nouveau = membreService.convertFromCreateRequest(req);
|
||||
if (keycloakId != null) nouveau.setKeycloakId(keycloakId);
|
||||
|
||||
// creerMembre() force actif=false + EN_ATTENTE_VALIDATION.
|
||||
// On active immédiatement car l'utilisateur est déjà authentifié via Keycloak.
|
||||
try {
|
||||
Membre cree = membreService.creerMembre(nouveau);
|
||||
return membreService.activerMembre(cree.getId());
|
||||
} catch (IllegalArgumentException e) {
|
||||
// Fiche déjà présente mais inactive — on l'active directement
|
||||
LOG.infof("Fiche existante pour %s — activation directe", email);
|
||||
Membre existant = membreService.trouverParEmail(email)
|
||||
.orElseThrow(() -> new jakarta.ws.rs.NotFoundException("Membre introuvable : " + email));
|
||||
return membreService.activerMembre(existant.getId());
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@@ -180,48 +245,62 @@ public class MembreResource {
|
||||
Membre nouveauMembre = membreService.creerMembre(membre);
|
||||
|
||||
// Provisionner le compte Keycloak (non bloquant — l'admin peut activer manuellement)
|
||||
String motDePasseTemporaire = null;
|
||||
try {
|
||||
keycloakSyncService.provisionKeycloakUser(nouveauMembre.getId());
|
||||
motDePasseTemporaire = keycloakSyncService.provisionKeycloakUser(nouveauMembre.getId());
|
||||
} catch (Exception e) {
|
||||
LOG.warnf("Provisionnement Keycloak échoué pour %s (non bloquant): %s", nouveauMembre.getEmail(), e.getMessage());
|
||||
}
|
||||
|
||||
// Validation périmètre ADMIN_ORGANISATION - lier le membre à l'organisation
|
||||
// Lier le membre à l'organisation si un organisationId est fourni
|
||||
java.util.Set<String> roles = securityIdentity.getRoles();
|
||||
boolean onlyOrgAdmin = roles != null && roles.contains("ADMIN_ORGANISATION")
|
||||
&& !roles.contains("ADMIN")
|
||||
&& !roles.contains("SUPER_ADMIN");
|
||||
|
||||
if (onlyOrgAdmin) {
|
||||
if (membreDTO.organisationId() == null) {
|
||||
return Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity(Map.of("error", "organisationId obligatoire pour ADMIN_ORGANISATION"))
|
||||
.build();
|
||||
if (membreDTO.organisationId() != null) {
|
||||
if (onlyOrgAdmin) {
|
||||
// Vérifier que l'ADMIN_ORGANISATION a accès à cette organisation
|
||||
String email = securityIdentity.getPrincipal().getName();
|
||||
List<UUID> userOrgIds = organisationService.listerOrganisationsPourUtilisateur(email)
|
||||
.stream()
|
||||
.map(org -> org.getId())
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
|
||||
if (!userOrgIds.contains(membreDTO.organisationId())) {
|
||||
return Response.status(Response.Status.FORBIDDEN)
|
||||
.entity(Map.of("error", "Vous n'avez pas accès à cette organisation"))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier que l'utilisateur a accès à cette organisation
|
||||
String email = securityIdentity.getPrincipal().getName();
|
||||
// Auto-activer si l'organisation a une souscription active (l'admin a déjà payé)
|
||||
boolean orgActif = membreService.orgHasActiveSubscription(membreDTO.organisationId());
|
||||
|
||||
List<UUID> userOrgIds = organisationService.listerOrganisationsPourUtilisateur(email)
|
||||
.stream()
|
||||
.map(org -> org.getId())
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
|
||||
if (!userOrgIds.contains(membreDTO.organisationId())) {
|
||||
return Response.status(Response.Status.FORBIDDEN)
|
||||
.entity(Map.of("error", "Vous n'avez pas accès à cette organisation"))
|
||||
.build();
|
||||
}
|
||||
|
||||
// Lier le membre à l'organisation et incrémenter le quota
|
||||
// Lier le membre à l'organisation (SUPER_ADMIN ou ADMIN_ORGANISATION)
|
||||
membreService.lierMembreOrganisationEtIncrementerQuota(
|
||||
nouveauMembre,
|
||||
membreDTO.organisationId(),
|
||||
"EN_ATTENTE_VALIDATION");
|
||||
orgActif ? "ACTIF" : "EN_ATTENTE_VALIDATION");
|
||||
|
||||
if (orgActif) {
|
||||
nouveauMembre = membreService.activerMembre(nouveauMembre.getId());
|
||||
try {
|
||||
keycloakSyncService.activerMembreDansKeycloak(nouveauMembre.getId());
|
||||
} catch (Exception e) {
|
||||
LOG.warnf("Activation Keycloak échouée pour %s (non bloquant): %s",
|
||||
nouveauMembre.getEmail(), e.getMessage());
|
||||
}
|
||||
}
|
||||
} else if (onlyOrgAdmin) {
|
||||
return Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity(Map.of("error", "organisationId obligatoire pour ADMIN_ORGANISATION"))
|
||||
.build();
|
||||
}
|
||||
|
||||
// Conversion de retour vers DTO
|
||||
MembreResponse nouveauMembreDTO = membreService.convertToResponse(nouveauMembre);
|
||||
nouveauMembreDTO.setMotDePasseTemporaire(motDePasseTemporaire);
|
||||
|
||||
return Response.status(Response.Status.CREATED).entity(nouveauMembreDTO).build();
|
||||
}
|
||||
@@ -674,6 +753,30 @@ public class MembreResource {
|
||||
.build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}/affecter-organisation")
|
||||
@RolesAllowed({"ADMIN", "SUPER_ADMIN"})
|
||||
@Operation(
|
||||
summary = "Affecter un membre à une organisation",
|
||||
description = "Crée le lien MembreOrganisation (statut EN_ATTENTE_VALIDATION) si le membre n'est pas encore rattaché à une organisation. Idempotent.")
|
||||
@APIResponse(responseCode = "200", description = "Membre affecté à l'organisation")
|
||||
@APIResponse(responseCode = "404", description = "Membre ou organisation non trouvé")
|
||||
@APIResponse(responseCode = "403", description = "Accès réservé aux ADMIN / SUPER_ADMIN")
|
||||
public Response affecterOrganisation(
|
||||
@Parameter(description = "UUID du membre") @PathParam("id") UUID id,
|
||||
@Parameter(description = "UUID de l'organisation") @QueryParam("organisationId") UUID organisationId) {
|
||||
LOG.infof("Affectation du membre %s à l'organisation %s", id, organisationId);
|
||||
|
||||
if (organisationId == null) {
|
||||
return Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity(Map.of("error", "organisationId est obligatoire"))
|
||||
.build();
|
||||
}
|
||||
|
||||
Membre membre = membreService.affecterOrganisation(id, organisationId);
|
||||
return Response.ok(membreService.convertToResponse(membre)).build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}/promouvoir-admin-organisation")
|
||||
@RolesAllowed({"ADMIN", "SUPER_ADMIN"})
|
||||
@@ -722,6 +825,37 @@ public class MembreResource {
|
||||
return Response.ok(membreService.convertToResponse(membreActive)).build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}/reinitialiser-mot-de-passe")
|
||||
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"})
|
||||
@Operation(summary = "Réinitialiser le mot de passe d'un membre",
|
||||
description = "Génère un nouveau mot de passe temporaire et le définit dans Keycloak. Le nouveau mot de passe est retourné une seule fois dans la réponse.")
|
||||
@APIResponse(responseCode = "200", description = "Mot de passe réinitialisé, retourné dans motDePasseTemporaire")
|
||||
@APIResponse(responseCode = "404", description = "Membre non trouvé")
|
||||
@APIResponse(responseCode = "400", description = "Le membre n'a pas de compte Keycloak")
|
||||
public Response reinitialiserMotDePasse(
|
||||
@Parameter(description = "UUID du membre") @PathParam("id") UUID id) {
|
||||
LOG.infof("Réinitialisation mot de passe pour membre ID: %s", id);
|
||||
|
||||
Membre membre = membreService.trouverParId(id)
|
||||
.orElseThrow(() -> new NotFoundException("Membre non trouvé avec l'ID: " + id));
|
||||
|
||||
String newPassword;
|
||||
try {
|
||||
newPassword = keycloakSyncService.reinitialiserMotDePasse(id);
|
||||
} catch (IllegalStateException e) {
|
||||
return Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity(Map.of("error", e.getMessage())).build();
|
||||
}
|
||||
|
||||
dev.lions.unionflow.server.api.dto.membre.response.MembreResponse response =
|
||||
membreService.convertToResponse(membre);
|
||||
response.setMotDePasseTemporaire(newPassword);
|
||||
|
||||
LOG.infof("Mot de passe réinitialisé pour %s", membre.getEmail());
|
||||
return Response.ok(response).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/export/count")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
|
||||
@@ -29,7 +29,7 @@ import dev.lions.unionflow.server.repository.MembreRepository;
|
||||
@Path("/api/notifications")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE", "USER" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE", "USER" })
|
||||
@Tag(name = "Notifications", description = "Gestion des notifications : envoi, templates et notifications groupées")
|
||||
public class NotificationResource {
|
||||
|
||||
@@ -99,7 +99,7 @@ public class NotificationResource {
|
||||
* @return Template créé
|
||||
*/
|
||||
@POST
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
@Path("/templates")
|
||||
public Response creerTemplate(@Valid CreateTemplateNotificationRequest request) {
|
||||
try {
|
||||
@@ -128,7 +128,7 @@ public class NotificationResource {
|
||||
* @return Notification créée
|
||||
*/
|
||||
@POST
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
public Response creerNotification(@Valid CreateNotificationRequest request) {
|
||||
try {
|
||||
NotificationResponse result = notificationService.creerNotification(request);
|
||||
@@ -148,7 +148,7 @@ public class NotificationResource {
|
||||
* @return Notification mise à jour
|
||||
*/
|
||||
@POST
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
@Path("/{id}/marquer-lue")
|
||||
public Response marquerCommeLue(@PathParam("id") UUID id) {
|
||||
try {
|
||||
@@ -260,7 +260,7 @@ public class NotificationResource {
|
||||
* @return Nombre de notifications créées
|
||||
*/
|
||||
@POST
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
@Path("/groupees")
|
||||
public Response envoyerNotificationsGroupees(NotificationGroupeeRequest request) {
|
||||
try {
|
||||
|
||||
@@ -86,7 +86,7 @@ public class OrganisationResource {
|
||||
|
||||
/** Crée une nouvelle organisation */
|
||||
@POST
|
||||
@RolesAllowed({"SUPER_ADMIN", "ADMIN", "MEMBRE"})
|
||||
@RolesAllowed({"SUPER_ADMIN"})
|
||||
|
||||
@Operation(
|
||||
summary = "Créer une nouvelle organisation",
|
||||
@@ -242,7 +242,7 @@ public class OrganisationResource {
|
||||
|
||||
/** Met à jour une organisation */
|
||||
@PUT
|
||||
@RolesAllowed({"ADMIN", "MEMBRE"})
|
||||
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE"})
|
||||
@Path("/{id}")
|
||||
|
||||
@Operation(
|
||||
@@ -294,7 +294,7 @@ public class OrganisationResource {
|
||||
|
||||
/** Supprime une organisation */
|
||||
@DELETE
|
||||
@RolesAllowed({"ADMIN"})
|
||||
@RolesAllowed({"SUPER_ADMIN"})
|
||||
@Path("/{id}")
|
||||
|
||||
@Operation(
|
||||
@@ -384,7 +384,7 @@ public class OrganisationResource {
|
||||
|
||||
/** Active une organisation */
|
||||
@POST
|
||||
@RolesAllowed({"ADMIN", "MEMBRE"})
|
||||
@RolesAllowed({"SUPER_ADMIN", "ADMIN"})
|
||||
@Path("/{id}/activer")
|
||||
|
||||
@Operation(
|
||||
@@ -419,7 +419,7 @@ public class OrganisationResource {
|
||||
|
||||
/** Suspend une organisation */
|
||||
@POST
|
||||
@RolesAllowed({"ADMIN", "MEMBRE"})
|
||||
@RolesAllowed({"SUPER_ADMIN", "ADMIN"})
|
||||
@Path("/{id}/suspendre")
|
||||
|
||||
@Operation(
|
||||
@@ -454,6 +454,7 @@ public class OrganisationResource {
|
||||
|
||||
/** Obtient les statistiques des organisations */
|
||||
@GET
|
||||
@RolesAllowed({"SUPER_ADMIN", "ADMIN", "ADMIN_ORGANISATION"})
|
||||
@Path("/statistiques")
|
||||
|
||||
@Operation(
|
||||
|
||||
@@ -25,7 +25,7 @@ import org.jboss.logging.Logger;
|
||||
@Path("/api/paiements")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE", "USER" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE", "USER" })
|
||||
@Tag(name = "Paiements", description = "Gestion des paiements : création, validation et suivi")
|
||||
public class PaiementResource {
|
||||
|
||||
@@ -41,7 +41,7 @@ public class PaiementResource {
|
||||
* @return Paiement créé
|
||||
*/
|
||||
@POST
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
public Response creerPaiement(@Valid CreatePaiementRequest request) {
|
||||
LOG.infof("POST /api/paiements - Création paiement: %s", request.numeroReference());
|
||||
PaiementResponse result = paiementService.creerPaiement(request);
|
||||
@@ -55,7 +55,7 @@ public class PaiementResource {
|
||||
* @return Paiement validé
|
||||
*/
|
||||
@POST
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
@Path("/{id}/valider")
|
||||
public Response validerPaiement(@PathParam("id") UUID id) {
|
||||
LOG.infof("POST /api/paiements/%s/valider", id);
|
||||
@@ -70,7 +70,7 @@ public class PaiementResource {
|
||||
* @return Paiement annulé
|
||||
*/
|
||||
@POST
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
@Path("/{id}/annuler")
|
||||
public Response annulerPaiement(@PathParam("id") UUID id) {
|
||||
LOG.infof("POST /api/paiements/%s/annuler", id);
|
||||
@@ -129,7 +129,7 @@ public class PaiementResource {
|
||||
*/
|
||||
@GET
|
||||
@Path("/mes-paiements/historique")
|
||||
@RolesAllowed({ "MEMBRE", "ADMIN" })
|
||||
@RolesAllowed({ "MEMBRE", "ADMIN", "ADMIN_ORGANISATION" })
|
||||
public Response getMonHistoriquePaiements(
|
||||
@QueryParam("limit") @DefaultValue("5") int limit) {
|
||||
LOG.infof("GET /api/paiements/mes-paiements/historique?limit=%d", limit);
|
||||
@@ -146,7 +146,7 @@ public class PaiementResource {
|
||||
*/
|
||||
@POST
|
||||
@Path("/initier-paiement-en-ligne")
|
||||
@RolesAllowed({ "MEMBRE", "MEMBRE_ACTIF", "ADMIN", "USER" })
|
||||
@RolesAllowed({ "MEMBRE", "MEMBRE_ACTIF", "ADMIN", "ADMIN_ORGANISATION", "USER" })
|
||||
public Response initierPaiementEnLigne(@Valid dev.lions.unionflow.server.api.dto.paiement.request.InitierPaiementEnLigneRequest request) {
|
||||
LOG.infof("POST /api/paiements/initier-paiement-en-ligne - cotisation: %s, méthode: %s",
|
||||
request.cotisationId(), request.methodePaiement());
|
||||
@@ -161,7 +161,7 @@ public class PaiementResource {
|
||||
*/
|
||||
@POST
|
||||
@Path("/initier-depot-epargne-en-ligne")
|
||||
@RolesAllowed({ "MEMBRE", "MEMBRE_ACTIF", "ADMIN", "USER" })
|
||||
@RolesAllowed({ "MEMBRE", "MEMBRE_ACTIF", "ADMIN", "ADMIN_ORGANISATION", "USER" })
|
||||
public Response initierDepotEpargneEnLigne(@Valid dev.lions.unionflow.server.api.dto.paiement.request.InitierDepotEpargneRequest request) {
|
||||
LOG.infof("POST /api/paiements/initier-depot-epargne-en-ligne - compte: %s, montant: %s",
|
||||
request.compteId(), request.montant());
|
||||
@@ -180,7 +180,7 @@ public class PaiementResource {
|
||||
*/
|
||||
@POST
|
||||
@Path("/declarer-paiement-manuel")
|
||||
@RolesAllowed({ "MEMBRE", "ADMIN" })
|
||||
@RolesAllowed({ "MEMBRE", "ADMIN", "ADMIN_ORGANISATION" })
|
||||
public Response declarerPaiementManuel(@Valid dev.lions.unionflow.server.api.dto.paiement.request.DeclarerPaiementManuelRequest request) {
|
||||
LOG.infof("POST /api/paiements/declarer-paiement-manuel - cotisation: %s, méthode: %s",
|
||||
request.cotisationId(), request.methodePaiement());
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package dev.lions.unionflow.server.resource;
|
||||
|
||||
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.service.SouscriptionService;
|
||||
import dev.lions.unionflow.server.service.support.SecuriteHelper;
|
||||
import io.quarkus.security.Authenticated;
|
||||
import jakarta.annotation.security.PermitAll;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.ws.rs.BadRequestException;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Ressource REST pour le workflow de souscription/onboarding UnionFlow.
|
||||
*
|
||||
* <p>Endpoints publics :
|
||||
* <ul>
|
||||
* <li>{@code GET /api/souscriptions/formules} — catalogue des formules (PermitAll)</li>
|
||||
* <li>{@code POST /api/souscriptions/confirmer-paiement} — callback deep link Wave (PermitAll)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Endpoints ADMIN_ORGANISATION :
|
||||
* <ul>
|
||||
* <li>{@code GET /api/souscriptions/ma-souscription}</li>
|
||||
* <li>{@code POST /api/souscriptions/demande}</li>
|
||||
* <li>{@code POST /api/souscriptions/{id}/initier-paiement}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Endpoints SUPER_ADMIN :
|
||||
* <ul>
|
||||
* <li>{@code GET /api/souscriptions/admin/en-attente}</li>
|
||||
* <li>{@code POST /api/souscriptions/admin/{id}/approuver}</li>
|
||||
* <li>{@code POST /api/souscriptions/admin/{id}/rejeter}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author UnionFlow Team
|
||||
* @version 1.0
|
||||
* @since 2026-03-30
|
||||
*/
|
||||
@Path("/api/souscriptions")
|
||||
@ApplicationScoped
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public class SouscriptionResource {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(SouscriptionResource.class);
|
||||
|
||||
@Inject
|
||||
SouscriptionService souscriptionService;
|
||||
|
||||
@Inject
|
||||
SecuriteHelper securiteHelper;
|
||||
|
||||
// ── Catalogue (public) ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Retourne le catalogue complet des formules d'abonnement.
|
||||
*
|
||||
* <p>Endpoint public — PermitAll. Utilisé par l'écran d'onboarding mobile
|
||||
* avant authentification.
|
||||
*/
|
||||
@GET
|
||||
@Path("/formules")
|
||||
@PermitAll
|
||||
public Response getFormules() {
|
||||
LOG.debug("GET /api/souscriptions/formules");
|
||||
List<FormuleAbonnementResponse> formules = souscriptionService.getFormules();
|
||||
return Response.ok(formules).build();
|
||||
}
|
||||
|
||||
// ── ADMIN_ORGANISATION ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Retourne la souscription de l'organisation du membre connecté.
|
||||
*/
|
||||
@GET
|
||||
@Path("/ma-souscription")
|
||||
@Authenticated
|
||||
public Response getMaSouscription() {
|
||||
LOG.debug("GET /api/souscriptions/ma-souscription");
|
||||
SouscriptionStatutResponse response = souscriptionService.getMaSouscription();
|
||||
return Response.ok(response).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une demande de souscription.
|
||||
*
|
||||
* <p>Le corps de la requête doit contenir les champs :
|
||||
* {@code typeFormule}, {@code plageMembres}, {@code typePeriode},
|
||||
* {@code typeOrganisation}, {@code organisationId}.
|
||||
*/
|
||||
@POST
|
||||
@Path("/demande")
|
||||
@Authenticated
|
||||
public Response creerDemande(@Valid SouscriptionDemandeRequest request) {
|
||||
LOG.infof("POST /api/souscriptions/demande — formule=%s plage=%s",
|
||||
request.getTypeFormule(), request.getPlageMembres());
|
||||
SouscriptionStatutResponse response = souscriptionService.creerDemande(request);
|
||||
return Response.status(Response.Status.CREATED).entity(response).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initie une session de paiement Wave pour la souscription identifiée.
|
||||
*
|
||||
* <p>Retourne le {@code waveLaunchUrl} à ouvrir dans le navigateur ou WebView.
|
||||
*
|
||||
* @param id UUID de la souscription
|
||||
*/
|
||||
@POST
|
||||
@Path("/{id}/initier-paiement")
|
||||
@Authenticated
|
||||
public Response initierPaiement(@PathParam("id") UUID id) {
|
||||
LOG.infof("POST /api/souscriptions/%s/initier-paiement", id);
|
||||
SouscriptionStatutResponse response = souscriptionService.initierPaiementWave(id);
|
||||
return Response.ok(response).build();
|
||||
}
|
||||
|
||||
// ── Callback Wave (public) ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Confirme le paiement Wave (appelé depuis le deep link ou un webhook).
|
||||
*
|
||||
* <p>Endpoint public car appelé par le système Wave en dehors de toute session
|
||||
* utilisateur. La souscription est identifiée par son UUID.
|
||||
*
|
||||
* @param id UUID de la souscription (query param {@code id})
|
||||
* @param waveId identifiant de la transaction Wave (query param {@code wave_id})
|
||||
*/
|
||||
@POST
|
||||
@Path("/confirmer-paiement")
|
||||
@PermitAll
|
||||
public Response confirmerPaiement(@QueryParam("id") UUID id,
|
||||
@QueryParam("wave_id") String waveId) {
|
||||
if (id == null) {
|
||||
throw new BadRequestException("Le paramètre 'id' est obligatoire");
|
||||
}
|
||||
LOG.infof("POST /api/souscriptions/confirmer-paiement — id=%s waveId=%s", id, waveId);
|
||||
souscriptionService.confirmerPaiement(id, waveId);
|
||||
return Response.ok(Map.of("message", "Paiement confirmé — compte activé")).build();
|
||||
}
|
||||
|
||||
// ── SuperAdmin ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Liste les souscriptions en attente de validation SuperAdmin (statut PAIEMENT_CONFIRME).
|
||||
*/
|
||||
@GET
|
||||
@Path("/admin/en-attente")
|
||||
@RolesAllowed({"SUPER_ADMIN"})
|
||||
public Response getSouscriptionsEnAttente() {
|
||||
LOG.debug("GET /api/souscriptions/admin/en-attente");
|
||||
List<SouscriptionStatutResponse> liste = souscriptionService.getSouscriptionsEnAttenteValidation();
|
||||
return Response.ok(liste).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Approuve une souscription et active le compte de l'administrateur d'organisation.
|
||||
*
|
||||
* @param id UUID de la souscription
|
||||
*/
|
||||
@POST
|
||||
@Path("/admin/{id}/approuver")
|
||||
@RolesAllowed({"SUPER_ADMIN"})
|
||||
public Response approuver(@PathParam("id") UUID id) {
|
||||
LOG.infof("POST /api/souscriptions/admin/%s/approuver", id);
|
||||
UUID superAdminId = securiteHelper.resolveMembreId();
|
||||
souscriptionService.approuver(id, superAdminId);
|
||||
return Response.ok(Map.of("message", "Souscription approuvée — compte activé")).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejette une souscription avec un commentaire obligatoire.
|
||||
*
|
||||
* <p>Le corps de la requête doit contenir {@code {"commentaire": "..."}}.
|
||||
*
|
||||
* @param id UUID de la souscription
|
||||
* @param body JSON avec le champ {@code commentaire}
|
||||
*/
|
||||
@POST
|
||||
@Path("/admin/{id}/rejeter")
|
||||
@RolesAllowed({"SUPER_ADMIN"})
|
||||
public Response rejeter(@PathParam("id") UUID id, Map<String, String> body) {
|
||||
LOG.infof("POST /api/souscriptions/admin/%s/rejeter", id);
|
||||
String commentaire = body != null ? body.get("commentaire") : null;
|
||||
if (commentaire == null || commentaire.isBlank()) {
|
||||
throw new BadRequestException("Le champ 'commentaire' est obligatoire pour un rejet");
|
||||
}
|
||||
UUID superAdminId = securiteHelper.resolveMembreId();
|
||||
souscriptionService.rejeter(id, superAdminId, commentaire);
|
||||
return Response.ok(Map.of("message", "Souscription rejetée")).build();
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import java.util.UUID;
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Tag(name = "Suggestions", description = "Gestion des suggestions utilisateur")
|
||||
@Slf4j
|
||||
@RolesAllowed({ "USER", "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "USER", "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
public class SuggestionResource {
|
||||
|
||||
@Inject
|
||||
|
||||
@@ -29,7 +29,7 @@ import java.util.UUID;
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Tag(name = "Tickets", description = "Gestion des tickets support")
|
||||
@Slf4j
|
||||
@RolesAllowed({ "USER", "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "USER", "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
public class TicketResource {
|
||||
|
||||
@Inject
|
||||
|
||||
@@ -36,7 +36,7 @@ import io.quarkus.security.identity.SecurityIdentity;
|
||||
@Path("/api/references/types-organisation")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@RolesAllowed({ "SUPER_ADMIN", "SUPER_ADMINISTRATEUR", "ADMIN", "MEMBRE", "USER" })
|
||||
@RolesAllowed({ "SUPER_ADMIN", "ADMIN", "ADMIN_ORGANISATION", "MEMBRE", "USER" })
|
||||
public class TypeOrganisationReferenceResource {
|
||||
|
||||
private static final String DOMAINE_TYPE_ORGANISATION = "TYPE_ORGANISATION";
|
||||
@@ -56,7 +56,7 @@ public class TypeOrganisationReferenceResource {
|
||||
}
|
||||
|
||||
@POST
|
||||
@RolesAllowed({ "SUPER_ADMIN", "SUPER_ADMINISTRATEUR", "ADMIN" })
|
||||
@RolesAllowed({ "SUPER_ADMIN", "ADMIN" })
|
||||
public Response create(@Valid CreateTypeReferenceRequest request) {
|
||||
CreateTypeReferenceRequest withDomaine = CreateTypeReferenceRequest.builder()
|
||||
.domaine(DOMAINE_TYPE_ORGANISATION)
|
||||
@@ -83,7 +83,7 @@ public class TypeOrganisationReferenceResource {
|
||||
|
||||
@PUT
|
||||
@Path("/{id}")
|
||||
@RolesAllowed({ "SUPER_ADMIN", "SUPER_ADMINISTRATEUR", "ADMIN" })
|
||||
@RolesAllowed({ "SUPER_ADMIN", "ADMIN" })
|
||||
public Response update(@PathParam("id") UUID id, @Valid UpdateTypeReferenceRequest request) {
|
||||
try {
|
||||
TypeReferenceResponse updated = typeReferenceService.modifier(id, request);
|
||||
@@ -97,10 +97,10 @@ public class TypeOrganisationReferenceResource {
|
||||
|
||||
@DELETE
|
||||
@Path("/{id}")
|
||||
@RolesAllowed({ "SUPER_ADMIN", "SUPER_ADMINISTRATEUR", "ADMIN" })
|
||||
@RolesAllowed({ "SUPER_ADMIN", "ADMIN" })
|
||||
public Response supprimer(@PathParam("id") UUID id) {
|
||||
try {
|
||||
if (securityIdentity.hasRole("SUPER_ADMIN") || securityIdentity.hasRole("SUPER_ADMINISTRATEUR")) {
|
||||
if (securityIdentity.hasRole("SUPER_ADMIN")) {
|
||||
typeReferenceService.supprimerPourSuperAdmin(id);
|
||||
} else {
|
||||
typeReferenceService.supprimer(id);
|
||||
|
||||
@@ -52,7 +52,7 @@ import org.jboss.logging.Logger;
|
||||
@Tag(name = "Types de référence", description = "Gestion des données de référence"
|
||||
+ " paramétrables")
|
||||
@RolesAllowed({
|
||||
"SUPER_ADMIN", "SUPER_ADMINISTRATEUR",
|
||||
"SUPER_ADMIN",
|
||||
"ADMIN", "MEMBRE", "USER"
|
||||
})
|
||||
public class TypeReferenceResource {
|
||||
@@ -174,7 +174,7 @@ public class TypeReferenceResource {
|
||||
*/
|
||||
@POST
|
||||
@RolesAllowed({
|
||||
"SUPER_ADMIN", "SUPER_ADMINISTRATEUR", "ADMIN"
|
||||
"SUPER_ADMIN", "ADMIN"
|
||||
})
|
||||
@Operation(summary = "Créer une référence", description = "Ajoute une nouvelle valeur dans"
|
||||
+ " un domaine")
|
||||
@@ -212,7 +212,7 @@ public class TypeReferenceResource {
|
||||
@PUT
|
||||
@Path("/{id}")
|
||||
@RolesAllowed({
|
||||
"SUPER_ADMIN", "SUPER_ADMINISTRATEUR", "ADMIN"
|
||||
"SUPER_ADMIN", "ADMIN"
|
||||
})
|
||||
@Operation(summary = "Modifier une référence", description = "Met à jour une valeur"
|
||||
+ " existante")
|
||||
@@ -251,7 +251,7 @@ public class TypeReferenceResource {
|
||||
@DELETE
|
||||
@Path("/{id}")
|
||||
@RolesAllowed({
|
||||
"SUPER_ADMIN", "SUPER_ADMINISTRATEUR"
|
||||
"SUPER_ADMIN"
|
||||
})
|
||||
@Operation(summary = "Supprimer une référence", description = "Supprime une valeur non"
|
||||
+ " système")
|
||||
|
||||
@@ -29,7 +29,7 @@ import org.jboss.logging.Logger;
|
||||
@Path("/api/wave")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE", "USER" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE", "USER" })
|
||||
@Tag(name = "Wave Mobile Money", description = "Gestion des comptes et transactions Wave Mobile Money")
|
||||
public class WaveResource {
|
||||
|
||||
@@ -43,7 +43,7 @@ public class WaveResource {
|
||||
// ========================================
|
||||
|
||||
@POST
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
@Path("/comptes")
|
||||
@Operation(summary = "Créer un compte Wave", description = "Crée un nouveau compte Wave pour un membre ou une organisation")
|
||||
@APIResponses({
|
||||
@@ -67,7 +67,7 @@ public class WaveResource {
|
||||
}
|
||||
|
||||
@PUT
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
@Path("/comptes/{id}")
|
||||
@Operation(summary = "Mettre à jour un compte Wave", description = "Met à jour les informations d'un compte Wave existant")
|
||||
@APIResponses({
|
||||
@@ -94,7 +94,7 @@ public class WaveResource {
|
||||
}
|
||||
|
||||
@POST
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
@Path("/comptes/{id}/verifier")
|
||||
@Operation(summary = "Vérifier un compte Wave", description = "Vérifie la validité d'un compte Wave")
|
||||
@APIResponses({
|
||||
@@ -193,7 +193,7 @@ public class WaveResource {
|
||||
// ========================================
|
||||
|
||||
@POST
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
@Path("/transactions")
|
||||
@Operation(summary = "Créer une transaction Wave", description = "Initie une nouvelle transaction de paiement Wave")
|
||||
@APIResponses({
|
||||
@@ -213,7 +213,7 @@ public class WaveResource {
|
||||
}
|
||||
|
||||
@PUT
|
||||
@RolesAllowed({ "ADMIN", "MEMBRE" })
|
||||
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
|
||||
@Path("/transactions/{waveTransactionId}/statut")
|
||||
@Operation(summary = "Mettre à jour le statut d'une transaction", description = "Met à jour le statut d'une transaction Wave (ex: COMPLETED, FAILED)")
|
||||
@APIResponses({
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user