diff --git a/src/main/java/de/lions/unionflow/server/auth/AuthCallbackResource.java b/src/main/java/de/lions/unionflow/server/auth/AuthCallbackResource.java index 08e310e..f21a671 100644 --- a/src/main/java/de/lions/unionflow/server/auth/AuthCallbackResource.java +++ b/src/main/java/de/lions/unionflow/server/auth/AuthCallbackResource.java @@ -1,5 +1,6 @@ package de.lions.unionflow.server.auth; +import io.quarkus.security.PermitAll; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.QueryParam; @@ -11,6 +12,7 @@ import org.jboss.logging.Logger; * mobile. */ @Path("/auth") +@PermitAll public class AuthCallbackResource { private static final Logger log = Logger.getLogger(AuthCallbackResource.class); diff --git a/src/main/java/dev/lions/unionflow/server/entity/BaremeCotisationRole.java b/src/main/java/dev/lions/unionflow/server/entity/BaremeCotisationRole.java new file mode 100644 index 0000000..0af03c1 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/entity/BaremeCotisationRole.java @@ -0,0 +1,62 @@ +package dev.lions.unionflow.server.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import lombok.*; + +/** + * Barème de cotisation par rôle fonctionnel au sein d'une organisation. + * + *

Permet de définir des montants différenciés selon le rôle du membre + * (PRESIDENT, TRESORIER, MEMBRE_ORDINAIRE, etc.). + * + *

Si aucun barème n'est défini pour un rôle donné, le système utilise + * le montant par défaut de {@link ParametresCotisationOrganisation}. + * + *

Table : {@code bareme_cotisation_role} + */ +@Entity +@Table( + name = "bareme_cotisation_role", + uniqueConstraints = @UniqueConstraint( + name = "uq_bareme_cot_org_role", + columnNames = {"organisation_id", "role_org"} + ) +) +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EqualsAndHashCode(callSuper = true) +public class BaremeCotisationRole extends BaseEntity { + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "organisation_id", nullable = false) + private Organisation organisation; + + /** + * Rôle fonctionnel dans l'organisation (ex: PRESIDENT, TRESORIER, SECRETAIRE, MEMBRE_ORDINAIRE). + * Correspond à {@link dev.lions.unionflow.server.entity.MembreOrganisation#getRoleOrg()}. + */ + @NotBlank + @Column(name = "role_org", nullable = false, length = 50) + private String roleOrg; + + @Builder.Default + @DecimalMin("0.00") + @Digits(integer = 10, fraction = 2) + @Column(name = "montant_mensuel", nullable = false, precision = 12, scale = 2) + private BigDecimal montantMensuel = BigDecimal.ZERO; + + @Builder.Default + @DecimalMin("0.00") + @Digits(integer = 10, fraction = 2) + @Column(name = "montant_annuel", nullable = false, precision = 12, scale = 2) + private BigDecimal montantAnnuel = BigDecimal.ZERO; + + /** Description optionnelle du barème (ex: "Taux réduit bureau exécutif"). */ + @Column(name = "description", length = 255) + private String description; +} diff --git a/src/main/java/dev/lions/unionflow/server/entity/ParametresCotisationOrganisation.java b/src/main/java/dev/lions/unionflow/server/entity/ParametresCotisationOrganisation.java index d58f375..bd314eb 100644 --- a/src/main/java/dev/lions/unionflow/server/entity/ParametresCotisationOrganisation.java +++ b/src/main/java/dev/lions/unionflow/server/entity/ParametresCotisationOrganisation.java @@ -73,6 +73,15 @@ public class ParametresCotisationOrganisation extends BaseEntity { @Column(name = "cotisation_obligatoire", nullable = false) private Boolean cotisationObligatoire = true; + /** + * Active la génération automatique mensuelle des cotisations pour cette organisation. + * Quand {@code true}, un job planifié crée automatiquement une cotisation par membre actif + * le 1er de chaque mois, en utilisant les barèmes par rôle ou le montant par défaut. + */ + @Builder.Default + @Column(name = "generation_automatique_activee", nullable = false) + private Boolean generationAutomatiqueActivee = false; + // ── Méthodes métier ──────────────────────────────────────────────────────── /** diff --git a/src/main/java/dev/lions/unionflow/server/repository/BaremeCotisationRoleRepository.java b/src/main/java/dev/lions/unionflow/server/repository/BaremeCotisationRoleRepository.java new file mode 100644 index 0000000..03e6111 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/repository/BaremeCotisationRoleRepository.java @@ -0,0 +1,20 @@ +package dev.lions.unionflow.server.repository; + +import dev.lions.unionflow.server.entity.BaremeCotisationRole; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@ApplicationScoped +public class BaremeCotisationRoleRepository implements PanacheRepositoryBase { + + public Optional findByOrganisationIdAndRoleOrg(UUID organisationId, String roleOrg) { + return find("organisation.id = ?1 AND roleOrg = ?2", organisationId, roleOrg).firstResultOptional(); + } + + public List findByOrganisationId(UUID organisationId) { + return find("organisation.id = ?1", organisationId).list(); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/repository/CotisationRepository.java b/src/main/java/dev/lions/unionflow/server/repository/CotisationRepository.java index 197f34c..ad37ea1 100644 --- a/src/main/java/dev/lions/unionflow/server/repository/CotisationRepository.java +++ b/src/main/java/dev/lions/unionflow/server/repository/CotisationRepository.java @@ -601,4 +601,22 @@ public class CotisationRepository extends BaseRepository { } return orderBy.toString(); } + + /** + * Vérifie si une cotisation mensuelle existe déjà pour ce membre, cette organisation, + * cette année et ce mois. Utilisé pour éviter les doublons lors de la génération automatique. + */ + public boolean existsByMembreOrganisationAnneeAndMois(UUID membreId, UUID organisationId, int annee, int mois) { + Long count = entityManager.createQuery( + "SELECT COUNT(c) FROM Cotisation c " + + "WHERE c.membre.id = :membreId AND c.organisation.id = :orgId " + + "AND c.annee = :annee AND c.mois = :mois", + Long.class) + .setParameter("membreId", membreId) + .setParameter("orgId", organisationId) + .setParameter("annee", annee) + .setParameter("mois", mois) + .getSingleResult(); + return count != null && count > 0; + } } diff --git a/src/main/java/dev/lions/unionflow/server/repository/DocumentRepository.java b/src/main/java/dev/lions/unionflow/server/repository/DocumentRepository.java index ba830a9..1b16a4f 100644 --- a/src/main/java/dev/lions/unionflow/server/repository/DocumentRepository.java +++ b/src/main/java/dev/lions/unionflow/server/repository/DocumentRepository.java @@ -66,6 +66,16 @@ public class DocumentRepository implements PanacheRepositoryBase public List findAllActifs() { return find("actif = true ORDER BY dateCreation DESC").list(); } + + /** + * Trouve les documents créés par un utilisateur (par email) + * + * @param email Email de l'utilisateur (creePar) + * @return Liste des documents + */ + public List findByCreePar(String email) { + return find("creePar = ?1 AND actif = true ORDER BY dateCreation DESC", email).list(); + } } diff --git a/src/main/java/dev/lions/unionflow/server/repository/ParametresCotisationOrganisationRepository.java b/src/main/java/dev/lions/unionflow/server/repository/ParametresCotisationOrganisationRepository.java new file mode 100644 index 0000000..5c60dd1 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/repository/ParametresCotisationOrganisationRepository.java @@ -0,0 +1,22 @@ +package dev.lions.unionflow.server.repository; + +import dev.lions.unionflow.server.entity.ParametresCotisationOrganisation; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@ApplicationScoped +public class ParametresCotisationOrganisationRepository + implements PanacheRepositoryBase { + + public Optional findByOrganisationId(UUID organisationId) { + return find("organisation.id = ?1", organisationId).firstResultOptional(); + } + + /** Retourne toutes les organisations ayant activé la génération automatique. */ + public List findAvecGenerationAutomatiqueActivee() { + return find("generationAutomatiqueActivee = true AND actif = true").list(); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/resource/AnalyticsResource.java b/src/main/java/dev/lions/unionflow/server/resource/AnalyticsResource.java index dda8cc4..f8f7d04 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/AnalyticsResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/AnalyticsResource.java @@ -50,7 +50,7 @@ public class AnalyticsResource { /** Calcule une métrique analytics pour une période donnée */ @GET @Path("/metriques/{typeMetrique}") - @RolesAllowed({"ADMIN", "MANAGER", "MEMBER"}) + @RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE"}) @Operation( summary = "Calculer une métrique analytics", description = "Calcule une métrique spécifique pour une période et organisation données") @@ -88,7 +88,7 @@ public class AnalyticsResource { /** Calcule les tendances d'un KPI sur une période */ @GET @Path("/tendances/{typeMetrique}") - @RolesAllowed({"ADMIN", "MANAGER", "MEMBER"}) + @RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE"}) @Operation( summary = "Calculer la tendance d'un KPI", description = "Calcule l'évolution et les tendances d'un KPI sur une période donnée") @@ -127,7 +127,7 @@ public class AnalyticsResource { /** Obtient tous les KPI pour une organisation */ @GET @Path("/kpis") - @RolesAllowed({"ADMIN", "MANAGER", "MEMBER"}) + @RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE"}) @Operation( summary = "Obtenir tous les KPI", description = "Récupère tous les KPI calculés pour une organisation et période données") @@ -163,7 +163,7 @@ public class AnalyticsResource { /** Calcule le KPI de performance globale */ @GET @Path("/performance-globale") - @RolesAllowed({"ADMIN", "MANAGER"}) + @RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "SUPER_ADMIN"}) @Operation( summary = "Calculer la performance globale", description = "Calcule le score de performance globale de l'organisation") @@ -208,7 +208,7 @@ public class AnalyticsResource { /** Obtient les évolutions des KPI par rapport à la période précédente */ @GET @Path("/evolutions") - @RolesAllowed({"ADMIN", "MANAGER", "MEMBER"}) + @RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE"}) @Operation( summary = "Obtenir les évolutions des KPI", description = "Récupère les évolutions des KPI par rapport à la période précédente") @@ -248,7 +248,7 @@ public class AnalyticsResource { /** Obtient les widgets du tableau de bord pour un utilisateur */ @GET @Path("/dashboard/widgets") - @RolesAllowed({"ADMIN", "MANAGER", "MEMBER"}) + @RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE"}) @Operation( summary = "Obtenir les widgets du tableau de bord", description = "Récupère tous les widgets configurés pour le tableau de bord de l'utilisateur") @@ -285,7 +285,7 @@ public class AnalyticsResource { /** Obtient les types de métriques disponibles */ @GET @Path("/types-metriques") - @RolesAllowed({"ADMIN", "MANAGER", "MEMBER"}) + @RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE"}) @Operation( summary = "Obtenir les types de métriques disponibles", description = "Récupère la liste de tous les types de métriques disponibles") @@ -300,7 +300,7 @@ public class AnalyticsResource { /** Obtient les périodes d'analyse disponibles */ @GET @Path("/periodes-analyse") - @RolesAllowed({"ADMIN", "MANAGER", "MEMBER"}) + @RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE"}) @Operation( summary = "Obtenir les périodes d'analyse disponibles", description = "Récupère la liste de toutes les périodes d'analyse disponibles") diff --git a/src/main/java/dev/lions/unionflow/server/resource/ApprovalResource.java b/src/main/java/dev/lions/unionflow/server/resource/ApprovalResource.java index df2bb76..1b21d57 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/ApprovalResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/ApprovalResource.java @@ -1,6 +1,7 @@ package dev.lions.unionflow.server.resource; import dev.lions.unionflow.server.service.ApprovalService; +import dev.lions.unionflow.server.api.dto.common.ErrorResponse; import dev.lions.unionflow.server.api.dto.finance_workflow.request.ApproveTransactionRequest; import dev.lions.unionflow.server.api.dto.finance_workflow.request.RejectTransactionRequest; import dev.lions.unionflow.server.api.dto.finance_workflow.response.TransactionApprovalResponse; @@ -53,7 +54,7 @@ public class ApprovalResource { if (transactionId == null || transactionType == null || amount == null) { return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("transactionId, transactionType et amount sont requis")) + .entity(ErrorResponse.of("transactionId, transactionType et amount sont requis")) .build(); } @@ -64,12 +65,12 @@ public class ApprovalResource { return Response.status(Response.Status.CREATED).entity(approval).build(); } catch (IllegalArgumentException e) { return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } catch (Exception e) { LOG.error("Erreur lors de la création de la demande d'approbation", e); return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } } @@ -88,7 +89,7 @@ public class ApprovalResource { } catch (Exception e) { LOG.error("Erreur lors de la récupération des approbations en attente", e); return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } } @@ -106,12 +107,12 @@ public class ApprovalResource { return Response.ok(approval).build(); } catch (NotFoundException e) { return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } catch (Exception e) { LOG.error("Erreur lors de la récupération de l'approbation", e); return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } } @@ -131,16 +132,16 @@ public class ApprovalResource { return Response.ok(approval).build(); } catch (NotFoundException e) { return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } catch (ForbiddenException e) { return Response.status(Response.Status.FORBIDDEN) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } catch (Exception e) { LOG.error("Erreur lors de l'approbation de la transaction", e); return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } } @@ -160,16 +161,16 @@ public class ApprovalResource { return Response.ok(approval).build(); } catch (NotFoundException e) { return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } catch (ForbiddenException e) { return Response.status(Response.Status.FORBIDDEN) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } catch (Exception e) { LOG.error("Erreur lors du rejet de la transaction", e); return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } } @@ -197,12 +198,12 @@ public class ApprovalResource { return Response.ok(approvals).build(); } catch (IllegalArgumentException e) { return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } catch (Exception e) { LOG.error("Erreur lors de la récupération de l'historique", e); return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } } @@ -221,12 +222,11 @@ public class ApprovalResource { } catch (Exception e) { LOG.error("Erreur lors du comptage des approbations", e); return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } } - // Classes internes pour les réponses - record ErrorResponse(String message) {} + // Classe interne pour le comptage record CountResponse(long count) {} } diff --git a/src/main/java/dev/lions/unionflow/server/resource/BackupResource.java b/src/main/java/dev/lions/unionflow/server/resource/BackupResource.java index faa65e8..5b7adac 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/BackupResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/BackupResource.java @@ -36,7 +36,7 @@ public class BackupResource { * Lister toutes les sauvegardes */ @GET - @RolesAllowed({"SUPER_ADMIN", "ADMIN", "MODERATOR"}) + @RolesAllowed({"SUPER_ADMIN", "ADMIN", "MODERATEUR"}) @Operation(summary = "Lister toutes les sauvegardes disponibles") public List getAllBackups() { log.info("GET /api/backups"); @@ -48,7 +48,7 @@ public class BackupResource { */ @GET @Path("/{id}") - @RolesAllowed({"SUPER_ADMIN", "ADMIN", "MODERATOR"}) + @RolesAllowed({"SUPER_ADMIN", "ADMIN", "MODERATEUR"}) @Operation(summary = "Récupérer une sauvegarde par ID") public BackupResponse getBackupById(@PathParam("id") UUID id) { log.info("GET /api/backups/{}", id); @@ -98,7 +98,7 @@ public class BackupResource { */ @GET @Path("/config") - @RolesAllowed({"SUPER_ADMIN", "ADMIN", "MODERATOR"}) + @RolesAllowed({"SUPER_ADMIN", "ADMIN", "MODERATEUR"}) @Operation(summary = "Récupérer la configuration des sauvegardes automatiques") public BackupConfigResponse getBackupConfig() { log.info("GET /api/backups/config"); diff --git a/src/main/java/dev/lions/unionflow/server/resource/BudgetResource.java b/src/main/java/dev/lions/unionflow/server/resource/BudgetResource.java index da8f7f3..a109c14 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/BudgetResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/BudgetResource.java @@ -1,6 +1,7 @@ package dev.lions.unionflow.server.resource; import dev.lions.unionflow.server.service.BudgetService; +import dev.lions.unionflow.server.api.dto.common.ErrorResponse; import dev.lions.unionflow.server.api.dto.finance_workflow.request.CreateBudgetRequest; import dev.lions.unionflow.server.api.dto.finance_workflow.response.BudgetResponse; import jakarta.annotation.security.RolesAllowed; @@ -51,12 +52,12 @@ public class BudgetResource { return Response.ok(budgets).build(); } catch (BadRequestException e) { return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } catch (Exception e) { LOG.error("Erreur lors de la récupération des budgets", e); return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } } @@ -74,12 +75,12 @@ public class BudgetResource { return Response.ok(budget).build(); } catch (NotFoundException e) { return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } catch (Exception e) { LOG.error("Erreur lors de la récupération du budget", e); return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } } @@ -98,16 +99,16 @@ public class BudgetResource { .build(); } catch (NotFoundException e) { return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } catch (BadRequestException e) { return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } catch (Exception e) { LOG.error("Erreur lors de la création du budget", e); return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } } @@ -125,12 +126,12 @@ public class BudgetResource { return Response.ok(tracking).build(); } catch (NotFoundException e) { return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } catch (Exception e) { LOG.error("Erreur lors de la récupération du suivi budgétaire", e); return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } } @@ -150,16 +151,16 @@ public class BudgetResource { return Response.ok(budget).build(); } catch (NotFoundException e) { return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } catch (BadRequestException e) { return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } catch (Exception e) { LOG.error("Erreur lors de la mise à jour du budget", e); return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } } @@ -177,16 +178,15 @@ public class BudgetResource { return Response.noContent().build(); } catch (NotFoundException e) { return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } catch (Exception e) { LOG.error("Erreur lors de la suppression du budget", e); return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.of(e.getMessage())) .build(); } } - // Classe interne pour les réponses d'erreur - record ErrorResponse(String message) {} } + diff --git a/src/main/java/dev/lions/unionflow/server/resource/ComptabiliteResource.java b/src/main/java/dev/lions/unionflow/server/resource/ComptabiliteResource.java index 57782a0..d2c08de 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/ComptabiliteResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/ComptabiliteResource.java @@ -2,6 +2,7 @@ package dev.lions.unionflow.server.resource; import dev.lions.unionflow.server.api.dto.comptabilite.request.*; import dev.lions.unionflow.server.api.dto.comptabilite.response.*; +import dev.lions.unionflow.server.api.dto.common.ErrorResponse; import dev.lions.unionflow.server.service.ComptabiliteService; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; @@ -52,12 +53,12 @@ public class ComptabiliteResource { return Response.status(Response.Status.CREATED).entity(result).build(); } catch (IllegalArgumentException e) { return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.ofError(e.getMessage())) .build(); } catch (Exception e) { LOG.errorf(e, "Erreur lors de la création du compte comptable"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la création du compte comptable: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la création du compte comptable: " + e.getMessage())) .build(); } } @@ -76,12 +77,12 @@ public class ComptabiliteResource { return Response.ok(result).build(); } catch (jakarta.ws.rs.NotFoundException e) { return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse("Compte comptable non trouvé")) + .entity(ErrorResponse.ofError("Compte comptable non trouvé")) .build(); } catch (Exception e) { LOG.errorf(e, "Erreur lors de la recherche du compte comptable"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la recherche du compte comptable: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la recherche du compte comptable: " + e.getMessage())) .build(); } } @@ -100,7 +101,7 @@ public class ComptabiliteResource { } catch (Exception e) { LOG.errorf(e, "Erreur lors de la liste des comptes comptables"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la liste des comptes comptables: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la liste des comptes comptables: " + e.getMessage())) .build(); } } @@ -124,12 +125,12 @@ public class ComptabiliteResource { return Response.status(Response.Status.CREATED).entity(result).build(); } catch (IllegalArgumentException e) { return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.ofError(e.getMessage())) .build(); } catch (Exception e) { LOG.errorf(e, "Erreur lors de la création du journal comptable"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la création du journal comptable: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la création du journal comptable: " + e.getMessage())) .build(); } } @@ -148,12 +149,12 @@ public class ComptabiliteResource { return Response.ok(result).build(); } catch (jakarta.ws.rs.NotFoundException e) { return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse("Journal comptable non trouvé")) + .entity(ErrorResponse.ofError("Journal comptable non trouvé")) .build(); } catch (Exception e) { LOG.errorf(e, "Erreur lors de la recherche du journal comptable"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la recherche du journal comptable: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la recherche du journal comptable: " + e.getMessage())) .build(); } } @@ -172,7 +173,7 @@ public class ComptabiliteResource { } catch (Exception e) { LOG.errorf(e, "Erreur lors de la liste des journaux comptables"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la liste des journaux comptables: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la liste des journaux comptables: " + e.getMessage())) .build(); } } @@ -196,12 +197,12 @@ public class ComptabiliteResource { return Response.status(Response.Status.CREATED).entity(result).build(); } catch (IllegalArgumentException e) { return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.ofError(e.getMessage())) .build(); } catch (Exception e) { LOG.errorf(e, "Erreur lors de la création de l'écriture comptable"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la création de l'écriture comptable: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la création de l'écriture comptable: " + e.getMessage())) .build(); } } @@ -220,12 +221,12 @@ public class ComptabiliteResource { return Response.ok(result).build(); } catch (jakarta.ws.rs.NotFoundException e) { return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse("Écriture comptable non trouvée")) + .entity(ErrorResponse.ofError("Écriture comptable non trouvée")) .build(); } catch (Exception e) { LOG.errorf(e, "Erreur lors de la recherche de l'écriture comptable"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la recherche de l'écriture comptable: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la recherche de l'écriture comptable: " + e.getMessage())) .build(); } } @@ -245,7 +246,7 @@ public class ComptabiliteResource { } catch (Exception e) { LOG.errorf(e, "Erreur lors de la liste des écritures"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la liste des écritures: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la liste des écritures: " + e.getMessage())) .build(); } } @@ -265,17 +266,10 @@ public class ComptabiliteResource { } catch (Exception e) { LOG.errorf(e, "Erreur lors de la liste des écritures"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la liste des écritures: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la liste des écritures: " + e.getMessage())) .build(); } } - /** Classe interne pour les réponses d'erreur */ - public static class ErrorResponse { - public String error; - - public ErrorResponse(String error) { - this.error = error; - } - } } + diff --git a/src/main/java/dev/lions/unionflow/server/resource/CompteAdherentResource.java b/src/main/java/dev/lions/unionflow/server/resource/CompteAdherentResource.java index ce7638a..42a5dc4 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/CompteAdherentResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/CompteAdherentResource.java @@ -247,4 +247,55 @@ public class CompteAdherentResource { membreKeycloakSyncService.changerMotDePassePremierLogin(membreOpt.get().getId(), nouveauMotDePasse); return Response.ok(Map.of("message", "Mot de passe mis à jour avec succès.")).build(); } + + /** + * Endpoint mobile : changement de mot de passe depuis l'app Flutter. + * Bypass lions-user-manager — appel direct à l'API Admin Keycloak. + * + *

Body attendu : {@code { "userId": "...", "oldPassword": "...", "newPassword": "..." }} + */ + @POST + @Path("/auth/change-password") + @Authenticated + @Operation( + summary = "Changer le mot de passe (mobile)", + description = "Endpoint dédié à l'application mobile. Bypass lions-user-manager via API Admin Keycloak directe." + ) + public Response changerMotDePasseMobile(Map body) { + String email = securiteHelper.resolveEmail(); + if (email == null || email.isBlank()) { + return Response.status(Response.Status.UNAUTHORIZED).build(); + } + + String newPassword = body == null ? null : body.get("newPassword"); + if (newPassword == null || newPassword.isBlank()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("message", "Le champ 'newPassword' est requis.")) + .build(); + } + if (newPassword.length() < 8) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("message", "Le mot de passe doit contenir au moins 8 caractères.")) + .build(); + } + + Optional 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(); + } + + try { + membreKeycloakSyncService.changerMotDePasseDirectKeycloak(membreOpt.get().getId(), newPassword); + return Response.ok(Map.of("message", "Mot de passe mis à jour avec succès.")).build(); + } catch (Exception e) { + LOG.errorf(e, "Erreur changement mot de passe pour %s", email); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("message", "Erreur lors du changement de mot de passe: " + e.getMessage())) + .build(); + } + } } diff --git a/src/main/java/dev/lions/unionflow/server/resource/CotisationResource.java b/src/main/java/dev/lions/unionflow/server/resource/CotisationResource.java index e7bcb8f..22ee1f8 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/CotisationResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/CotisationResource.java @@ -60,19 +60,19 @@ public class CotisationResource { try { log.info("GET /api/cotisations/public - page: {}, size: {}", page, size); - List cotisations = cotisationService.getAllCotisations(page, size); + List cotisations = cotisationService.getAllCotisations(page, size); List> content = cotisations.stream() .map(c -> { Map map = new java.util.HashMap<>(); - map.put("id", c.id().toString()); - map.put("reference", c.numeroReference()); - map.put("nomMembre", c.nomMembre()); - map.put("montantDu", c.montantDu()); - map.put("montantPaye", c.montantPaye()); - map.put("statut", c.statut()); - map.put("statutLibelle", c.statutLibelle()); - map.put("dateEcheance", c.dateEcheance().toString()); + map.put("id", c.getId() != null ? c.getId().toString() : null); + map.put("reference", c.getNumeroReference()); + map.put("nomMembre", c.getNomMembre()); + map.put("montantDu", c.getMontantDu()); + map.put("montantPaye", c.getMontantPaye()); + map.put("statut", c.getStatut()); + map.put("statutLibelle", c.getStatutLibelle()); + map.put("dateEcheance", c.getDateEcheance() != null ? c.getDateEcheance().toString() : null); return map; }) .collect(Collectors.toList()); @@ -113,7 +113,7 @@ public class CotisationResource { try { log.info("GET /api/cotisations - page: {}, size: {}", page, size); - List cotisations = cotisationService.getAllCotisations(page, size); + List cotisations = cotisationService.getAllCotisations(page, size); return Response.ok(cotisations).build(); } catch (Exception e) { log.error("Erreur lister cotisations", e); @@ -233,7 +233,7 @@ public class CotisationResource { @QueryParam("page") @DefaultValue("0") int page, @QueryParam("size") @DefaultValue("20") int size) { try { - List results = cotisationService.getCotisationsByMembre(membreId, page, size); + List results = cotisationService.getCotisationsByMembre(membreId, page, size); return Response.ok(results).build(); } catch (Exception e) { return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(Map.of("error", e.getMessage())).build(); @@ -251,7 +251,7 @@ public class CotisationResource { @QueryParam("page") @DefaultValue("0") int page, @QueryParam("size") @DefaultValue("20") int size) { try { - List results = cotisationService.getCotisationsByStatut(statut, page, size); + List results = cotisationService.getCotisationsByStatut(statut, page, size); return Response.ok(results).build(); } catch (Exception e) { return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(Map.of("error", e.getMessage())).build(); @@ -268,7 +268,7 @@ public class CotisationResource { @QueryParam("page") @DefaultValue("0") int page, @QueryParam("size") @DefaultValue("20") int size) { try { - List results = cotisationService.getCotisationsEnRetard(page, size); + List results = cotisationService.getCotisationsEnRetard(page, size); return Response.ok(results).build(); } catch (Exception e) { return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(Map.of("error", e.getMessage())).build(); @@ -290,7 +290,7 @@ public class CotisationResource { @QueryParam("page") @DefaultValue("0") int page, @QueryParam("size") @DefaultValue("20") int size) { try { - List results = cotisationService.rechercherCotisations( + List results = cotisationService.rechercherCotisations( membreId, statut, typeCotisation, annee, mois, page, size); return Response.ok(results).build(); } catch (Exception e) { @@ -399,7 +399,7 @@ public class CotisationResource { @QueryParam("size") @DefaultValue("50") int size) { try { log.info("GET /api/cotisations/mes-cotisations"); - List results = cotisationService.getMesCotisations(page, size); + List results = cotisationService.getMesCotisations(page, size); return Response.ok(results).build(); } catch (Exception e) { log.error("Erreur récupération mes cotisations", e); @@ -423,7 +423,7 @@ public class CotisationResource { public Response getMesCotisationsEnAttente() { try { log.info("GET /api/cotisations/mes-cotisations/en-attente"); - List results = cotisationService.getMesCotisationsEnAttente(); + List results = cotisationService.getMesCotisationsEnAttente(); return Response.ok(results).build(); } catch (Exception e) { log.error("Erreur récupération mes cotisations en attente", e); diff --git a/src/main/java/dev/lions/unionflow/server/resource/DocumentResource.java b/src/main/java/dev/lions/unionflow/server/resource/DocumentResource.java index 1bef625..ff6c2ba 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/DocumentResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/DocumentResource.java @@ -1,5 +1,6 @@ package dev.lions.unionflow.server.resource; +import dev.lions.unionflow.server.api.dto.common.ErrorResponse; import dev.lions.unionflow.server.api.dto.document.request.CreateDocumentRequest; import dev.lions.unionflow.server.api.dto.document.response.DocumentResponse; import dev.lions.unionflow.server.api.dto.document.request.CreatePieceJointeRequest; @@ -62,7 +63,7 @@ public class DocumentResource { } catch (Exception e) { LOG.errorf(e, "Erreur lors de la création du document"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la création du document: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la création du document: " + e.getMessage())) .build(); } } @@ -87,7 +88,7 @@ public class DocumentResource { try { if (file == null || file.fileName() == null) { return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Aucun fichier fourni")) + .entity(ErrorResponse.ofError("Aucun fichier fourni")) .build(); } @@ -135,12 +136,31 @@ public class DocumentResource { } catch (IllegalArgumentException e) { LOG.warnf("Validation échouée pour upload: %s", e.getMessage()); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.ofError(e.getMessage())) .build(); } catch (Exception e) { LOG.errorf(e, "Erreur lors de l'upload du fichier"); return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse("Erreur lors de l'upload: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de l'upload: " + e.getMessage())) + .build(); + } + } + + /** + * Liste les documents de l'utilisateur connecté + * + * @return Liste des documents du membre connecté + */ + @GET + @Path("/mes-documents") + public Response listerMesDocuments() { + try { + List result = documentService.listerMesDocuments(); + return Response.ok(result).build(); + } catch (Exception e) { + LOG.errorf(e, "Erreur lors de la liste des documents du membre connecté"); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(ErrorResponse.ofError("Erreur lors de la récupération des documents: " + e.getMessage())) .build(); } } @@ -159,12 +179,12 @@ public class DocumentResource { return Response.ok(result).build(); } catch (jakarta.ws.rs.NotFoundException e) { return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse("Document non trouvé")) + .entity(ErrorResponse.ofError("Document non trouvé")) .build(); } catch (Exception e) { LOG.errorf(e, "Erreur lors de la recherche du document"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la recherche du document: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la recherche du document: " + e.getMessage())) .build(); } } @@ -184,14 +204,12 @@ public class DocumentResource { return Response.ok().build(); } catch (jakarta.ws.rs.NotFoundException e) { return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse("Document non trouvé")) + .entity(ErrorResponse.ofError("Document non trouvé")) .build(); } catch (Exception e) { LOG.errorf(e, "Erreur lors de l'enregistrement du téléchargement"); return Response.status(Response.Status.BAD_REQUEST) - .entity( - new ErrorResponse( - "Erreur lors de l'enregistrement du téléchargement: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de l'enregistrement du téléchargement: " + e.getMessage())) .build(); } } @@ -211,12 +229,12 @@ public class DocumentResource { return Response.status(Response.Status.CREATED).entity(result).build(); } catch (IllegalArgumentException e) { return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.ofError(e.getMessage())) .build(); } catch (Exception e) { LOG.errorf(e, "Erreur lors de la création de la pièce jointe"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la création de la pièce jointe: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la création de la pièce jointe: " + e.getMessage())) .build(); } } @@ -236,17 +254,10 @@ public class DocumentResource { } catch (Exception e) { LOG.errorf(e, "Erreur lors de la liste des pièces jointes"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la liste des pièces jointes: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la liste des pièces jointes: " + e.getMessage())) .build(); } } - /** Classe interne pour les réponses d'erreur */ - public static class ErrorResponse { - public String error; - - public ErrorResponse(String error) { - this.error = error; - } - } } + diff --git a/src/main/java/dev/lions/unionflow/server/resource/EvenementResource.java b/src/main/java/dev/lions/unionflow/server/resource/EvenementResource.java index 1a32e35..93656ad 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/EvenementResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/EvenementResource.java @@ -8,6 +8,7 @@ import dev.lions.unionflow.server.entity.InscriptionEvenement; import dev.lions.unionflow.server.service.EvenementService; import io.quarkus.panache.common.Page; import io.quarkus.panache.common.Sort; +import io.quarkus.security.PermitAll; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.transaction.Transactional; @@ -53,6 +54,7 @@ public class EvenementResource { /** Endpoint de test public pour vérifier la connectivité */ @GET @Path("/test") + @PermitAll @Operation(summary = "Test de connectivité", description = "Endpoint public pour tester la connectivité") @APIResponse(responseCode = "200", description = "Test réussi") public Response testConnectivity() { @@ -69,6 +71,7 @@ public class EvenementResource { /** Endpoint de debug pour vérifier le chargement des données */ @GET @Path("/count") + @RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "SUPER_ADMIN" }) @Operation(summary = "Compter les événements", description = "Compte le nombre d'événements dans la base") @APIResponse(responseCode = "200", description = "Nombre d'événements") public Response countEvenements() { @@ -194,6 +197,7 @@ public class EvenementResource { /** Liste les événements publics */ @GET @Path("/publics") + @PermitAll @Operation(summary = "Événements publics") public Response evenementsPublics( @QueryParam("page") @DefaultValue("0") int page, @@ -379,6 +383,7 @@ public class EvenementResource { /** Liste des feedbacks d'un événement */ @GET @Path("/{id}/feedbacks") + @RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "PRESIDENT", "SECRETAIRE", "ORGANISATEUR_EVENEMENT", "MEMBRE", "USER" }) @Operation(summary = "Liste des feedbacks de l'événement") @APIResponse(responseCode = "200", description = "Liste des feedbacks") public Response getFeedbacks(@PathParam("id") UUID evenementId) { diff --git a/src/main/java/dev/lions/unionflow/server/resource/HealthResource.java b/src/main/java/dev/lions/unionflow/server/resource/HealthResource.java index 85536a4..84abc3f 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/HealthResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/HealthResource.java @@ -1,5 +1,6 @@ package dev.lions.unionflow.server.resource; +import io.quarkus.security.PermitAll; import jakarta.enterprise.context.ApplicationScoped; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; @@ -15,6 +16,7 @@ import org.eclipse.microprofile.openapi.annotations.tags.Tag; @Path("/api/status") @Produces(MediaType.APPLICATION_JSON) @ApplicationScoped +@PermitAll @Tag(name = "Status", description = "API de statut du serveur") public class HealthResource { diff --git a/src/main/java/dev/lions/unionflow/server/resource/LogsMonitoringResource.java b/src/main/java/dev/lions/unionflow/server/resource/LogsMonitoringResource.java index 0e010e0..56e15d2 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/LogsMonitoringResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/LogsMonitoringResource.java @@ -38,7 +38,7 @@ public class LogsMonitoringResource { */ @POST @Path("/logs/search") - @RolesAllowed({"SUPER_ADMIN", "ADMIN", "MODERATOR"}) + @RolesAllowed({"SUPER_ADMIN", "ADMIN", "MODERATEUR"}) @Operation(summary = "Rechercher dans les logs système", description = "Recherche avec filtres (niveau, source, texte, dates)") public List searchLogs(@Valid LogSearchRequest request) { log.info("POST /api/logs/search - level={}, source={}", request.getLevel(), request.getSource()); @@ -90,7 +90,7 @@ public class LogsMonitoringResource { */ @GET @Path("/monitoring/metrics") - @RolesAllowed({"SUPER_ADMIN", "ADMIN", "MODERATOR", "HR_MANAGER", "ACTIVE_MEMBER"}) + @RolesAllowed({"SUPER_ADMIN", "ADMIN", "MODERATEUR"}) @Operation(summary = "Récupérer les métriques système en temps réel") public SystemMetricsResponse getSystemMetrics() { log.debug("GET /api/monitoring/metrics"); @@ -102,7 +102,7 @@ public class LogsMonitoringResource { */ @GET @Path("/alerts") - @RolesAllowed({"SUPER_ADMIN", "ADMIN", "MODERATOR"}) + @RolesAllowed({"SUPER_ADMIN", "ADMIN", "MODERATEUR"}) @Operation(summary = "Récupérer toutes les alertes actives") public List getActiveAlerts() { log.info("GET /api/alerts"); @@ -114,7 +114,7 @@ public class LogsMonitoringResource { */ @POST @Path("/alerts/{id}/acknowledge") - @RolesAllowed({"SUPER_ADMIN", "ADMIN", "MODERATOR"}) + @RolesAllowed({"SUPER_ADMIN", "ADMIN", "MODERATEUR"}) @Operation(summary = "Acquitter une alerte") public Response acknowledgeAlert(@PathParam("id") UUID id) { log.info("POST /api/alerts/{}/acknowledge", id); @@ -127,7 +127,7 @@ public class LogsMonitoringResource { */ @GET @Path("/alerts/config") - @RolesAllowed({"SUPER_ADMIN", "ADMIN", "MODERATOR"}) + @RolesAllowed({"SUPER_ADMIN", "ADMIN", "MODERATEUR"}) @Operation(summary = "Récupérer la configuration des alertes système") public AlertConfigResponse getAlertConfig() { log.info("GET /api/alerts/config"); diff --git a/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java b/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java index ca4126f..df05161 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java @@ -421,6 +421,55 @@ public class MembreResource { } } + /** + * Liste tous les membres actifs (statut compte = ACTIF). + * Utilisé notamment pour la création de campagnes de cotisations. + */ + @GET + @Path("/actifs") + @RolesAllowed({ "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "TRESORIER" }) + @Operation(summary = "Membres actifs", description = "Liste tous les membres dont le compte est actif") + @APIResponse(responseCode = "200", description = "Liste des membres actifs") + public Response getMembresActifs() { + try { + LOG.info("GET /api/membres/actifs"); + List membres = membreService.listerMembresActifs(); + List membresDTO = membreService.convertToResponseList(membres); + return Response.ok(membresDTO).build(); + } catch (Exception e) { + LOG.errorf(e, "Erreur récupération membres actifs"); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", e.getMessage())).build(); + } + } + + /** + * Liste les membres d'une organisation spécifique (statut ACTIF dans l'organisation). + * Utilisé pour la création de campagnes ciblées. + */ + @GET + @Path("/organisation/{organisationId}") + @RolesAllowed({ "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "TRESORIER" }) + @Operation(summary = "Membres d'une organisation", description = "Liste les membres actifs d'une organisation") + @APIResponse(responseCode = "200", description = "Liste des membres") + public Response getMembresParOrganisation( + @Parameter(description = "UUID de l'organisation") @PathParam("organisationId") UUID organisationId) { + try { + LOG.infof("GET /api/membres/organisation/%s", organisationId); + List liens = + membreOrgRepository.findMembresActifsParOrganisation(organisationId); + List membresDTO = liens.stream() + .filter(mo -> mo.getMembre() != null) + .map(mo -> membreService.convertToResponse(mo.getMembre())) + .collect(java.util.stream.Collectors.toList()); + return Response.ok(membresDTO).build(); + } catch (Exception e) { + LOG.errorf(e, "Erreur récupération membres organisation %s", organisationId); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", e.getMessage())).build(); + } + } + @GET @Path("/recherche") @Operation(summary = "Rechercher des membres par nom ou prénom") @@ -1248,6 +1297,23 @@ public class MembreResource { return Response.ok(Map.of("statut", updated.getStatutMembre())).build(); } + /** + * Trouve un membre par son numéro de membre (ex: MBR-0001). + * Utilisé notamment pour la recherche de parrain lors de l'inscription. + */ + @GET + @Path("/numero/{numeroMembre}") + @RolesAllowed({ "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MODERATEUR", "MEMBRE", "USER" }) + @Operation(summary = "Trouver un membre par son numéro") + @APIResponse(responseCode = "200", description = "Membre trouvé") + @APIResponse(responseCode = "404", description = "Membre non trouvé") + public Response obtenirParNumero(@PathParam("numeroMembre") String numeroMembre) { + LOG.infof("GET /api/membres/numero/%s", numeroMembre); + Membre membre = membreService.trouverParNumeroMembre(numeroMembre) + .orElseThrow(() -> new NotFoundException("Membre non trouvé avec le numéro: " + numeroMembre)); + return Response.ok(membreService.convertToResponse(membre)).build(); + } + /** Résout l'UUID de l'admin connecté depuis le JWT subject. */ private UUID resolveCurrentAdminId() { try { diff --git a/src/main/java/dev/lions/unionflow/server/resource/NotificationResource.java b/src/main/java/dev/lions/unionflow/server/resource/NotificationResource.java index 06c72c9..67887f5 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/NotificationResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/NotificationResource.java @@ -1,5 +1,6 @@ package dev.lions.unionflow.server.resource; +import dev.lions.unionflow.server.api.dto.common.ErrorResponse; import dev.lions.unionflow.server.api.dto.notification.request.CreateNotificationRequest; import dev.lions.unionflow.server.api.dto.notification.request.CreateTemplateNotificationRequest; import dev.lions.unionflow.server.api.dto.notification.response.NotificationResponse; @@ -61,7 +62,7 @@ public class NotificationResource { } catch (Exception e) { LOG.errorf(e, "Erreur liste notifications membre connecté"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur: " + e.getMessage())) .build(); } } @@ -83,7 +84,7 @@ public class NotificationResource { } catch (Exception e) { LOG.errorf(e, "Erreur liste notifications non lues"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur: " + e.getMessage())) .build(); } } @@ -107,12 +108,12 @@ public class NotificationResource { return Response.status(Response.Status.CREATED).entity(result).build(); } catch (IllegalArgumentException e) { return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.ofError(e.getMessage())) .build(); } catch (Exception e) { LOG.errorf(e, "Erreur lors de la création du template"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la création du template: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la création du template: " + e.getMessage())) .build(); } } @@ -136,7 +137,7 @@ public class NotificationResource { } catch (Exception e) { LOG.errorf(e, "Erreur lors de la création de la notification"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la création de la notification: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la création de la notification: " + e.getMessage())) .build(); } } @@ -156,12 +157,12 @@ public class NotificationResource { return Response.ok(result).build(); } catch (jakarta.ws.rs.NotFoundException e) { return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse("Notification non trouvée")) + .entity(ErrorResponse.ofError("Notification non trouvée")) .build(); } catch (Exception e) { LOG.errorf(e, "Erreur lors du marquage de la notification"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors du marquage de la notification: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors du marquage de la notification: " + e.getMessage())) .build(); } } @@ -180,12 +181,12 @@ public class NotificationResource { return Response.ok(result).build(); } catch (jakarta.ws.rs.NotFoundException e) { return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse("Notification non trouvée")) + .entity(ErrorResponse.ofError("Notification non trouvée")) .build(); } catch (Exception e) { LOG.errorf(e, "Erreur lors de la recherche de la notification"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la recherche de la notification: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la recherche de la notification: " + e.getMessage())) .build(); } } @@ -205,7 +206,7 @@ public class NotificationResource { } catch (Exception e) { LOG.errorf(e, "Erreur lors de la liste des notifications"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la liste des notifications: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la liste des notifications: " + e.getMessage())) .build(); } } @@ -225,9 +226,7 @@ public class NotificationResource { } catch (Exception e) { LOG.errorf(e, "Erreur lors de la liste des notifications non lues"); return Response.status(Response.Status.BAD_REQUEST) - .entity( - new ErrorResponse( - "Erreur lors de la liste des notifications non lues: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la liste des notifications non lues: " + e.getMessage())) .build(); } } @@ -246,9 +245,7 @@ public class NotificationResource { } catch (Exception e) { LOG.errorf(e, "Erreur lors de la liste des notifications en attente"); return Response.status(Response.Status.BAD_REQUEST) - .entity( - new ErrorResponse( - "Erreur lors de la liste des notifications en attente: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la liste des notifications en attente: " + e.getMessage())) .build(); } } @@ -269,27 +266,16 @@ public class NotificationResource { return Response.ok(Map.of("notificationsCreees", notificationsCreees)).build(); } catch (IllegalArgumentException e) { return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.ofError(e.getMessage())) .build(); } catch (Exception e) { LOG.errorf(e, "Erreur lors de l'envoi des notifications groupées"); return Response.status(Response.Status.BAD_REQUEST) - .entity( - new ErrorResponse( - "Erreur lors de l'envoi des notifications groupées: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de l'envoi des notifications groupées: " + e.getMessage())) .build(); } } - /** Classe interne pour les réponses d'erreur */ - public static class ErrorResponse { - public String error; - - public ErrorResponse(String error) { - this.error = error; - } - } - /** Classe interne pour les requêtes de notifications groupées (WOU/DRY) */ public static class NotificationGroupeeRequest { public List membreIds; diff --git a/src/main/java/dev/lions/unionflow/server/resource/PaiementResource.java b/src/main/java/dev/lions/unionflow/server/resource/PaiementResource.java index e636855..5639a9e 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/PaiementResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/PaiementResource.java @@ -170,6 +170,24 @@ public class PaiementResource { return Response.status(Response.Status.CREATED).entity(result).build(); } + /** + * Polling du statut d'une IntentionPaiement Wave. + * Si Wave a confirmé le paiement, réconcilie automatiquement la cotisation (PAYEE) et retourne COMPLETEE. + * Le client web appelle cet endpoint toutes les 3 secondes pendant l'affichage du QR code. + * + * @param intentionId UUID de l'intention (clientReference retourné par initier-paiement-en-ligne) + * @return Statut courant + waveLaunchUrl (pour re-générer le QR si besoin) + message + */ + @GET + @Path("/statut-intention/{intentionId}") + @RolesAllowed({ "MEMBRE", "MEMBRE_ACTIF", "ADMIN", "ADMIN_ORGANISATION", "USER" }) + public Response getStatutIntention(@PathParam("intentionId") java.util.UUID intentionId) { + LOG.infof("GET /api/paiements/statut-intention/%s", intentionId); + dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse result = + paiementService.verifierStatutIntention(intentionId); + return Response.ok(result).build(); + } + /** * Déclare un paiement manuel (espèces, virement, chèque). * Le paiement est créé avec le statut EN_ATTENTE_VALIDATION. diff --git a/src/main/java/dev/lions/unionflow/server/resource/PropositionAideResource.java b/src/main/java/dev/lions/unionflow/server/resource/PropositionAideResource.java index d4c5b7d..7eb7ae5 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/PropositionAideResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/PropositionAideResource.java @@ -4,6 +4,7 @@ import dev.lions.unionflow.server.api.dto.solidarite.request.CreatePropositionAi import dev.lions.unionflow.server.api.dto.solidarite.request.UpdatePropositionAideRequest; import dev.lions.unionflow.server.api.dto.solidarite.response.PropositionAideResponse; import dev.lions.unionflow.server.service.PropositionAideService; +import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.validation.Valid; import jakarta.ws.rs.*; @@ -21,6 +22,7 @@ import java.util.List; @Path("/api/propositions-aide") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) +@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MEMBRE", "USER"}) @Tag(name = "Propositions d'aide", description = "Gestion des propositions d'aide solidarité") public class PropositionAideResource { diff --git a/src/main/java/dev/lions/unionflow/server/resource/RoleResource.java b/src/main/java/dev/lions/unionflow/server/resource/RoleResource.java index f800ef2..d8c931b 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/RoleResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/RoleResource.java @@ -3,6 +3,7 @@ package dev.lions.unionflow.server.resource; import dev.lions.unionflow.server.api.dto.role.response.RoleResponse; import dev.lions.unionflow.server.entity.Role; import dev.lions.unionflow.server.service.RoleService; +import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; @@ -19,6 +20,7 @@ import java.util.stream.Collectors; */ @Path("/api/roles") @Produces(MediaType.APPLICATION_JSON) +@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"}) @Tag(name = "Rôles", description = "Gestion des rôles et permissions") public class RoleResource { diff --git a/src/main/java/dev/lions/unionflow/server/resource/SouscriptionResource.java b/src/main/java/dev/lions/unionflow/server/resource/SouscriptionResource.java index 1e8e6c6..b37fbd5 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/SouscriptionResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/SouscriptionResource.java @@ -18,6 +18,7 @@ import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; @@ -158,6 +159,36 @@ public class SouscriptionResource { // ── SuperAdmin ──────────────────────────────────────────────────────────── + /** + * Liste toutes les souscriptions (SuperAdmin dashboard), avec filtre optionnel par org. + */ + @GET + @Path("/admin/toutes") + @RolesAllowed({"SUPER_ADMIN"}) + public Response getSouscriptionsToutes( + @QueryParam("organisationId") UUID organisationId, + @QueryParam("page") @DefaultValue("0") int page, + @QueryParam("size") @DefaultValue("1000") int size) { + LOG.debug("GET /api/souscriptions/admin/toutes"); + List liste = souscriptionService.listerToutes(organisationId, page, size); + return Response.ok(liste).build(); + } + + /** + * Retourne la souscription active d'une organisation (SuperAdmin). + */ + @GET + @Path("/admin/organisation/{organisationId}/active") + @RolesAllowed({"SUPER_ADMIN"}) + public Response getActiveParOrganisation(@PathParam("organisationId") UUID organisationId) { + LOG.debugf("GET /api/souscriptions/admin/organisation/%s/active", organisationId); + SouscriptionStatutResponse resp = souscriptionService.obtenirActiveParOrganisation(organisationId); + if (resp == null) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + return Response.ok(resp).build(); + } + /** * Liste les souscriptions en attente de validation SuperAdmin (statut PAIEMENT_CONFIRME). */ diff --git a/src/main/java/dev/lions/unionflow/server/resource/SystemResource.java b/src/main/java/dev/lions/unionflow/server/resource/SystemResource.java index f21d6c9..38a80f4 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/SystemResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/SystemResource.java @@ -38,7 +38,7 @@ public class SystemResource { */ @GET @Path("/config") - @RolesAllowed({"SUPER_ADMIN", "ADMIN", "MODERATOR"}) + @RolesAllowed({"SUPER_ADMIN", "ADMIN", "MODERATEUR"}) @Operation(summary = "Récupérer la configuration système", description = "Retourne la configuration système complète") public SystemConfigResponse getSystemConfig() { log.info("GET /api/system/config"); @@ -62,7 +62,7 @@ public class SystemResource { */ @GET @Path("/cache/stats") - @RolesAllowed({"SUPER_ADMIN", "ADMIN", "MODERATOR"}) + @RolesAllowed({"SUPER_ADMIN", "ADMIN", "MODERATEUR"}) @Operation(summary = "Récupérer les statistiques du cache système") public CacheStatsResponse getCacheStats() { log.info("GET /api/system/cache/stats"); @@ -111,7 +111,7 @@ public class SystemResource { */ @GET @Path("/metrics") - @RolesAllowed({"SUPER_ADMIN", "ADMIN", "MODERATOR"}) + @RolesAllowed({"SUPER_ADMIN", "ADMIN", "MODERATEUR"}) @Operation( summary = "Récupérer les métriques système en temps réel", description = "Retourne toutes les métriques système (CPU, RAM, disque, utilisateurs actifs, etc.)" diff --git a/src/main/java/dev/lions/unionflow/server/resource/WaveRedirectResource.java b/src/main/java/dev/lions/unionflow/server/resource/WaveRedirectResource.java index 37f925c..a0e1bb1 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/WaveRedirectResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/WaveRedirectResource.java @@ -4,12 +4,13 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import dev.lions.unionflow.server.api.dto.mutuelle.epargne.TransactionEpargneRequest; import dev.lions.unionflow.server.api.enums.mutuelle.epargne.TypeTransactionEpargne; -import dev.lions.unionflow.server.api.enums.paiement.StatutIntentionPaiement; -import dev.lions.unionflow.server.entity.Cotisation; import dev.lions.unionflow.server.entity.IntentionPaiement; import dev.lions.unionflow.server.repository.IntentionPaiementRepository; +import dev.lions.unionflow.server.service.PaiementService; import dev.lions.unionflow.server.service.mutuelle.epargne.TransactionEpargneService; import jakarta.annotation.security.PermitAll; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; import jakarta.inject.Inject; import jakarta.transaction.Transactional; import jakarta.ws.rs.GET; @@ -21,7 +22,6 @@ import org.jboss.logging.Logger; import java.math.BigDecimal; import java.net.URI; -import java.time.LocalDateTime; import java.util.UUID; /** @@ -50,18 +50,65 @@ public class WaveRedirectResource { @Inject TransactionEpargneService transactionEpargneService; + @Inject + PaiementService paiementService; + @GET @Path("/success") @Transactional public Response success(@QueryParam("ref") String ref) { - LOG.infof("Wave redirect success, ref=%s", ref); - if (mockEnabled && ref != null && !ref.isBlank()) { - applyMockCompletion(ref); + LOG.infof("Wave redirect success (mobile), ref=%s", ref); + if (ref != null && !ref.isBlank()) { + applyCompletion(ref); } String location = buildDeepLink("success", ref); return Response.seeOther(URI.create(location)).build(); } + /** + * Endpoint de redirection Wave pour le flux web QR code. + * Appelé par Wave sur le téléphone du membre après paiement confirmé. + * Marque la cotisation PAYEE et affiche une page HTML de confirmation. + */ + @GET + @Path("/web-success") + @Produces(MediaType.TEXT_HTML) + @Transactional + public Response webSuccess(@QueryParam("ref") String ref) { + LOG.infof("Wave redirect web-success, ref=%s", ref); + if (ref != null && !ref.isBlank()) { + applyCompletion(ref); + } + String html = """ + + + + Paiement confirmé + + + +

+
+

Paiement confirmé !

+

Votre cotisation a été enregistrée avec succès.

+

+ Vous pouvez fermer cette page et revenir sur UnionFlow. +

+
+ + + """; + return Response.ok(html).build(); + } + @GET @Path("/error") public Response error(@QueryParam("ref") String ref) { @@ -85,40 +132,31 @@ public class WaveRedirectResource { if (ref == null || ref.isBlank()) { return Response.status(Response.Status.BAD_REQUEST).entity("ref requis").build(); } - applyMockCompletion(ref); + applyCompletion(ref); return Response.seeOther(URI.create(buildDeepLink("success", ref))).build(); } - /** En mode mock : marque l'intention COMPLETEE et les cotisations liées PAYEE (simulation Wave). */ - private void applyMockCompletion(String ref) { + /** + * Marque l'intention comme complétée et réconcilie les cotisations/dépôts liés. + * Délègue au PaiementService pour les cotisations ; gère les dépôts épargne localement. + */ + private void applyCompletion(String ref) { try { UUID intentionId = UUID.fromString(ref.trim()); IntentionPaiement intention = intentionPaiementRepository.findById(intentionId); if (intention == null) { - LOG.warnf("Intention non trouvée pour mock: %s", ref); + LOG.warnf("Intention non trouvée: %s", ref); return; } - intention.setStatut(StatutIntentionPaiement.COMPLETEE); - intention.setDateCompletion(LocalDateTime.now()); - intentionPaiementRepository.persist(intention); + // Gérer les dépôts épargne (non couverts par PaiementService) String objetsCibles = intention.getObjetsCibles(); if (objetsCibles != null && !objetsCibles.isBlank()) { JsonNode arr = OBJECT_MAPPER.readTree(objetsCibles); if (arr.isArray()) { for (JsonNode node : arr) { - if (node.has("type") && "COTISATION".equals(node.get("type").asText()) && node.has("id")) { - UUID cotisationId = UUID.fromString(node.get("id").asText()); - Cotisation cotisation = intentionPaiementRepository.getEntityManager().find(Cotisation.class, cotisationId); - if (cotisation != null) { - BigDecimal montant = node.has("montant") ? new BigDecimal(node.get("montant").asText()) : cotisation.getMontantDu(); - cotisation.setMontantPaye(montant); - cotisation.setStatut("PAYEE"); - cotisation.setDatePaiement(LocalDateTime.now()); - intentionPaiementRepository.getEntityManager().merge(cotisation); - LOG.infof("Mock Wave: cotisation %s marquée PAYEE", cotisationId); - } - } else if (node.has("type") && "DEPOT_EPARGNE".equals(node.get("type").asText()) && node.has("compteId") && node.has("montant")) { + if ("DEPOT_EPARGNE".equals(node.path("type").asText()) + && node.has("compteId") && node.has("montant")) { String compteId = node.get("compteId").asText(); BigDecimal montant = new BigDecimal(node.get("montant").asText()); TransactionEpargneRequest req = TransactionEpargneRequest.builder() @@ -128,14 +166,17 @@ public class WaveRedirectResource { .motif("Dépôt via Wave (mobile money)") .build(); transactionEpargneService.executerTransaction(req); - LOG.infof("Mock Wave: dépôt épargne %s XOF sur compte %s", montant, compteId); + LOG.infof("Wave: dépôt épargne %s XOF sur compte %s", montant, compteId); } } } } - LOG.infof("Mock Wave: intention %s complétée (validation simulée)", ref); + + // Déléguer la complétion cotisations au service + paiementService.completerIntention(intention, null); + LOG.infof("Wave: intention %s complétée", ref); } catch (Exception e) { - LOG.errorf(e, "Mock Wave: erreur applyMockCompletion ref=%s", ref); + LOG.errorf(e, "Wave: erreur applyCompletion ref=%s", ref); } } diff --git a/src/main/java/dev/lions/unionflow/server/resource/WaveResource.java b/src/main/java/dev/lions/unionflow/server/resource/WaveResource.java index 00e56b9..a07d37b 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/WaveResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/WaveResource.java @@ -1,5 +1,6 @@ package dev.lions.unionflow.server.resource; +import dev.lions.unionflow.server.api.dto.common.ErrorResponse; import dev.lions.unionflow.server.api.dto.wave.CompteWaveDTO; import dev.lions.unionflow.server.api.dto.wave.TransactionWaveDTO; import dev.lions.unionflow.server.api.enums.wave.StatutTransactionWave; @@ -56,12 +57,12 @@ public class WaveResource { return Response.status(Response.Status.CREATED).entity(result).build(); } catch (IllegalArgumentException e) { return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse(e.getMessage())) + .entity(ErrorResponse.ofError(e.getMessage())) .build(); } catch (Exception e) { LOG.errorf(e, "Erreur lors de la création du compte Wave"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la création du compte Wave: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la création du compte Wave: " + e.getMessage())) .build(); } } @@ -83,12 +84,12 @@ public class WaveResource { return Response.ok(result).build(); } catch (jakarta.ws.rs.NotFoundException e) { return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse("Compte Wave non trouvé")) + .entity(ErrorResponse.ofError("Compte Wave non trouvé")) .build(); } catch (Exception e) { LOG.errorf(e, "Erreur lors de la mise à jour du compte Wave"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la mise à jour du compte Wave: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la mise à jour du compte Wave: " + e.getMessage())) .build(); } } @@ -109,12 +110,12 @@ public class WaveResource { return Response.ok(result).build(); } catch (jakarta.ws.rs.NotFoundException e) { return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse("Compte Wave non trouvé")) + .entity(ErrorResponse.ofError("Compte Wave non trouvé")) .build(); } catch (Exception e) { LOG.errorf(e, "Erreur lors de la vérification du compte Wave"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la vérification du compte Wave: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la vérification du compte Wave: " + e.getMessage())) .build(); } } @@ -133,12 +134,12 @@ public class WaveResource { return Response.ok(result).build(); } catch (jakarta.ws.rs.NotFoundException e) { return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse("Compte Wave non trouvé")) + .entity(ErrorResponse.ofError("Compte Wave non trouvé")) .build(); } catch (Exception e) { LOG.errorf(e, "Erreur lors de la recherche du compte Wave"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la recherche du compte Wave: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la recherche du compte Wave: " + e.getMessage())) .build(); } } @@ -156,14 +157,14 @@ public class WaveResource { CompteWaveDTO result = waveService.trouverCompteWaveParTelephone(numeroTelephone); if (result == null) { return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse("Compte Wave non trouvé")) + .entity(ErrorResponse.ofError("Compte Wave non trouvé")) .build(); } return Response.ok(result).build(); } catch (Exception e) { LOG.errorf(e, "Erreur lors de la recherche du compte Wave"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la recherche du compte Wave: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la recherche du compte Wave: " + e.getMessage())) .build(); } } @@ -183,7 +184,7 @@ public class WaveResource { } catch (Exception e) { LOG.errorf(e, "Erreur lors de la liste des comptes Wave"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la liste des comptes Wave: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la liste des comptes Wave: " + e.getMessage())) .build(); } } @@ -207,7 +208,7 @@ public class WaveResource { } catch (Exception e) { LOG.errorf(e, "Erreur lors de la création de la transaction Wave"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la création de la transaction Wave: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la création de la transaction Wave: " + e.getMessage())) .build(); } } @@ -229,14 +230,12 @@ public class WaveResource { return Response.ok(result).build(); } catch (jakarta.ws.rs.NotFoundException e) { return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse("Transaction Wave non trouvée")) + .entity(ErrorResponse.ofError("Transaction Wave non trouvée")) .build(); } catch (Exception e) { LOG.errorf(e, "Erreur lors de la mise à jour du statut de la transaction Wave"); return Response.status(Response.Status.BAD_REQUEST) - .entity( - new ErrorResponse( - "Erreur lors de la mise à jour du statut de la transaction Wave: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la mise à jour du statut de la transaction Wave: " + e.getMessage())) .build(); } } @@ -255,22 +254,15 @@ public class WaveResource { return Response.ok(result).build(); } catch (jakarta.ws.rs.NotFoundException e) { return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse("Transaction Wave non trouvée")) + .entity(ErrorResponse.ofError("Transaction Wave non trouvée")) .build(); } catch (Exception e) { LOG.errorf(e, "Erreur lors de la recherche de la transaction Wave"); return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Erreur lors de la recherche de la transaction Wave: " + e.getMessage())) + .entity(ErrorResponse.ofError("Erreur lors de la recherche de la transaction Wave: " + e.getMessage())) .build(); } } - /** Classe interne pour les réponses d'erreur */ - public static class ErrorResponse { - public String error; - - public ErrorResponse(String error) { - this.error = error; - } - } } + diff --git a/src/main/java/dev/lions/unionflow/server/scheduler/MemberLifecycleScheduler.java b/src/main/java/dev/lions/unionflow/server/scheduler/MemberLifecycleScheduler.java new file mode 100644 index 0000000..b678eb6 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/scheduler/MemberLifecycleScheduler.java @@ -0,0 +1,63 @@ +package dev.lions.unionflow.server.scheduler; + +import dev.lions.unionflow.server.service.MemberLifecycleService; +import io.quarkus.scheduler.Scheduled; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.jboss.logging.Logger; + +/** + * Planificateur automatique pour le cycle de vie des membres. + * + *

Tâches exécutées périodiquement : + *

+ * + *

Utilise le module {@code quarkus-scheduler} (déjà dans le pom.xml). + * En production, les expressions cron peuvent être configurées via : + * {@code unionflow.lifecycle.invitation.expiry.cron} dans application.properties. + */ +@ApplicationScoped +public class MemberLifecycleScheduler { + + private static final Logger LOG = Logger.getLogger(MemberLifecycleScheduler.class); + + @Inject + MemberLifecycleService lifecycleService; + + /** + * Envoie des rappels pour les invitations qui expirent dans les 24 prochaines heures. + * Exécuté toutes les heures. + */ + @Scheduled(cron = "${unionflow.lifecycle.reminder.cron:0 0 * * * ?}", + identity = "membre-invitation-reminder") + void rappelerInvitationsExpirantBientot() { + try { + int count = lifecycleService.envoyerRappelsInvitation(); + if (count > 0) { + LOG.infof("[Scheduler] Rappels d'invitation envoyés : %d", count); + } + } catch (Exception e) { + LOG.errorf(e, "[Scheduler] Erreur lors de l'envoi des rappels d'invitation"); + } + } + + /** + * Expire les invitations dont la date limite est dépassée. + * Exécuté chaque nuit à 02:00. + */ + @Scheduled(cron = "${unionflow.lifecycle.expiry.cron:0 0 2 * * ?}", + identity = "membre-invitation-expiry") + void expirerInvitations() { + try { + int count = lifecycleService.expirerInvitations(); + if (count > 0) { + LOG.infof("[Scheduler] Invitations expirées traitées : %d", count); + } + } catch (Exception e) { + LOG.errorf(e, "[Scheduler] Erreur lors de l'expiration des invitations"); + } + } +} diff --git a/src/main/java/dev/lions/unionflow/server/security/ModuleAccessFilter.java b/src/main/java/dev/lions/unionflow/server/security/ModuleAccessFilter.java new file mode 100644 index 0000000..5f5b4f2 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/security/ModuleAccessFilter.java @@ -0,0 +1,106 @@ +package dev.lions.unionflow.server.security; + +import dev.lions.unionflow.server.service.OrganisationModuleService; +import jakarta.annotation.Priority; +import jakarta.inject.Inject; +import jakarta.ws.rs.Priorities; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.container.ResourceInfo; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.Provider; +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.Map; +import java.util.UUID; +import org.jboss.logging.Logger; + +/** + * Filtre JAX-RS qui applique le contrôle d'accès par module métier. + * + *

S'exécute après l'authentification (AUTHORIZATION + 10) pour laisser + * Quarkus OIDC et {@code @RolesAllowed} s'exécuter en premier. + * + *

Le filtre : + *

    + *
  1. Détecte l'annotation {@link RequiresModule} sur la méthode ou la classe.
  2. + *
  3. Extrait l'organisation active depuis le header {@code X-Active-Organisation-Id}.
  4. + *
  5. Vérifie via {@link OrganisationModuleService} que le module est activé.
  6. + *
  7. Retourne HTTP 403 avec un message explicite si le module est absent.
  8. + *
+ * + *

Header attendu : {@code X-Active-Organisation-Id: } + */ +@Provider +@Priority(Priorities.AUTHORIZATION + 10) +public class ModuleAccessFilter implements ContainerRequestFilter { + + private static final Logger LOG = Logger.getLogger(ModuleAccessFilter.class); + + /** Nom du header HTTP transportant l'UUID de l'organisation active. */ + public static final String HEADER_ACTIVE_ORG = "X-Active-Organisation-Id"; + + @Inject + OrganisationModuleService organisationModuleService; + + @Context + ResourceInfo resourceInfo; + + @Override + public void filter(ContainerRequestContext requestContext) throws IOException { + // 1. Détecter @RequiresModule sur la méthode, puis sur la classe + Method method = resourceInfo.getResourceMethod(); + RequiresModule annotation = method != null ? method.getAnnotation(RequiresModule.class) : null; + if (annotation == null && resourceInfo.getResourceClass() != null) { + annotation = resourceInfo.getResourceClass().getAnnotation(RequiresModule.class); + } + + if (annotation == null) { + // Aucune annotation — laisser passer + return; + } + + String moduleRequis = annotation.value().toUpperCase(); + + // 2. Extraire l'organisation active depuis le header + String orgIdHeader = requestContext.getHeaderString(HEADER_ACTIVE_ORG); + if (orgIdHeader == null || orgIdHeader.isBlank()) { + LOG.warnf("@RequiresModule(%s) — header %s absent, accès refusé", moduleRequis, HEADER_ACTIVE_ORG); + requestContext.abortWith(buildForbiddenResponse( + "Le header " + HEADER_ACTIVE_ORG + " est requis pour accéder à ce module.", + moduleRequis)); + return; + } + + UUID organisationId; + try { + organisationId = UUID.fromString(orgIdHeader.trim()); + } catch (IllegalArgumentException e) { + LOG.warnf("@RequiresModule(%s) — header %s invalide : %s", moduleRequis, HEADER_ACTIVE_ORG, orgIdHeader); + requestContext.abortWith(buildForbiddenResponse( + "La valeur du header " + HEADER_ACTIVE_ORG + " n'est pas un UUID valide.", + moduleRequis)); + return; + } + + // 3. Vérifier l'activation du module + if (!organisationModuleService.isModuleActif(organisationId, moduleRequis)) { + String message = annotation.message().isBlank() + ? "Le module '" + moduleRequis + "' n'est pas activé pour cette organisation." + : annotation.message(); + LOG.infof("@RequiresModule(%s) — module inactif pour l'organisation %s", moduleRequis, organisationId); + requestContext.abortWith(buildForbiddenResponse(message, moduleRequis)); + } + } + + private Response buildForbiddenResponse(String message, String module) { + return Response.status(Response.Status.FORBIDDEN) + .entity(Map.of( + "error", "MODULE_NOT_ACTIVE", + "message", message, + "module", module + )) + .build(); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/security/OrganisationContextFilter.java b/src/main/java/dev/lions/unionflow/server/security/OrganisationContextFilter.java new file mode 100644 index 0000000..87b3f0c --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/security/OrganisationContextFilter.java @@ -0,0 +1,141 @@ +package dev.lions.unionflow.server.security; + +import dev.lions.unionflow.server.entity.Membre; +import dev.lions.unionflow.server.entity.MembreOrganisation; +import dev.lions.unionflow.server.entity.Organisation; +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.annotation.Priority; +import jakarta.inject.Inject; +import jakarta.ws.rs.Priorities; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.ext.Provider; +import java.io.IOException; +import java.util.Optional; +import java.util.UUID; +import org.jboss.logging.Logger; + +/** + * Filtre JAX-RS qui résout l'organisation active pour chaque requête. + * + *

Lit le header {@code X-Active-Organisation-Id}, valide que l'utilisateur + * connecté est bien membre actif de cette organisation, puis peuple + * {@link OrganisationContextHolder} pour les services aval. + * + *

Si le header est absent, le contexte reste non résolu (les endpoints + * qui ne nécessitent pas de contexte org continuent de fonctionner normalement). + * Si le header est présent mais invalide ou si l'utilisateur n'est pas membre, + * la requête est rejetée avec HTTP 403. + * + *

Priorité : AUTHORIZATION + 5 (après auth, avant @RequiresModule à +10). + */ +@Provider +@Priority(Priorities.AUTHORIZATION + 5) +public class OrganisationContextFilter implements ContainerRequestFilter { + + private static final Logger LOG = Logger.getLogger(OrganisationContextFilter.class); + public static final String HEADER_ACTIVE_ORG = ModuleAccessFilter.HEADER_ACTIVE_ORG; + + @Inject + OrganisationContextHolder contextHolder; + + @Inject + SecurityIdentity securityIdentity; + + @Inject + MembreRepository membreRepository; + + @Inject + MembreOrganisationRepository membreOrganisationRepository; + + @Inject + OrganisationRepository organisationRepository; + + @Override + public void filter(ContainerRequestContext requestContext) throws IOException { + String orgIdHeader = requestContext.getHeaderString(HEADER_ACTIVE_ORG); + + if (orgIdHeader == null || orgIdHeader.isBlank()) { + // Pas de contexte org — les endpoints sans contexte fonctionnent normalement + return; + } + + // Valider le format UUID + UUID organisationId; + try { + organisationId = UUID.fromString(orgIdHeader.trim()); + } catch (IllegalArgumentException e) { + LOG.warnf("Header %s invalide : %s", HEADER_ACTIVE_ORG, orgIdHeader); + requestContext.abortWith(jakarta.ws.rs.core.Response + .status(jakarta.ws.rs.core.Response.Status.BAD_REQUEST) + .entity(java.util.Map.of("error", "INVALID_ORGANISATION_ID", + "message", "Le header X-Active-Organisation-Id n'est pas un UUID valide.")) + .build()); + return; + } + + // Résoudre l'organisation + Optional orgOpt = organisationRepository.findByIdOptional(organisationId); + if (orgOpt.isEmpty()) { + LOG.warnf("Organisation introuvable : %s", organisationId); + requestContext.abortWith(jakarta.ws.rs.core.Response + .status(jakarta.ws.rs.core.Response.Status.NOT_FOUND) + .entity(java.util.Map.of("error", "ORGANISATION_NOT_FOUND", + "message", "Organisation introuvable.")) + .build()); + return; + } + + Organisation organisation = orgOpt.get(); + + // Vérifier l'appartenance (skip pour SUPERADMIN qui a accès à tout) + if (!securityIdentity.isAnonymous()) { + boolean isSuperAdmin = securityIdentity.getRoles().contains("SUPERADMIN") + || securityIdentity.getRoles().contains("SUPER_ADMIN") + || securityIdentity.getRoles().contains("ADMIN"); + + if (!isSuperAdmin) { + String email = securityIdentity.getPrincipal().getName(); + Optional membreOpt = membreRepository.findByEmail(email); + + if (membreOpt.isPresent()) { + Optional membreOrgOpt = membreOrganisationRepository + .findByMembreIdAndOrganisationId(membreOpt.get().getId(), organisationId); + + if (membreOrgOpt.isEmpty()) { + LOG.warnf("Utilisateur %s n'est pas membre de l'organisation %s", email, organisationId); + requestContext.abortWith(jakarta.ws.rs.core.Response + .status(jakarta.ws.rs.core.Response.Status.FORBIDDEN) + .entity(java.util.Map.of("error", "NOT_MEMBER_OF_ORGANISATION", + "message", "Vous n'êtes pas membre de cette organisation.")) + .build()); + return; + } + + // Vérifier que le membre est actif + MembreOrganisation membreOrg = membreOrgOpt.get(); + String statut = membreOrg.getStatutMembre() != null + ? membreOrg.getStatutMembre().name() : ""; + if (!"ACTIF".equals(statut)) { + LOG.warnf("Membre %s statut non-actif (%s) dans l'organisation %s", email, statut, organisationId); + requestContext.abortWith(jakarta.ws.rs.core.Response + .status(jakarta.ws.rs.core.Response.Status.FORBIDDEN) + .entity(java.util.Map.of("error", "MEMBER_NOT_ACTIVE", + "message", "Votre adhésion à cette organisation n'est pas active.")) + .build()); + return; + } + } + } + } + + // Peupler le contexte + contextHolder.setOrganisationId(organisationId); + contextHolder.setOrganisation(organisation); + contextHolder.setResolved(true); + LOG.debugf("Contexte org résolu : %s (%s)", organisation.getNom(), organisationId); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/security/OrganisationContextHolder.java b/src/main/java/dev/lions/unionflow/server/security/OrganisationContextHolder.java new file mode 100644 index 0000000..6061d39 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/security/OrganisationContextHolder.java @@ -0,0 +1,61 @@ +package dev.lions.unionflow.server.security; + +import dev.lions.unionflow.server.entity.Organisation; +import jakarta.enterprise.context.RequestScoped; +import java.util.UUID; + +/** + * Holder request-scoped contenant l'organisation active résolue pour la requête courante. + * + *

Peuplé par {@link OrganisationContextFilter} à partir du header + * {@code X-Active-Organisation-Id}. Utilisé par les services métier pour + * scoper toutes les opérations à l'organisation active. + * + *

Exemple d'utilisation dans un service : + *

{@code
+ *   @Inject OrganisationContextHolder orgContext;
+ *
+ *   public List listTontines() {
+ *       UUID orgId = orgContext.getOrganisationId();
+ *       return tontineRepository.findByOrganisationId(orgId);
+ *   }
+ * }
+ */ +@RequestScoped +public class OrganisationContextHolder { + + private UUID organisationId; + private Organisation organisation; + private boolean resolved = false; + + public UUID getOrganisationId() { + return organisationId; + } + + public void setOrganisationId(UUID organisationId) { + this.organisationId = organisationId; + } + + public Organisation getOrganisation() { + return organisation; + } + + public void setOrganisation(Organisation organisation) { + this.organisation = organisation; + } + + public boolean isResolved() { + return resolved; + } + + public void setResolved(boolean resolved) { + this.resolved = resolved; + } + + /** + * Retourne true si un contexte d'organisation est disponible. + */ + public boolean hasContext() { + return resolved && organisationId != null; + } +} diff --git a/src/main/java/dev/lions/unionflow/server/security/RequiresModule.java b/src/main/java/dev/lions/unionflow/server/security/RequiresModule.java new file mode 100644 index 0000000..235fbba --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/security/RequiresModule.java @@ -0,0 +1,42 @@ +package dev.lions.unionflow.server.security; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation déclarative pour le contrôle d'accès par module métier. + * + *

Placée sur un endpoint JAX-RS, elle vérifie que l'organisation active + * du contexte de la requête a bien le module requis activé. + * + *

Le module est déterminé par le type d'organisation (Option C) et non + * par le plan tarifaire. + * + *

Exemple d'utilisation : + *

{@code
+ *   @GET
+ *   @Path("/cycles")
+ *   @RequiresModule("TONTINE")
+ *   @RolesAllowed({"ADMIN", "TONTINE_MANAGER"})
+ *   public Response getCycles() { ... }
+ * }
+ */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequiresModule { + + /** + * Nom du module requis (ex: "TONTINE", "CREDIT", "EPARGNE", "AGRICULTURE"). + * Doit correspondre à l'une des valeurs de {@link dev.lions.unionflow.server.service.OrganisationModuleService#MODULES_COMMUNS} + * ou des modules métier définis par type d'organisation. + */ + String value(); + + /** + * Message d'erreur personnalisé affiché si le module n'est pas actif. + * Par défaut, un message générique est généré. + */ + String message() default ""; +} diff --git a/src/main/java/dev/lions/unionflow/server/security/RoleConstant.java b/src/main/java/dev/lions/unionflow/server/security/RoleConstant.java new file mode 100644 index 0000000..e0d6728 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/security/RoleConstant.java @@ -0,0 +1,42 @@ +package dev.lions.unionflow.server.security; + +/** + * Constantes centralisées pour les rôles UnionFlow. + * + *

Utiliser ces constantes dans {@code @RolesAllowed} pour éviter + * les fautes de frappe et garantir la cohérence entre ressources.

+ */ +public final class RoleConstant { + + // ── Rôles système ───────────────────────────────────────────────────────── + public static final String SUPER_ADMIN = "SUPER_ADMIN"; + public static final String ADMIN = "ADMIN"; + + // ── Rôles organisation ──────────────────────────────────────────────────── + public static final String ADMIN_ORGANISATION = "ADMIN_ORGANISATION"; + public static final String PRESIDENT = "PRESIDENT"; + public static final String VICE_PRESIDENT = "VICE_PRESIDENT"; + public static final String SECRETAIRE = "SECRETAIRE"; + public static final String TRESORIER = "TRESORIER"; + public static final String MODERATEUR = "MODERATEUR"; + + // ── Rôles membres ───────────────────────────────────────────────────────── + public static final String MEMBRE = "MEMBRE"; + public static final String USER = "USER"; + + // ── Rôles fonctionnels (événements) ─────────────────────────────────────── + public static final String ORGANISATEUR_EVENEMENT = "ORGANISATEUR_EVENEMENT"; + + // ── Rôles modules spécialisés ───────────────────────────────────────────── + public static final String TONTINE_RESP = "TONTINE_RESP"; + public static final String MUTUELLE_RESP = "MUTUELLE_RESP"; + public static final String VOTE_RESP = "VOTE_RESP"; + public static final String COOP_RESP = "COOP_RESP"; + public static final String ONG_RESP = "ONG_RESP"; + public static final String CULTE_RESP = "CULTE_RESP"; + public static final String REGISTRE_RESP = "REGISTRE_RESP"; + public static final String AGRI_RESP = "AGRI_RESP"; + public static final String COLLECTE_RESP = "COLLECTE_RESP"; + + private RoleConstant() {} +} diff --git a/src/main/java/dev/lions/unionflow/server/service/CotisationAutoGenerationService.java b/src/main/java/dev/lions/unionflow/server/service/CotisationAutoGenerationService.java new file mode 100644 index 0000000..58cdbce --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/service/CotisationAutoGenerationService.java @@ -0,0 +1,154 @@ +package dev.lions.unionflow.server.service; + +import dev.lions.unionflow.server.entity.BaremeCotisationRole; +import dev.lions.unionflow.server.entity.Cotisation; +import dev.lions.unionflow.server.entity.MembreOrganisation; +import dev.lions.unionflow.server.entity.Organisation; +import dev.lions.unionflow.server.entity.ParametresCotisationOrganisation; +import dev.lions.unionflow.server.repository.BaremeCotisationRoleRepository; +import dev.lions.unionflow.server.repository.CotisationRepository; +import dev.lions.unionflow.server.repository.MembreOrganisationRepository; +import dev.lions.unionflow.server.repository.ParametresCotisationOrganisationRepository; +import io.quarkus.scheduler.Scheduled; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; + +/** + * Génération automatique mensuelle des cotisations pour les organisations + * ayant activé {@code generationAutomatiqueActivee} dans leurs paramètres. + * + *

Le job s'exécute le 1er de chaque mois à 1h00. Pour chaque membre actif, + * il détermine le montant selon le barème du rôle fonctionnel de ce membre dans + * l'organisation, ou utilise le montant par défaut si aucun barème n'est configuré. + * + *

Les cotisations déjà générées pour le même mois/année sont ignorées (idempotence). + */ +@ApplicationScoped +@Slf4j +public class CotisationAutoGenerationService { + + @Inject + ParametresCotisationOrganisationRepository parametresRepository; + + @Inject + MembreOrganisationRepository membreOrganisationRepository; + + @Inject + BaremeCotisationRoleRepository baremeRepository; + + @Inject + CotisationRepository cotisationRepository; + + /** + * Point d'entrée du job planifié : 1er de chaque mois à 01:00. + */ + @Scheduled(cron = "0 0 1 1 * ?") + @Transactional + public void genererCotisationsMensuelles() { + LocalDate aujourd_hui = LocalDate.now(); + int annee = aujourd_hui.getYear(); + int mois = aujourd_hui.getMonthValue(); + log.info("=== Génération automatique cotisations {}/{} ===", mois, annee); + + List paramsList = + parametresRepository.findAvecGenerationAutomatiqueActivee(); + + if (paramsList.isEmpty()) { + log.info("Aucune organisation avec génération automatique activée."); + return; + } + + int totalCrees = 0; + int totalIgnores = 0; + + for (ParametresCotisationOrganisation params : paramsList) { + Organisation org = params.getOrganisation(); + int[] result = genererPourOrganisation(org, params, annee, mois); + totalCrees += result[0]; + totalIgnores += result[1]; + } + + log.info("=== Génération terminée : {} créées, {} ignorées (déjà existantes) ===", + totalCrees, totalIgnores); + } + + /** + * Génère les cotisations pour une organisation donnée. + * + * @return tableau [nbCreees, nbIgnorees] + */ + int[] genererPourOrganisation(Organisation org, + ParametresCotisationOrganisation params, + int annee, int mois) { + List membresActifs = + membreOrganisationRepository.findMembresActifsParOrganisation(org.getId()); + + int crees = 0; + int ignores = 0; + String devise = params.getDevise() != null ? params.getDevise() : "XOF"; + + for (MembreOrganisation mo : membresActifs) { + if (mo.getMembre() == null) continue; + + // Idempotence : ne pas recréer si déjà générée ce mois + if (cotisationRepository.existsByMembreOrganisationAnneeAndMois( + mo.getMembre().getId(), org.getId(), annee, mois)) { + ignores++; + continue; + } + + BigDecimal montant = resoudreMontantMensuel(org.getId(), mo.getRoleOrg(), params); + if (montant == null || montant.compareTo(BigDecimal.ZERO) == 0) { + log.debug("Org {} membre {} : montant 0, cotisation ignorée", org.getNom(), mo.getMembre().getId()); + ignores++; + continue; + } + + LocalDate echeance = LocalDate.of(annee, mois, 28); + Cotisation cotisation = Cotisation.builder() + .typeCotisation("MENSUELLE") + .libelle("Cotisation mensuelle " + String.format("%02d/%d", mois, annee)) + .description("Génération automatique — " + org.getNom()) + .montantDu(montant) + .montantPaye(BigDecimal.ZERO) + .codeDevise(devise) + .statut("EN_ATTENTE") + .dateEcheance(echeance) + .annee(annee) + .mois(mois) + .recurrente(true) + .membre(mo.getMembre()) + .organisation(org) + .build(); + cotisation.setNumeroReference(Cotisation.genererNumeroReference()); + cotisationRepository.persist(cotisation); + crees++; + } + + log.info("Org '{}' [{}/{}] : {} cotisation(s) créée(s), {} ignorée(s)", + org.getNom(), mois, annee, crees, ignores); + return new int[]{crees, ignores}; + } + + /** + * Détermine le montant mensuel applicable à ce membre selon son rôle. + * Priorité : barème du rôle → montant par défaut de l'organisation. + */ + private BigDecimal resoudreMontantMensuel(java.util.UUID orgId, String roleOrg, + ParametresCotisationOrganisation params) { + if (roleOrg != null && !roleOrg.isBlank()) { + Optional bareme = + baremeRepository.findByOrganisationIdAndRoleOrg(orgId, roleOrg); + if (bareme.isPresent() && bareme.get().getMontantMensuel() != null) { + return bareme.get().getMontantMensuel(); + } + } + return params.getMontantCotisationMensuelle(); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/service/CotisationService.java b/src/main/java/dev/lions/unionflow/server/service/CotisationService.java index 8a9af55..aff1fd5 100644 --- a/src/main/java/dev/lions/unionflow/server/service/CotisationService.java +++ b/src/main/java/dev/lions/unionflow/server/service/CotisationService.java @@ -70,17 +70,17 @@ public class CotisationService { * @param size taille de la page * @return liste des cotisations converties en Summary Response */ - public List getAllCotisations(int page, int size) { + public List getAllCotisations(int page, int size) { log.debug("Récupération des cotisations - page: {}, size: {}", page, size); jakarta.persistence.TypedQuery query = cotisationRepository.getEntityManager().createQuery( - "SELECT c FROM Cotisation c ORDER BY c.dateEcheance DESC", + "SELECT c FROM Cotisation c LEFT JOIN FETCH c.membre LEFT JOIN FETCH c.organisation ORDER BY c.dateEcheance DESC", Cotisation.class); query.setFirstResult(page * size); query.setMaxResults(size); List cotisations = query.getResultList(); - return cotisations.stream().map(this::convertToSummaryResponse).collect(Collectors.toList()); + return cotisations.stream().map(this::convertToResponse).collect(Collectors.toList()); } /** @@ -283,7 +283,7 @@ public class CotisationService { /** * Récupère les cotisations d'un membre. */ - public List getCotisationsByMembre(@NotNull UUID membreId, int page, int size) { + public List getCotisationsByMembre(@NotNull UUID membreId, int page, int size) { log.debug("Récupération des cotisations du membre: {}", membreId); if (!membreRepository.findByIdOptional(membreId).isPresent()) { @@ -293,35 +293,35 @@ public class CotisationService { List cotisations = cotisationRepository.findByMembreId( membreId, Page.of(page, size), Sort.by("dateEcheance").descending()); - return cotisations.stream().map(this::convertToSummaryResponse).collect(Collectors.toList()); + return cotisations.stream().map(this::convertToResponse).collect(Collectors.toList()); } /** * Récupère les cotisations par statut. */ - public List getCotisationsByStatut(@NotNull String statut, int page, int size) { + public List getCotisationsByStatut(@NotNull String statut, int page, int size) { log.debug("Récupération des cotisations avec statut: {}", statut); List cotisations = cotisationRepository.findByStatut(statut, Page.of(page, size)); - return cotisations.stream().map(this::convertToSummaryResponse).collect(Collectors.toList()); + return cotisations.stream().map(this::convertToResponse).collect(Collectors.toList()); } /** * Récupère les cotisations en retard. */ - public List getCotisationsEnRetard(int page, int size) { + public List getCotisationsEnRetard(int page, int size) { log.debug("Récupération des cotisations en retard"); List cotisations = cotisationRepository.findCotisationsEnRetard(LocalDate.now(), Page.of(page, size)); - return cotisations.stream().map(this::convertToSummaryResponse).collect(Collectors.toList()); + return cotisations.stream().map(this::convertToResponse).collect(Collectors.toList()); } /** * Recherche avancée de cotisations. */ - public List rechercherCotisations( + public List rechercherCotisations( UUID membreId, String statut, String typeCotisation, @@ -334,7 +334,7 @@ public class CotisationService { List cotisations = cotisationRepository.rechercheAvancee( membreId, statut, typeCotisation, annee, mois, Page.of(page, size)); - return cotisations.stream().map(this::convertToSummaryResponse).collect(Collectors.toList()); + return cotisations.stream().map(this::convertToResponse).collect(Collectors.toList()); } /** @@ -699,7 +699,7 @@ public class CotisationService { * Toutes les cotisations du membre connecté (tous statuts), ou des organisations gérées si ADMIN/ADMIN_ORGANISATION. * Utilisé pour les onglets Toutes / Payées / Dues / Retard. */ - public List getMesCotisations(int page, int size) { + public List getMesCotisations(int page, int size) { String email = securiteHelper.resolveEmail(); if (email == null || email.isBlank()) { return Collections.emptyList(); @@ -714,7 +714,7 @@ public class CotisationService { Set orgIds = orgs.stream().map(Organisation::getId).collect(Collectors.toCollection(LinkedHashSet::new)); List cotisations = cotisationRepository.findByOrganisationIdIn( orgIds, Page.of(page, size), Sort.by("dateEcheance").descending()); - return cotisations.stream().map(this::convertToSummaryResponse).collect(Collectors.toList()); + return cotisations.stream().map(this::convertToResponse).collect(Collectors.toList()); } Membre membreConnecte = membreRepository.findByEmail(email).orElse(null); if (membreConnecte == null) { @@ -731,7 +731,7 @@ public class CotisationService { * * @return Liste des cotisations en attente */ - public List getMesCotisationsEnAttente() { + public List getMesCotisationsEnAttente() { String email = securiteHelper.resolveEmail(); if (email == null || email.isBlank()) { return Collections.emptyList(); @@ -745,7 +745,7 @@ public class CotisationService { Set orgIds = orgs.stream().map(Organisation::getId).collect(Collectors.toCollection(LinkedHashSet::new)); List cotisations = cotisationRepository.findEnAttenteByOrganisationIdIn(orgIds); log.info("Cotisations en attente (admin): {} pour {} organisations", cotisations.size(), orgIds.size()); - return cotisations.stream().map(this::convertToSummaryResponse).collect(Collectors.toList()); + return cotisations.stream().map(this::convertToResponse).collect(Collectors.toList()); } Membre membreConnecte = membreRepository.findByEmail(email).orElse(null); if (membreConnecte == null) { @@ -758,6 +758,7 @@ public class CotisationService { List cotisations = cotisationRepository.getEntityManager() .createQuery( "SELECT c FROM Cotisation c " + + "LEFT JOIN FETCH c.membre LEFT JOIN FETCH c.organisation " + "WHERE c.membre.id = :membreId " + "AND c.statut = 'EN_ATTENTE' " + "AND EXTRACT(YEAR FROM c.dateEcheance) = :annee " + @@ -769,7 +770,7 @@ public class CotisationService { log.info("Cotisations en attente trouvées: {} pour le membre {}", cotisations.size(), membreConnecte.getNumeroMembre()); return cotisations.stream() - .map(this::convertToSummaryResponse) + .map(this::convertToResponse) .collect(Collectors.toList()); } diff --git a/src/main/java/dev/lions/unionflow/server/service/DemandeAideService.java b/src/main/java/dev/lions/unionflow/server/service/DemandeAideService.java index 43f94de..42ff266 100644 --- a/src/main/java/dev/lions/unionflow/server/service/DemandeAideService.java +++ b/src/main/java/dev/lions/unionflow/server/service/DemandeAideService.java @@ -13,6 +13,8 @@ import dev.lions.unionflow.server.mapper.DemandeAideMapper; import dev.lions.unionflow.server.repository.DemandeAideRepository; import dev.lions.unionflow.server.repository.MembreRepository; import dev.lions.unionflow.server.repository.OrganisationRepository; +import io.quarkus.panache.common.Page; +import io.quarkus.panache.common.Sort; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.transaction.Transactional; @@ -403,9 +405,19 @@ public class DemandeAideService { cacheDemandesRecentes.keySet().retainAll(cacheTimestamps.keySet()); } - /** Charge toutes les demandes depuis la base et les mappe en DTO. */ + /** + * Charge les demandes depuis la base avec une limite de 1000 enregistrements. + * Log un avertissement si le résultat dépasse 500 éléments pour anticiper les risques OOM. + */ private List chargerToutesLesDemandesDepuisBDD() { - List entities = demandeAideRepository.listAll(); + int limite = 1000; + List entities = demandeAideRepository.findAll( + Page.ofSize(limite), + Sort.by("dateDemande", Sort.Direction.Descending) + ); + if (entities.size() > 500) { + LOG.warnf("chargerToutesLesDemandesDepuisBDD : %d demandes chargées en mémoire — risque OOM si la volumétrie continue de croître", entities.size()); + } return entities.stream() .map(demandeAideMapper::toDTO) .collect(Collectors.toList()); diff --git a/src/main/java/dev/lions/unionflow/server/service/DocumentService.java b/src/main/java/dev/lions/unionflow/server/service/DocumentService.java index f15813a..cecbb4a 100644 --- a/src/main/java/dev/lions/unionflow/server/service/DocumentService.java +++ b/src/main/java/dev/lions/unionflow/server/service/DocumentService.java @@ -122,6 +122,19 @@ public class DocumentService { return convertToResponse(pieceJointe); } + /** + * Liste les documents de l'utilisateur connecté + * + * @return Liste des documents créés par l'utilisateur connecté + */ + public List listerMesDocuments() { + String email = keycloakService.getCurrentUserEmail(); + LOG.infof("Listing des documents pour l'utilisateur: %s", email); + return documentRepository.findByCreePar(email).stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + } + /** * Liste toutes les pièces jointes d'un document * diff --git a/src/main/java/dev/lions/unionflow/server/service/MemberLifecycleService.java b/src/main/java/dev/lions/unionflow/server/service/MemberLifecycleService.java new file mode 100644 index 0000000..4fb84dd --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/service/MemberLifecycleService.java @@ -0,0 +1,319 @@ +package dev.lions.unionflow.server.service; + +import dev.lions.unionflow.server.api.dto.notification.request.CreateNotificationRequest; +import dev.lions.unionflow.server.api.enums.membre.StatutMembre; +import dev.lions.unionflow.server.entity.Membre; +import dev.lions.unionflow.server.entity.MembreOrganisation; +import dev.lions.unionflow.server.repository.MembreOrganisationRepository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import org.jboss.logging.Logger; + +/** + * Service de cycle de vie des membres au sein d'une organisation. + * + *

Gère les transitions de statut automatiques et manuelles : + *

+ * + *

Les tâches automatiques sont déclenchées par {@link MemberLifecycleScheduler}. + */ +@ApplicationScoped +public class MemberLifecycleService { + + private static final Logger LOG = Logger.getLogger(MemberLifecycleService.class); + + /** Durée de validité par défaut d'une invitation (7 jours). */ + public static final int INVITATION_EXPIRY_DAYS = 7; + + /** Délai de rappel avant expiration (24h avant). */ + public static final int INVITATION_REMINDER_HOURS = 24; + + @Inject + MembreOrganisationRepository membreOrgRepository; + + @Inject + NotificationService notificationService; + + // ========================================================================= + // Transitions manuelles + // ========================================================================= + + /** + * Active un membre dans une organisation (transition → ACTIF). + * + * @param membreOrgId UUID du lien membre-organisation + * @param adminId UUID de l'administrateur qui valide + * @param motif Motif optionnel de l'activation + */ + @Transactional + public MembreOrganisation activerMembre(UUID membreOrgId, UUID adminId, String motif) { + MembreOrganisation lien = charger(membreOrgId); + verifierTransitionAutorisee(lien.getStatutMembre(), + StatutMembre.EN_ATTENTE_VALIDATION, StatutMembre.INVITE, StatutMembre.SUSPENDU); + + lien.setStatutMembre(StatutMembre.ACTIF); + lien.setDateChangementStatut(LocalDate.now()); + lien.setMotifStatut(motif != null ? motif : "Validation par l'administrateur"); + membreOrgRepository.persist(lien); + + // Mettre à jour le compteur de membres de l'organisation + lien.getOrganisation().ajouterMembre(); + + LOG.infof("Membre %s activé dans organisation %s par admin %s", + lien.getMembre().getId(), lien.getOrganisation().getId(), adminId); + + // Notifier le membre + envoyerNotification(lien.getMembre(), "Adhésion activée", + "Votre adhésion à " + lien.getOrganisation().getNom() + " est maintenant active."); + + return lien; + } + + /** + * Suspend un membre dans une organisation (ACTIF → SUSPENDU). + */ + @Transactional + public MembreOrganisation suspendreMembre(UUID membreOrgId, UUID adminId, String motif) { + MembreOrganisation lien = charger(membreOrgId); + verifierTransitionAutorisee(lien.getStatutMembre(), StatutMembre.ACTIF); + + lien.setStatutMembre(StatutMembre.SUSPENDU); + lien.setDateChangementStatut(LocalDate.now()); + lien.setMotifStatut(motif != null ? motif : "Suspension par l'administrateur"); + membreOrgRepository.persist(lien); + + LOG.infof("Membre %s suspendu dans organisation %s", lien.getMembre().getId(), + lien.getOrganisation().getId()); + + envoyerNotification(lien.getMembre(), "Adhésion suspendue", + "Votre adhésion à " + lien.getOrganisation().getNom() + " a été suspendue. Motif : " + motif); + + return lien; + } + + /** + * Radie un membre d'une organisation (→ RADIE). + */ + @Transactional + public MembreOrganisation radierMembre(UUID membreOrgId, UUID adminId, String motif) { + MembreOrganisation lien = charger(membreOrgId); + + lien.setStatutMembre(StatutMembre.RADIE); + lien.setDateChangementStatut(LocalDate.now()); + lien.setMotifStatut(motif != null ? motif : "Radiation par l'administrateur"); + membreOrgRepository.persist(lien); + + // Mettre à jour le compteur de membres de l'organisation + lien.getOrganisation().retirerMembre(); + + LOG.infof("Membre %s radié de l'organisation %s. Motif: %s", + lien.getMembre().getId(), lien.getOrganisation().getId(), motif); + + envoyerNotification(lien.getMembre(), "Adhésion radiée", + "Votre adhésion à " + lien.getOrganisation().getNom() + " a été radiée."); + + return lien; + } + + /** + * Archive un membre (→ ARCHIVE) sans supprimer l'historique. + */ + @Transactional + public MembreOrganisation archiverMembre(UUID membreOrgId, String motif) { + MembreOrganisation lien = charger(membreOrgId); + + lien.setStatutMembre(StatutMembre.ARCHIVE); + lien.setDateChangementStatut(LocalDate.now()); + lien.setMotifArchivage(motif); + membreOrgRepository.persist(lien); + + // Mettre à jour le compteur de membres de l'organisation + lien.getOrganisation().retirerMembre(); + + LOG.infof("Membre %s archivé dans l'organisation %s", lien.getMembre().getId(), + lien.getOrganisation().getId()); + + return lien; + } + + /** + * Crée une invitation (statut INVITE) pour un nouveau membre. + * + * @param membre Le membre à inviter (doit exister en base) + * @param organisation L'organisation cible + * @param adminId L'administrateur qui invite + * @param roleOrg Rôle proposé dans l'organisation (optionnel) + * @return le lien MembreOrganisation créé avec le token d'invitation + */ + @Transactional + public MembreOrganisation inviterMembre( + dev.lions.unionflow.server.entity.Membre membre, + dev.lions.unionflow.server.entity.Organisation organisation, + UUID adminId, + String roleOrg) { + + // Vérifier s'il n'existe pas déjà un lien actif + membreOrgRepository.findByMembreIdAndOrganisationId(membre.getId(), organisation.getId()) + .ifPresent(existing -> { + throw new IllegalStateException("Le membre est déjà lié à cette organisation."); + }); + + String token = UUID.randomUUID().toString().replace("-", ""); + LocalDateTime now = LocalDateTime.now(); + + MembreOrganisation lien = MembreOrganisation.builder() + .membre(membre) + .organisation(organisation) + .statutMembre(StatutMembre.INVITE) + .dateInvitation(now) + .dateExpirationInvitation(now.plusDays(INVITATION_EXPIRY_DAYS)) + .tokenInvitation(token) + .invitePar(adminId) + .roleOrg(roleOrg) + .build(); + + membreOrgRepository.persist(lien); + + LOG.infof("Invitation créée pour membre %s dans organisation %s (expire: %s)", + membre.getId(), organisation.getId(), lien.getDateExpirationInvitation()); + + // Notifier le membre + envoyerNotification(membre, "Invitation à rejoindre " + organisation.getNom(), + "Vous avez été invité à rejoindre " + organisation.getNom() + + ". Votre invitation expire dans " + INVITATION_EXPIRY_DAYS + " jours."); + + return lien; + } + + /** + * Accepte une invitation via son token (INVITE → EN_ATTENTE_VALIDATION). + */ + @Transactional + public MembreOrganisation accepterInvitation(String token) { + MembreOrganisation lien = membreOrgRepository + .find("tokenInvitation = ?1 and statutMembre = ?2", token, StatutMembre.INVITE) + .firstResultOptional() + .orElseThrow(() -> new IllegalArgumentException("Invitation introuvable ou déjà utilisée.")); + + if (lien.getDateExpirationInvitation() != null && + lien.getDateExpirationInvitation().isBefore(LocalDateTime.now())) { + throw new IllegalStateException("Cette invitation a expiré."); + } + + lien.setStatutMembre(StatutMembre.EN_ATTENTE_VALIDATION); + lien.setDateChangementStatut(LocalDate.now()); + lien.setTokenInvitation(null); // Invalider le token après usage + membreOrgRepository.persist(lien); + + LOG.infof("Invitation acceptée par membre %s dans organisation %s", + lien.getMembre().getId(), lien.getOrganisation().getId()); + + return lien; + } + + // ========================================================================= + // Traitements automatiques (appelés par MemberLifecycleScheduler) + // ========================================================================= + + /** + * Expire les invitations dont la date limite est dépassée. + * Les membres INVITE passent au statut RADIE. + * + * @return nombre d'invitations expirées + */ + @Transactional + public int expirerInvitations() { + LocalDateTime now = LocalDateTime.now(); + List expiredInvitations = membreOrgRepository.findInvitationsExpirees(now); + + int count = 0; + for (MembreOrganisation lien : expiredInvitations) { + lien.setStatutMembre(StatutMembre.RADIE); + lien.setDateChangementStatut(LocalDate.now()); + lien.setMotifStatut("Invitation expirée sans réponse"); + membreOrgRepository.persist(lien); + count++; + + LOG.infof("Invitation expirée : membre %s dans org %s", + lien.getMembre().getId(), lien.getOrganisation().getId()); + } + + if (count > 0) { + LOG.infof("Expiration des invitations : %d invitation(s) expirée(s)", count); + } + return count; + } + + /** + * Envoie des rappels pour les invitations qui expirent dans les prochaines 24h. + * + * @return nombre de rappels envoyés + */ + @Transactional + public int envoyerRappelsInvitation() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime dans24h = now.plusHours(INVITATION_REMINDER_HOURS); + List aRappeler = + membreOrgRepository.findInvitationsExpirantBientot(now, dans24h); + + int count = 0; + for (MembreOrganisation lien : aRappeler) { + envoyerNotification(lien.getMembre(), + "Rappel : votre invitation expire bientôt", + "Votre invitation à rejoindre " + lien.getOrganisation().getNom() + + " expire dans moins de 24h. Acceptez-la maintenant."); + count++; + } + + if (count > 0) { + LOG.infof("Rappels d'invitation : %d rappel(s) envoyé(s)", count); + } + return count; + } + + // ========================================================================= + // Helpers privés + // ========================================================================= + + private MembreOrganisation charger(UUID membreOrgId) { + return membreOrgRepository.findByIdOptional(membreOrgId) + .orElseThrow(() -> new IllegalArgumentException( + "Lien membre-organisation introuvable : " + membreOrgId)); + } + + private void verifierTransitionAutorisee(StatutMembre current, StatutMembre... autorises) { + for (StatutMembre autorise : autorises) { + if (autorise.equals(current)) return; + } + throw new IllegalStateException( + "Transition non autorisée depuis le statut " + current); + } + + private void envoyerNotification(Membre membre, String titre, String corps) { + try { + if (notificationService != null) { + CreateNotificationRequest req = CreateNotificationRequest.builder() + .typeNotification("SYSTEME") + .priorite("NORMALE") + .sujet(titre) + .corps(corps) + .membreId(membre.getId()) + .build(); + notificationService.creerNotification(req); + } + } catch (Exception e) { + LOG.warnf("Impossible d'envoyer la notification à %s : %s", membre.getId(), e.getMessage()); + } + } +} diff --git a/src/main/java/dev/lions/unionflow/server/service/MembreKeycloakSyncService.java b/src/main/java/dev/lions/unionflow/server/service/MembreKeycloakSyncService.java index cf97ade..a5addc9 100644 --- a/src/main/java/dev/lions/unionflow/server/service/MembreKeycloakSyncService.java +++ b/src/main/java/dev/lions/unionflow/server/service/MembreKeycloakSyncService.java @@ -7,12 +7,19 @@ import dev.lions.unionflow.server.entity.Membre; import dev.lions.unionflow.server.repository.MembreRepository; import dev.lions.user.manager.dto.user.UserDTO; import dev.lions.user.manager.dto.user.UserSearchCriteriaDTO; +import io.quarkus.oidc.client.NamedOidcClient; +import io.quarkus.oidc.client.OidcClient; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.transaction.Transactional; import jakarta.ws.rs.NotFoundException; +import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.rest.client.inject.RestClient; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -60,6 +67,14 @@ public class MembreKeycloakSyncService { private static final Logger LOGGER = Logger.getLogger(MembreKeycloakSyncService.class.getName()); private static final String DEFAULT_REALM = "unionflow"; + /** URL du serveur Keycloak. Ex : https://security.lions.dev/realms/unionflow */ + @ConfigProperty(name = "quarkus.oidc.auth-server-url") + String oidcAuthServerUrl; + + @Inject + @NamedOidcClient("admin-service") + OidcClient adminOidcClient; + @Inject MembreRepository membreRepository; @@ -518,7 +533,15 @@ public class MembreKeycloakSyncService { .temporary(false) .build(); - userServiceClient.resetPassword(keycloakUserId, DEFAULT_REALM, resetRequest); + // Tentative via lions-user-manager ; fallback sur l'API Admin Keycloak directe si 403 + try { + userServiceClient.resetPassword(keycloakUserId, DEFAULT_REALM, resetRequest); + } catch (jakarta.ws.rs.ForbiddenException | jakarta.ws.rs.ServiceUnavailableException e) { + LOGGER.warning("lions-user-manager reset-password échoué (" + e.getMessage() + + "), fallback sur API Admin Keycloak directe."); + changerMotDePasseDirectKeycloak(membre.getId(), nouveauMotDePasse); + return; // changerMotDePasseDirectKeycloak persiste déjà les flags + } membre.setPremiereConnexion(false); // Auto-activation : le membre prouve son identité en changeant son mot de passe temporaire @@ -541,6 +564,75 @@ public class MembreKeycloakSyncService { LOGGER.info("Mot de passe premier login changé pour: " + membre.getEmail()); } + /** + * Change le mot de passe d'un membre en appelant directement l'API Admin Keycloak. + * Bypass lions-user-manager (évite les erreurs 403 de service account). + * + * @param membreId UUID du membre UnionFlow + * @param nouveauMotDePasse Nouveau mot de passe (en clair, transmis en HTTPS) + */ + @Transactional + public void changerMotDePasseDirectKeycloak(UUID membreId, String nouveauMotDePasse) { + LOGGER.info("Changement de mot de passe (direct Keycloak) pour membre ID: " + membreId); + + Membre membre = membreRepository.findByIdOptional(membreId) + .orElseThrow(() -> new NotFoundException("Membre non trouvé: " + membreId)); + + if (membre.getKeycloakId() == null) { + throw new IllegalStateException("Le membre n'a pas de compte Keycloak: " + membre.getEmail()); + } + + String keycloakUserId = membre.getKeycloakId().toString(); + + // Obtenir le token admin via OIDC client credentials + String adminToken; + try { + adminToken = adminOidcClient.getTokens().await().indefinitely().getAccessToken(); + } catch (Exception e) { + throw new RuntimeException("Impossible d'obtenir le token admin Keycloak: " + e.getMessage(), e); + } + + // Dériver l'URL Admin Keycloak depuis l'URL OIDC + // Ex: https://security.lions.dev/realms/unionflow → https://security.lions.dev/admin/realms/unionflow + String adminUrl = oidcAuthServerUrl.replace("/realms/", "/admin/realms/") + + "/users/" + keycloakUserId + "/reset-password"; + + String body = String.format( + "{\"type\":\"password\",\"value\":\"%s\",\"temporary\":false}", + nouveauMotDePasse.replace("\"", "\\\"")); + + try { + HttpClient client = HttpClient.newHttpClient(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(adminUrl)) + .header("Authorization", "Bearer " + adminToken) + .header("Content-Type", "application/json") + .PUT(HttpRequest.BodyPublishers.ofString(body)) + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 204 && response.statusCode() != 200) { + throw new RuntimeException("Keycloak Admin API retourné " + response.statusCode() + + ": " + response.body()); + } + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException("Erreur lors de la réinitialisation du mot de passe Keycloak: " + e.getMessage(), e); + } + + // Mettre à jour les flags + membre.setPremiereConnexion(false); + if ("EN_ATTENTE_VALIDATION".equals(membre.getStatutCompte())) { + membre.setStatutCompte("ACTIF"); + membre.setActif(true); + } + membreRepository.persist(membre); + + LOGGER.info("Mot de passe changé (direct Keycloak) pour: " + membre.getEmail()); + } + /** * Marque le premier login comme complété. * diff --git a/src/main/java/dev/lions/unionflow/server/service/MembreService.java b/src/main/java/dev/lions/unionflow/server/service/MembreService.java index d934b7c..e5dceb0 100644 --- a/src/main/java/dev/lions/unionflow/server/service/MembreService.java +++ b/src/main/java/dev/lions/unionflow/server/service/MembreService.java @@ -278,6 +278,11 @@ public class MembreService { return membreRepository.findByEmail(email); } + /** Trouve un membre par son numéro de membre (ex: MBR-0001) */ + public Optional trouverParNumeroMembre(String numeroMembre) { + return membreRepository.findByNumeroMembre(numeroMembre); + } + /** Liste tous les membres actifs */ public List listerMembresActifs() { return membreRepository.findAllActifs(); @@ -523,12 +528,14 @@ public class MembreService { UUID organisationId = null; String organisationNom = null; + java.time.LocalDate dateAdhesion = null; if (membre.getMembresOrganisations() != null && !membre.getMembresOrganisations().isEmpty()) { dev.lions.unionflow.server.entity.MembreOrganisation mo = membre.getMembresOrganisations().get(0); if (mo.getOrganisation() != null) { organisationId = mo.getOrganisation().getId(); organisationNom = mo.getOrganisation().getNom(); } + dateAdhesion = mo.getDateAdhesion(); } return new MembreSummaryResponse( @@ -545,7 +552,8 @@ public class MembreService { membre.getActif(), rolesNames, organisationId, - organisationNom); + organisationNom, + dateAdhesion); } /** Convertit un CreateMembreRequest en entité Membre */ diff --git a/src/main/java/dev/lions/unionflow/server/service/OrganisationModuleService.java b/src/main/java/dev/lions/unionflow/server/service/OrganisationModuleService.java new file mode 100644 index 0000000..0a4592e --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/service/OrganisationModuleService.java @@ -0,0 +1,157 @@ +package dev.lions.unionflow.server.service; + +import dev.lions.unionflow.server.entity.Organisation; +import dev.lions.unionflow.server.repository.OrganisationRepository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; +import java.util.LinkedHashSet; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.jboss.logging.Logger; + +/** + * Service de gestion des modules actifs par organisation. + * + *

Architecture Option C — les modules actifs sont déterminés par le TYPE + * d'organisation, pas par le plan tarifaire. Le plan impacte uniquement la + * profondeur fonctionnelle (reporting, API, fédération). + * + *

Les modules sont regroupés en deux catégories : + *

+ */ +@ApplicationScoped +public class OrganisationModuleService { + + private static final Logger LOG = Logger.getLogger(OrganisationModuleService.class); + + /** Modules présents sur toutes les organisations quelle que soit leur nature. */ + public static final Set MODULES_COMMUNS = Set.of( + "MEMBRES", + "COTISATIONS", + "EVENEMENTS", + "COMMUNICATION", + "DOCUMENTS", + "NOTIFICATION", + "AIDE" + ); + + @Inject + OrganisationRepository organisationRepository; + + /** + * Retourne l'ensemble des modules actifs pour une organisation donnée. + * Combine les modules communs avec les modules métier du type d'org. + */ + public Set getModulesActifs(UUID organisationId) { + Optional opt = organisationRepository.findByIdOptional(organisationId); + if (opt.isEmpty()) { + LOG.warnf("Organisation introuvable : %s", organisationId); + return MODULES_COMMUNS; + } + return getModulesActifs(opt.get()); + } + + /** + * Retourne l'ensemble des modules actifs pour une organisation. + */ + public Set getModulesActifs(Organisation organisation) { + Set modules = new LinkedHashSet<>(MODULES_COMMUNS); + + // 1. Modules issus du champ modulesActifs persisté (calculé depuis types_reference en V18) + String modulesActifsCsv = organisation.getModulesActifs(); + if (modulesActifsCsv != null && !modulesActifsCsv.isBlank()) { + Arrays.stream(modulesActifsCsv.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .forEach(modules::add); + return modules; + } + + // 2. Fallback : déduction depuis le typeOrganisation si modulesActifs non renseigné + modules.addAll(getModulesParType(organisation.getTypeOrganisation())); + return modules; + } + + /** + * Vérifie si un module spécifique est actif pour une organisation. + */ + public boolean isModuleActif(UUID organisationId, String module) { + return getModulesActifs(organisationId).contains(module.toUpperCase()); + } + + /** + * Retourne les modules métier associés à un type d'organisation. + * Utilisé en fallback si la colonne modules_actifs n'est pas peuplée. + */ + public Set getModulesParType(String typeOrganisation) { + if (typeOrganisation == null) { + return Collections.emptySet(); + } + Set modules = new LinkedHashSet<>(); + switch (typeOrganisation.toUpperCase()) { + case "TONTINE" -> { + modules.add("TONTINE"); + modules.add("FINANCE"); + } + case "MUTUELLE_EPARGNE" -> { + modules.add("EPARGNE"); + modules.add("FINANCE"); + modules.add("LCB_FT"); + } + case "MUTUELLE_CREDIT" -> { + modules.add("EPARGNE"); + modules.add("CREDIT"); + modules.add("FINANCE"); + modules.add("LCB_FT"); + } + case "COOPERATIVE" -> { + modules.add("AGRICULTURE"); + modules.add("FINANCE"); + } + case "ONG", "FONDATION" -> { + modules.add("PROJETS_ONG"); + modules.add("COLLECTE_FONDS"); + modules.add("FINANCE"); + } + case "EGLISE", "GROUPE_PRIERE" -> { + modules.add("CULTE_DONS"); + } + case "SYNDICAT", "ORDRE_PROFESSIONNEL", "FEDERATION" -> { + modules.add("VOTES"); + modules.add("REGISTRE_AGREMENT"); + } + case "GIE" -> { + modules.add("FINANCE"); + } + case "ASSOCIATION", "CLUB_SERVICE", "CLUB_SPORTIF", "CLUB_CULTUREL" -> { + modules.add("VOTES"); + } + default -> LOG.debugf("Type d''organisation non reconnu pour module mapping : %s", typeOrganisation); + } + return modules; + } + + /** + * Retourne la liste des modules actifs sous forme de tableau JSON-friendly. + * Utilisé par l'endpoint /api/organisations/{id}/modules-actifs + */ + public ModulesActifsResponse getModulesActifsResponse(UUID organisationId) { + Optional opt = organisationRepository.findByIdOptional(organisationId); + if (opt.isEmpty()) { + return new ModulesActifsResponse(organisationId, Collections.emptySet(), "UNKNOWN"); + } + Organisation org = opt.get(); + Set modules = getModulesActifs(org); + return new ModulesActifsResponse(organisationId, modules, org.getTypeOrganisation()); + } + + /** DTO de réponse pour l'endpoint modules-actifs. */ + public record ModulesActifsResponse(UUID organisationId, Set modules, String typeOrganisation) {} +} diff --git a/src/main/java/dev/lions/unionflow/server/service/PaiementService.java b/src/main/java/dev/lions/unionflow/server/service/PaiementService.java index 3d216fb..d8284da 100644 --- a/src/main/java/dev/lions/unionflow/server/service/PaiementService.java +++ b/src/main/java/dev/lions/unionflow/server/service/PaiementService.java @@ -333,7 +333,11 @@ public class PaiementService { .build(); intentionPaiementRepository.persist(intention); - String successUrl = base + "/api/wave-redirect/success?ref=" + intention.getId(); + // Web (sans numéro de téléphone) → page HTML de confirmation ; Mobile → deep link app + boolean isWebContext = request.numeroTelephone() == null || request.numeroTelephone().isBlank(); + String successUrl = base + (isWebContext + ? "/api/wave-redirect/web-success?ref=" + intention.getId() + : "/api/wave-redirect/success?ref=" + intention.getId()); String errorUrl = base + "/api/wave-redirect/error?ref=" + intention.getId(); String clientRef = intention.getId().toString(); // XOF : montant entier, pas de décimales (spec Wave) @@ -393,6 +397,113 @@ public class PaiementService { .build(); } + /** + * Vérifie le statut d'une IntentionPaiement Wave. + * Si la session Wave est complétée (paiement réussi), réconcilie automatiquement + * la cotisation (marque PAYEE) et met à jour l'intention (COMPLETEE). + * Appelé en polling depuis le web toutes les 3 secondes. + */ + @Transactional + public dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse verifierStatutIntention(UUID intentionId) { + IntentionPaiement intention = intentionPaiementRepository.findById(intentionId); + if (intention == null) { + throw new NotFoundException("IntentionPaiement non trouvée: " + intentionId); + } + + // Déjà terminée — retourner immédiatement + if (intention.isCompletee()) { + return buildStatutResponse(intention, "Paiement confirmé !"); + } + if (StatutIntentionPaiement.EXPIREE.equals(intention.getStatut()) + || StatutIntentionPaiement.ECHOUEE.equals(intention.getStatut())) { + return buildStatutResponse(intention, "Paiement " + intention.getStatut().name().toLowerCase()); + } + + // Session expirée côté UnionFlow (30 min) + if (intention.isExpiree()) { + intention.setStatut(StatutIntentionPaiement.EXPIREE); + intentionPaiementRepository.persist(intention); + return buildStatutResponse(intention, "Session expirée, veuillez recommencer"); + } + + // Vérifier le statut côté Wave si session connue + if (intention.getWaveCheckoutSessionId() != null) { + try { + WaveCheckoutService.WaveSessionStatusResponse waveStatus = + waveCheckoutService.getSession(intention.getWaveCheckoutSessionId()); + + if (waveStatus.isSucceeded()) { + completerIntention(intention, waveStatus.transactionId); + return buildStatutResponse(intention, "Paiement confirmé !"); + } else if (waveStatus.isExpired()) { + intention.setStatut(StatutIntentionPaiement.EXPIREE); + intentionPaiementRepository.persist(intention); + return buildStatutResponse(intention, "Session Wave expirée"); + } + } catch (WaveCheckoutService.WaveCheckoutException e) { + LOG.warnf(e, "Impossible de vérifier la session Wave %s — retry au prochain poll", + intention.getWaveCheckoutSessionId()); + } + } + + return buildStatutResponse(intention, "En attente de confirmation Wave..."); + } + + /** + * Marque l'IntentionPaiement COMPLETEE et réconcilie les cotisations cibles (PAYEE). + * Utilisé par le polling web ET par WaveRedirectResource lors du redirect success. + */ + @Transactional + public void completerIntention(IntentionPaiement intention, String waveTransactionId) { + if (intention.isCompletee()) return; // idempotent + + intention.setStatut(StatutIntentionPaiement.COMPLETEE); + intention.setDateCompletion(java.time.LocalDateTime.now()); + if (waveTransactionId != null) intention.setWaveTransactionId(waveTransactionId); + intentionPaiementRepository.persist(intention); + + // Réconcilier les cotisations listées dans objetsCibles + String objetsCibles = intention.getObjetsCibles(); + if (objetsCibles == null || objetsCibles.isBlank()) return; + + try { + com.fasterxml.jackson.databind.JsonNode arr = + new com.fasterxml.jackson.databind.ObjectMapper().readTree(objetsCibles); + if (!arr.isArray()) return; + for (com.fasterxml.jackson.databind.JsonNode node : arr) { + if (!"COTISATION".equals(node.path("type").asText())) continue; + UUID cotisationId = UUID.fromString(node.get("id").asText()); + java.math.BigDecimal montant = node.has("montant") + ? new java.math.BigDecimal(node.get("montant").asText()) + : intention.getMontantTotal(); + + Cotisation cotisation = paiementRepository.getEntityManager().find(Cotisation.class, cotisationId); + if (cotisation == null) continue; + + cotisation.setMontantPaye(montant); + cotisation.setStatut("PAYEE"); + cotisation.setDatePaiement(java.time.LocalDateTime.now()); + paiementRepository.getEntityManager().merge(cotisation); + LOG.infof("Cotisation %s marquée PAYEE — Wave txn %s", cotisationId, waveTransactionId); + } + } catch (Exception e) { + LOG.errorf(e, "Erreur réconciliation cotisations pour intention %s", intention.getId()); + } + } + + private dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse buildStatutResponse( + IntentionPaiement intention, String message) { + return dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse.builder() + .intentionId(intention.getId()) + .statut(intention.getStatut().name()) + .waveLaunchUrl(intention.getWaveLaunchUrl()) + .waveCheckoutSessionId(intention.getWaveCheckoutSessionId()) + .waveTransactionId(intention.getWaveTransactionId()) + .montant(intention.getMontantTotal()) + .message(message) + .build(); + } + /** Format E.164 pour Wave (ex: 771234567 -> +225771234567). */ private static String toE164(String numeroTelephone) { if (numeroTelephone == null || numeroTelephone.isBlank()) return null; diff --git a/src/main/java/dev/lions/unionflow/server/service/SouscriptionService.java b/src/main/java/dev/lions/unionflow/server/service/SouscriptionService.java index 9f784e0..7efb8f8 100644 --- a/src/main/java/dev/lions/unionflow/server/service/SouscriptionService.java +++ b/src/main/java/dev/lions/unionflow/server/service/SouscriptionService.java @@ -306,6 +306,40 @@ public class SouscriptionService { // ── Validation SuperAdmin ────────────────────────────────────────────────── + /** + * Liste toutes les souscriptions (SuperAdmin), avec filtre optionnel par organisation. + */ + public List listerToutes(UUID organisationId, int page, int size) { + if (organisationId != null) { + return souscriptionRepo + .find("organisation.id = ?1 order by dateCreation desc", organisationId) + .page(page, size) + .list() + .stream() + .map(s -> toStatutResponse(s, null)) + .collect(Collectors.toList()); + } + return souscriptionRepo + .findAll() + .page(page, size) + .list() + .stream() + .map(s -> toStatutResponse(s, null)) + .collect(Collectors.toList()); + } + + /** + * Retourne la souscription active d'une organisation (SuperAdmin). + */ + public SouscriptionStatutResponse obtenirActiveParOrganisation(UUID organisationId) { + return souscriptionRepo + .find("organisation.id = ?1 and statut = ?2 order by dateCreation desc", + organisationId, dev.lions.unionflow.server.api.enums.abonnement.StatutSouscription.ACTIVE) + .firstResultOptional() + .map(s -> toStatutResponse(s, null)) + .orElse(null); + } + /** * Liste les souscriptions en attente de validation SuperAdmin. */ @@ -499,6 +533,7 @@ public class SouscriptionService { r.setDateFin(s.getDateFin()); r.setDateValidation(s.getDateValidation()); r.setCommentaireRejet(s.getCommentaireRejet()); + r.setStatut(s.getStatut() != null ? s.getStatut().name() : null); if (s.getOrganisation() != null) { r.setOrganisationId(s.getOrganisation().getId().toString()); r.setOrganisationNom(s.getOrganisation().getNom()); diff --git a/src/main/java/dev/lions/unionflow/server/service/SystemMetricsService.java b/src/main/java/dev/lions/unionflow/server/service/SystemMetricsService.java index dee735a..1ad647f 100644 --- a/src/main/java/dev/lions/unionflow/server/service/SystemMetricsService.java +++ b/src/main/java/dev/lions/unionflow/server/service/SystemMetricsService.java @@ -58,6 +58,8 @@ public class SystemMetricsService { private final AtomicLong apiRequestsCount = new AtomicLong(0); private final AtomicLong apiRequestsLastHour = new AtomicLong(0); private final AtomicLong apiRequestsToday = new AtomicLong(0); + private final AtomicLong requestCount = new AtomicLong(0); + private final AtomicLong totalResponseTime = new AtomicLong(0); private long startTimeMillis; private LocalDateTime startTime; @@ -283,11 +285,15 @@ public class SystemMetricsService { } /** - * Nombre de sessions actives + * Nombre de sessions actives (proxy : membres avec compte ACTIF) */ private Integer getActiveSessionsCount() { - // TODO: Implémenter avec vrai système de sessions Keycloak - return 0; + try { + return (int) membreRepository.count("statutCompte = 'ACTIF'"); + } catch (Exception e) { + log.warn("Impossible de compter les membres actifs", e); + return 0; + } } /** @@ -303,11 +309,12 @@ public class SystemMetricsService { } /** - * Temps de réponse moyen API + * Temps de réponse moyen API (basé sur les appels enregistrés via recordRequest) */ private Double getAverageResponseTime() { - // TODO: Implémenter avec vrai système de métriques - return 0.0; + long count = requestCount.get(); + if (count == 0) return 0.0; + return (double) totalResponseTime.get() / count; } /** @@ -422,4 +429,14 @@ public class SystemMetricsService { apiRequestsLastHour.incrementAndGet(); apiRequestsToday.incrementAndGet(); } + + /** + * Enregistrer une requête avec son temps de réponse (en ms) + * Permet le calcul du temps de réponse moyen via getAverageResponseTime() + */ + public void recordRequest(long responseTimeMs) { + requestCount.incrementAndGet(); + totalResponseTime.addAndGet(responseTimeMs); + incrementApiRequestCount(); + } } diff --git a/src/main/java/dev/lions/unionflow/server/service/WaveCheckoutService.java b/src/main/java/dev/lions/unionflow/server/service/WaveCheckoutService.java index e53585b..84d233e 100644 --- a/src/main/java/dev/lions/unionflow/server/service/WaveCheckoutService.java +++ b/src/main/java/dev/lions/unionflow/server/service/WaveCheckoutService.java @@ -149,6 +149,62 @@ public class WaveCheckoutService { return HexFormat.of().formatHex(hash); } + /** + * Interroge l'état d'une session Wave Checkout (spec : GET /v1/checkout/sessions/:id). + * Utilisé par le polling web pour détecter automatiquement la complétion du paiement. + * + * @param sessionId ID de session Wave (cos-xxx) + * @return statut de la session (checkout_status, payment_status, transaction_id) + */ + public WaveSessionStatusResponse getSession(String sessionId) throws WaveCheckoutException { + boolean useMock = mockEnabled || apiKey == null || apiKey.trim().isBlank(); + if (useMock) { + // En mock, on ne peut pas vraiment vérifier — retourner EN_COURS (polling s'arrête via /web-success) + LOG.warnf("Wave getSession en mode MOCK — session %s", sessionId); + return new WaveSessionStatusResponse(sessionId, "open", "processing", null); + } + + String base = (baseUrl == null || baseUrl.endsWith("/")) ? baseUrl.replaceAll("/+$", "") : baseUrl; + if (!base.endsWith("/v1")) base = base + "/v1"; + String url = base + "/checkout/sessions/" + sessionId; + + try { + long timestamp = System.currentTimeMillis() / 1000; + java.net.http.HttpRequest.Builder requestBuilder = java.net.http.HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", "Bearer " + apiKey) + .header("Content-Type", "application/json") + .timeout(Duration.ofSeconds(15)) + .GET(); + + if (signingSecret != null && !signingSecret.trim().isBlank()) { + String sig = computeWaveSignature(timestamp, ""); + requestBuilder.header("Wave-Signature", "t=" + timestamp + ",v1=" + sig); + } + + java.net.http.HttpClient client = java.net.http.HttpClient.newBuilder().build(); + java.net.http.HttpResponse response = client.send( + requestBuilder.build(), + java.net.http.HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + + if (response.statusCode() >= 400) { + throw new WaveCheckoutException("Wave API: " + response.statusCode() + " " + response.body()); + } + + JsonNode root = objectMapper.readTree(response.body()); + String checkoutStatus = root.has("checkout_status") ? root.get("checkout_status").asText() : null; + String paymentStatus = root.has("payment_status") ? root.get("payment_status").asText() : null; + String transactionId = root.has("transaction_id") ? root.get("transaction_id").asText() : null; + return new WaveSessionStatusResponse(sessionId, checkoutStatus, paymentStatus, transactionId); + + } catch (WaveCheckoutException e) { + throw e; + } catch (Exception e) { + LOG.error(e.getMessage(), e); + throw new WaveCheckoutException("Erreur vérification session Wave: " + e.getMessage(), e); + } + } + public String getRedirectBaseUrl() { return (redirectBaseUrl == null || redirectBaseUrl.trim().isBlank()) ? "http://localhost:8080" : redirectBaseUrl.trim(); } @@ -159,6 +215,31 @@ public class WaveCheckoutService { return new WaveCheckoutSessionResponse(mockId, successUrl); } + public static final class WaveSessionStatusResponse { + public final String sessionId; + /** "open" | "complete" | "expired" */ + public final String checkoutStatus; + /** "processing" | "cancelled" | "succeeded" */ + public final String paymentStatus; + /** ID transaction Wave (TCN...) — non-null si succeeded */ + public final String transactionId; + + public WaveSessionStatusResponse(String sessionId, String checkoutStatus, String paymentStatus, String transactionId) { + this.sessionId = sessionId; + this.checkoutStatus = checkoutStatus; + this.paymentStatus = paymentStatus; + this.transactionId = transactionId; + } + + public boolean isSucceeded() { + return "succeeded".equals(paymentStatus) && "complete".equals(checkoutStatus); + } + + public boolean isExpired() { + return "expired".equals(checkoutStatus); + } + } + public static final class WaveCheckoutSessionResponse { public final String id; public final String waveLaunchUrl; diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index 698a38d..2b8fc03 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -26,8 +26,8 @@ quarkus.http.cors.origins=* quarkus.oidc.tenant-enabled=true quarkus.oidc.auth-server-url=http://localhost:8180/realms/unionflow quarkus.oidc.client-id=unionflow-server -# Validation audience : seuls les tokens destinés à unionflow-server sont acceptés -# Nécessite un audience mapper dans Keycloak : unionflow-mobile client → scope → audience mapper → unionflow-server +# Audience mapper configuré sur unionflow-client et unionflow-mobile dans Keycloak +# → les tokens contiennent désormais "unionflow-server" dans le claim aud quarkus.oidc.token.audience=unionflow-server quarkus.oidc.credentials.secret=unionflow-secret-2025 quarkus.oidc.tls.verification=none diff --git a/src/main/resources/db/migration/V17__Add_Role_Categories_And_Functional_Roles.sql b/src/main/resources/db/migration/V17__Add_Role_Categories_And_Functional_Roles.sql new file mode 100644 index 0000000..44ef324 --- /dev/null +++ b/src/main/resources/db/migration/V17__Add_Role_Categories_And_Functional_Roles.sql @@ -0,0 +1,144 @@ +-- ============================================================================ +-- V17 — Catégorisation des rôles + seed rôles fonctionnels et métier +-- Architecture hybride RBAC : PLATEFORME + FONCTIONNEL + METIER +-- ============================================================================ + +-- 1. Ajouter la colonne categorie +ALTER TABLE roles ADD COLUMN IF NOT EXISTS categorie VARCHAR(30); + +-- 2. Taguer les rôles plateforme existants (seedés en V13) +UPDATE roles +SET categorie = 'PLATEFORME' +WHERE code IN ('SUPERADMIN', 'ORGADMIN', 'MODERATOR', 'ACTIVEMEMBER', 'SIMPLEMEMBER', 'VISITOR') + AND categorie IS NULL; + +-- 3. Contrainte NOT NULL après mise à jour (valeur par défaut) +ALTER TABLE roles ALTER COLUMN categorie SET DEFAULT 'FONCTIONNEL'; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'roles' AND column_name = 'categorie') THEN + UPDATE roles SET categorie = 'FONCTIONNEL' WHERE categorie IS NULL; + END IF; +END $$; + +-- 4. Index sur categorie +CREATE INDEX IF NOT EXISTS idx_role_categorie ON roles (categorie); + +-- ============================================================================ +-- Rôles FONCTIONNELS — communs à toutes les organisations +-- ============================================================================ +INSERT INTO roles (id, nom, code, libelle, description, niveau_hierarchique, type_role, categorie, actif, date_creation, date_modification, cree_par, modifie_par, version) +SELECT + gen_random_uuid(), v.code, v.code, v.libelle, v.description, v.niveau_hierarchique, 'SYSTEME', 'FONCTIONNEL', true, + NOW(), NOW(), 'system', 'system', 0 +FROM (VALUES + ('PRESIDENT', 'Président', 'Représentant légal de l''organisation', 15), + ('VICE_PRESIDENT', 'Vice-Président', 'Représentant légal adjoint', 16), + ('SECRETAIRE', 'Secrétaire', 'Gestion administrative et des archives', 25), + ('SECRETAIRE_ADJOINT', 'Secrétaire Adjoint', 'Assistant du secrétaire', 26), + ('TRESORIER', 'Trésorier', 'Gestion des finances et de la trésorerie', 25), + ('TRESORIER_ADJOINT', 'Trésorier Adjoint', 'Assistant du trésorier', 26), + ('COMMISSAIRE_COMPTES', 'Commissaire aux Comptes', 'Contrôle et audit interne des comptes', 30), + ('RESPONSABLE_MEMBRES', 'Responsable des Membres', 'Gestion des adhésions et des membres', 35), + ('RESPONSABLE_COMMUNICATION','Responsable Communication', 'Communication interne et externe', 35), + ('RESPONSABLE_EVENEMENTS', 'Responsable Évènements', 'Organisation des évènements et activités', 35), + ('RESPONSABLE_TECHNIQUE', 'Responsable Technique', 'Support et outils techniques', 35), + ('RESPONSABLE_SOCIAL', 'Responsable Social', 'Activités solidaires et entraide', 35) +) AS v(code, libelle, description, niveau_hierarchique) +WHERE NOT EXISTS (SELECT 1 FROM roles WHERE roles.code = v.code); + +-- ============================================================================ +-- Rôles METIER — spécifiques par type d'organisation +-- ============================================================================ + +-- Tontine +INSERT INTO roles (id, nom, code, libelle, description, niveau_hierarchique, type_role, categorie, actif, date_creation, date_modification, cree_par, modifie_par, version) +SELECT + gen_random_uuid(), v.code, v.code, v.libelle, v.description, v.niveau_hierarchique, 'SYSTEME', 'METIER', true, + NOW(), NOW(), 'system', 'system', 0 +FROM (VALUES + ('TONTINE_MANAGER', 'Responsable Tontine', 'Gestion des cycles, cotisations et rotations tontine', 20), + ('TONTINE_COLLECTOR', 'Collecteur Tontine', 'Collecte des cotisations périodiques de la tontine', 30) +) AS v(code, libelle, description, niveau_hierarchique) +WHERE NOT EXISTS (SELECT 1 FROM roles WHERE roles.code = v.code); + +-- Mutuelle / Épargne / Crédit +INSERT INTO roles (id, nom, code, libelle, description, niveau_hierarchique, type_role, categorie, actif, date_creation, date_modification, cree_par, modifie_par, version) +SELECT + gen_random_uuid(), v.code, v.code, v.libelle, v.description, v.niveau_hierarchique, 'SYSTEME', 'METIER', true, + NOW(), NOW(), 'system', 'system', 0 +FROM (VALUES + ('MUTUELLE_RESP', 'Responsable Mutuelle', 'Gestion des comptes épargne et demandes de crédit', 20), + ('CREDIT_ANALYSTE', 'Analyste Crédit', 'Analyse et instruction des dossiers de crédit', 25), + ('EPARGNE_MANAGER', 'Gestionnaire Épargne', 'Supervision des comptes et transactions épargne', 25) +) AS v(code, libelle, description, niveau_hierarchique) +WHERE NOT EXISTS (SELECT 1 FROM roles WHERE roles.code = v.code); + +-- Coopérative +INSERT INTO roles (id, nom, code, libelle, description, niveau_hierarchique, type_role, categorie, actif, date_creation, date_modification, cree_par, modifie_par, version) +SELECT + gen_random_uuid(), v.code, v.code, v.libelle, v.description, v.niveau_hierarchique, 'SYSTEME', 'METIER', true, + NOW(), NOW(), 'system', 'system', 0 +FROM (VALUES + ('COOP_RESP', 'Responsable Coopérative', 'Gestion des campagnes et productions agricoles', 20) +) AS v(code, libelle, description, niveau_hierarchique) +WHERE NOT EXISTS (SELECT 1 FROM roles WHERE roles.code = v.code); + +-- ONG / Association +INSERT INTO roles (id, nom, code, libelle, description, niveau_hierarchique, type_role, categorie, actif, date_creation, date_modification, cree_par, modifie_par, version) +SELECT + gen_random_uuid(), v.code, v.code, v.libelle, v.description, v.niveau_hierarchique, 'SYSTEME', 'METIER', true, + NOW(), NOW(), 'system', 'system', 0 +FROM (VALUES + ('ONG_RESP', 'Responsable ONG', 'Pilotage des projets et programmes ONG', 20), + ('PROJET_MANAGER', 'Chef de Projet', 'Gestion et suivi d''un projet spécifique', 25), + ('DONATEUR', 'Donateur', 'Contributeur financier externe à l''ONG', 50) +) AS v(code, libelle, description, niveau_hierarchique) +WHERE NOT EXISTS (SELECT 1 FROM roles WHERE roles.code = v.code); + +-- Culte / Religieux +INSERT INTO roles (id, nom, code, libelle, description, niveau_hierarchique, type_role, categorie, actif, date_creation, date_modification, cree_par, modifie_par, version) +SELECT + gen_random_uuid(), v.code, v.code, v.libelle, v.description, v.niveau_hierarchique, 'SYSTEME', 'METIER', true, + NOW(), NOW(), 'system', 'system', 0 +FROM (VALUES + ('CULTE_RESP', 'Responsable Culte', 'Gestion des dons religieux et activités cultuelles', 20), + ('PASTEUR', 'Pasteur / Imam / Prêtre', 'Leader spirituel de l''organisation religieuse', 10), + ('DIACRE', 'Diacre / Responsable Groupe', 'Responsable d''un groupe ou d''une cellule', 30) +) AS v(code, libelle, description, niveau_hierarchique) +WHERE NOT EXISTS (SELECT 1 FROM roles WHERE roles.code = v.code); + +-- Vote / Gouvernance +INSERT INTO roles (id, nom, code, libelle, description, niveau_hierarchique, type_role, categorie, actif, date_creation, date_modification, cree_par, modifie_par, version) +SELECT + gen_random_uuid(), v.code, v.code, v.libelle, v.description, v.niveau_hierarchique, 'SYSTEME', 'METIER', true, + NOW(), NOW(), 'system', 'system', 0 +FROM (VALUES + ('VOTE_RESP', 'Responsable Vote', 'Administration des campagnes de vote et élections', 20), + ('SCRUTATEUR', 'Scrutateur', 'Dépouillement et comptage des votes', 30) +) AS v(code, libelle, description, niveau_hierarchique) +WHERE NOT EXISTS (SELECT 1 FROM roles WHERE roles.code = v.code); + +-- Collecte de fonds +INSERT INTO roles (id, nom, code, libelle, description, niveau_hierarchique, type_role, categorie, actif, date_creation, date_modification, cree_par, modifie_par, version) +SELECT + gen_random_uuid(), v.code, v.code, v.libelle, v.description, v.niveau_hierarchique, 'SYSTEME', 'METIER', true, + NOW(), NOW(), 'system', 'system', 0 +FROM (VALUES + ('COLLECTE_RESP', 'Responsable Collecte', 'Gestion des campagnes de collecte de fonds', 20) +) AS v(code, libelle, description, niveau_hierarchique) +WHERE NOT EXISTS (SELECT 1 FROM roles WHERE roles.code = v.code); + +-- Registre / Professionnel +INSERT INTO roles (id, nom, code, libelle, description, niveau_hierarchique, type_role, categorie, actif, date_creation, date_modification, cree_par, modifie_par, version) +SELECT + gen_random_uuid(), v.code, v.code, v.libelle, v.description, v.niveau_hierarchique, 'SYSTEME', 'METIER', true, + NOW(), NOW(), 'system', 'system', 0 +FROM (VALUES + ('REGISTRE_RESP', 'Responsable Registre', 'Gestion des agréments et accréditations professionnelles', 20), + ('CONSULTANT', 'Consultant', 'Accès lecture étendu pour analyse et conseil', 45), + ('GESTIONNAIRE_RH', 'Gestionnaire RH', 'Gestion des ressources humaines de l''organisation', 30) +) AS v(code, libelle, description, niveau_hierarchique) +WHERE NOT EXISTS (SELECT 1 FROM roles WHERE roles.code = v.code); diff --git a/src/main/resources/db/migration/V18__Add_Organisation_Categorie_Type_And_Seed_Official_Types.sql b/src/main/resources/db/migration/V18__Add_Organisation_Categorie_Type_And_Seed_Official_Types.sql new file mode 100644 index 0000000..99af3b7 --- /dev/null +++ b/src/main/resources/db/migration/V18__Add_Organisation_Categorie_Type_And_Seed_Official_Types.sql @@ -0,0 +1,135 @@ +-- ============================================================================ +-- V18 — Colonne categorie_type sur organisations + seed des 17 types officiels +-- Colonnes NOT NULL de TypeReference : domaine, code, libelle, +-- ordre_affichage (default 0), est_defaut (default false), est_systeme (default true pour seed) +-- ============================================================================ + +-- 1. Ajouter les colonnes sur organisations +ALTER TABLE organisations ADD COLUMN IF NOT EXISTS categorie_type VARCHAR(50); +ALTER TABLE organisations ADD COLUMN IF NOT EXISTS modules_actifs TEXT; + +-- 2. Index +CREATE INDEX IF NOT EXISTS idx_organisation_categorie ON organisations (categorie_type); + +-- ============================================================================ +-- 3. S'assurer que les colonnes nécessaires existent dans types_reference +-- ============================================================================ +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'types_reference' AND column_name = 'categorie' + ) THEN + ALTER TABLE types_reference ADD COLUMN categorie VARCHAR(50); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'types_reference' AND column_name = 'modules_requis' + ) THEN + ALTER TABLE types_reference ADD COLUMN modules_requis TEXT; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'types_reference' AND column_name = 'ordre_affichage' + ) THEN + ALTER TABLE types_reference ADD COLUMN ordre_affichage INTEGER NOT NULL DEFAULT 0; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'types_reference' AND column_name = 'est_defaut' + ) THEN + ALTER TABLE types_reference ADD COLUMN est_defaut BOOLEAN NOT NULL DEFAULT false; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'types_reference' AND column_name = 'est_systeme' + ) THEN + ALTER TABLE types_reference ADD COLUMN est_systeme BOOLEAN NOT NULL DEFAULT false; + END IF; +END $$; + +-- ============================================================================ +-- 4. Seed des types officiels dans types_reference +-- ============================================================================ + +-- Catégorie ASSOCIATIF +INSERT INTO types_reference (id, domaine, libelle, code, categorie, modules_requis, actif, est_defaut, est_systeme, ordre_affichage, date_creation, date_modification, version) +SELECT gen_random_uuid(), 'TYPE_ORGANISATION', v.libelle, v.code, v.categorie, v.modules_requis, true, false, true, 0, NOW(), NOW(), 0 +FROM (VALUES + ('Association Générale', 'ASSOCIATION', 'ASSOCIATIF', 'MEMBRES,COTISATIONS,EVENEMENTS,COMMUNICATION,DOCUMENTS,VOTES,AIDE'), + ('Club Service', 'CLUB_SERVICE', 'ASSOCIATIF', 'MEMBRES,COTISATIONS,EVENEMENTS,COMMUNICATION,DOCUMENTS,VOTES,PROJETS_SOLIDAIRES'), + ('Club Sportif', 'CLUB_SPORTIF', 'ASSOCIATIF', 'MEMBRES,COTISATIONS,EVENEMENTS,COMMUNICATION,DOCUMENTS'), + ('Club Culturel', 'CLUB_CULTUREL','ASSOCIATIF', 'MEMBRES,COTISATIONS,EVENEMENTS,COMMUNICATION,DOCUMENTS') +) AS v(libelle, code, categorie, modules_requis) +WHERE NOT EXISTS ( + SELECT 1 FROM types_reference WHERE types_reference.code = v.code AND domaine = 'TYPE_ORGANISATION' +); + +-- Catégorie FINANCIER_SOLIDAIRE +INSERT INTO types_reference (id, domaine, libelle, code, categorie, modules_requis, actif, est_defaut, est_systeme, ordre_affichage, date_creation, date_modification, version) +SELECT gen_random_uuid(), 'TYPE_ORGANISATION', v.libelle, v.code, v.categorie, v.modules_requis, true, false, true, 0, NOW(), NOW(), 0 +FROM (VALUES + ('Tontine', 'TONTINE', 'FINANCIER_SOLIDAIRE', 'MEMBRES,COTISATIONS,TONTINE,COMMUNICATION,DOCUMENTS,FINANCE'), + ('Mutuelle d''Épargne', 'MUTUELLE_EPARGNE', 'FINANCIER_SOLIDAIRE', 'MEMBRES,COTISATIONS,EPARGNE,COMMUNICATION,DOCUMENTS,FINANCE,LCB_FT'), + ('Mutuelle de Crédit', 'MUTUELLE_CREDIT', 'FINANCIER_SOLIDAIRE', 'MEMBRES,COTISATIONS,EPARGNE,CREDIT,COMMUNICATION,DOCUMENTS,FINANCE,LCB_FT'), + ('Coopérative', 'COOPERATIVE', 'FINANCIER_SOLIDAIRE', 'MEMBRES,COTISATIONS,AGRICULTURE,COMMUNICATION,DOCUMENTS,FINANCE') +) AS v(libelle, code, categorie, modules_requis) +WHERE NOT EXISTS ( + SELECT 1 FROM types_reference WHERE types_reference.code = v.code AND domaine = 'TYPE_ORGANISATION' +); + +-- Catégorie RELIGIEUX +INSERT INTO types_reference (id, domaine, libelle, code, categorie, modules_requis, actif, est_defaut, est_systeme, ordre_affichage, date_creation, date_modification, version) +SELECT gen_random_uuid(), 'TYPE_ORGANISATION', v.libelle, v.code, v.categorie, v.modules_requis, true, false, true, 0, NOW(), NOW(), 0 +FROM (VALUES + ('Église / Communauté Religieuse', 'EGLISE', 'RELIGIEUX', 'MEMBRES,COTISATIONS,CULTE_DONS,EVENEMENTS,COMMUNICATION,DOCUMENTS'), + ('Groupe de Prière / Cellule', 'GROUPE_PRIERE', 'RELIGIEUX', 'MEMBRES,EVENEMENTS,COMMUNICATION,DOCUMENTS') +) AS v(libelle, code, categorie, modules_requis) +WHERE NOT EXISTS ( + SELECT 1 FROM types_reference WHERE types_reference.code = v.code AND domaine = 'TYPE_ORGANISATION' +); + +-- Catégorie PROFESSIONNEL +INSERT INTO types_reference (id, domaine, libelle, code, categorie, modules_requis, actif, est_defaut, est_systeme, ordre_affichage, date_creation, date_modification, version) +SELECT gen_random_uuid(), 'TYPE_ORGANISATION', v.libelle, v.code, v.categorie, v.modules_requis, true, false, true, 0, NOW(), NOW(), 0 +FROM (VALUES + ('ONG', 'ONG', 'PROFESSIONNEL', 'MEMBRES,COTISATIONS,PROJETS_ONG,COLLECTE_FONDS,COMMUNICATION,DOCUMENTS,FINANCE'), + ('Fondation', 'FONDATION', 'PROFESSIONNEL', 'MEMBRES,COLLECTE_FONDS,PROJETS_ONG,COMMUNICATION,DOCUMENTS,FINANCE'), + ('Syndicat', 'SYNDICAT', 'PROFESSIONNEL', 'MEMBRES,COTISATIONS,VOTES,COMMUNICATION,DOCUMENTS,AIDE'), + ('Ordre Professionnel / Chambre', 'ORDRE_PROFESSIONNEL', 'PROFESSIONNEL', 'MEMBRES,COTISATIONS,REGISTRE_AGREMENT,COMMUNICATION,DOCUMENTS,VOTES'), + ('GIE', 'GIE', 'PROFESSIONNEL', 'MEMBRES,COTISATIONS,FINANCE,COMMUNICATION,DOCUMENTS') +) AS v(libelle, code, categorie, modules_requis) +WHERE NOT EXISTS ( + SELECT 1 FROM types_reference WHERE types_reference.code = v.code AND domaine = 'TYPE_ORGANISATION' +); + +-- Catégorie RESEAU_FEDERATION +INSERT INTO types_reference (id, domaine, libelle, code, categorie, modules_requis, actif, est_defaut, est_systeme, ordre_affichage, date_creation, date_modification, version) +SELECT gen_random_uuid(), 'TYPE_ORGANISATION', v.libelle, v.code, v.categorie, v.modules_requis, true, false, true, 0, NOW(), NOW(), 0 +FROM (VALUES + ('Fédération d''Organisations', 'FEDERATION', 'RESEAU_FEDERATION', 'MEMBRES,COTISATIONS,VOTES,COMMUNICATION,DOCUMENTS,FINANCE'), + ('Réseau / Plateforme', 'RESEAU', 'RESEAU_FEDERATION', 'MEMBRES,EVENEMENTS,COMMUNICATION,DOCUMENTS') +) AS v(libelle, code, categorie, modules_requis) +WHERE NOT EXISTS ( + SELECT 1 FROM types_reference WHERE types_reference.code = v.code AND domaine = 'TYPE_ORGANISATION' +); + +-- ============================================================================ +-- 5. Migrer les valeurs existantes dans organisations.categorie_type +-- ============================================================================ +UPDATE organisations o +SET categorie_type = tr.categorie, + modules_actifs = tr.modules_requis +FROM types_reference tr +WHERE tr.code = o.type_organisation + AND tr.domaine = 'TYPE_ORGANISATION' + AND o.categorie_type IS NULL; + +-- Valeur par défaut pour les organisations sans correspondance +UPDATE organisations +SET categorie_type = 'ASSOCIATIF' +WHERE categorie_type IS NULL; diff --git a/src/main/resources/db/migration/V19__FormuleAbonnement_Option_C_And_Org_Statuts.sql b/src/main/resources/db/migration/V19__FormuleAbonnement_Option_C_And_Org_Statuts.sql new file mode 100644 index 0000000..b80543e --- /dev/null +++ b/src/main/resources/db/migration/V19__FormuleAbonnement_Option_C_And_Org_Statuts.sql @@ -0,0 +1,180 @@ +-- ============================================================================ +-- V19 — Formules Option C : plan commercial + features + statuts org +-- Option C : prix = f(taille), modules = f(type org), features = f(plan) +-- ============================================================================ + +-- ============================================================================ +-- 1. Ajouter les colonnes Option C sur formules_abonnement +-- ============================================================================ + +-- Nom commercial affiché à l'utilisateur (MICRO / DECOUVERTE / ESSENTIEL / AVANCE / PROFESSIONNEL / ENTERPRISE) +ALTER TABLE formules_abonnement ADD COLUMN IF NOT EXISTS plan_commercial VARCHAR(30); + +-- Niveau de reporting inclus dans ce plan +ALTER TABLE formules_abonnement ADD COLUMN IF NOT EXISTS niveau_reporting VARCHAR(20) DEFAULT 'BASIQUE'; + +-- Accès API REST (pour intégrations externes) +ALTER TABLE formules_abonnement ADD COLUMN IF NOT EXISTS api_access BOOLEAN DEFAULT FALSE; + +-- Accès module fédération (gestion multi-org hiérarchique) +ALTER TABLE formules_abonnement ADD COLUMN IF NOT EXISTS federation_access BOOLEAN DEFAULT FALSE; + +-- Support prioritaire +ALTER TABLE formules_abonnement ADD COLUMN IF NOT EXISTS support_prioritaire BOOLEAN DEFAULT FALSE; + +-- SLA garanti (ex: '99.5%', '99.9%') +ALTER TABLE formules_abonnement ADD COLUMN IF NOT EXISTS sla_garanti VARCHAR(10); + +-- Nombre max d'administrateurs (NULL = illimité) +ALTER TABLE formules_abonnement ADD COLUMN IF NOT EXISTS max_admins INTEGER; + +-- ============================================================================ +-- 2. Mapper les 12 formules avec leur plan_commercial et features +-- ============================================================================ + +-- PETITE + BASIC → MICRO +UPDATE formules_abonnement SET + plan_commercial = 'MICRO', + niveau_reporting = 'BASIQUE', + api_access = FALSE, + federation_access = FALSE, + support_prioritaire = FALSE, + sla_garanti = '99.0%', + max_admins = 2 +WHERE code = 'BASIC' AND plage = 'PETITE'; + +-- PETITE + STANDARD → DECOUVERTE +UPDATE formules_abonnement SET + plan_commercial = 'DECOUVERTE', + niveau_reporting = 'STANDARD', + api_access = FALSE, + federation_access = FALSE, + support_prioritaire = FALSE, + sla_garanti = '99.0%', + max_admins = 5 +WHERE code = 'STANDARD' AND plage = 'PETITE'; + +-- PETITE + PREMIUM → ESSENTIEL +UPDATE formules_abonnement SET + plan_commercial = 'ESSENTIEL', + niveau_reporting = 'STANDARD', + api_access = TRUE, + federation_access = FALSE, + support_prioritaire = FALSE, + sla_garanti = '99.5%', + max_admins = 10 +WHERE code = 'PREMIUM' AND plage = 'PETITE'; + +-- MOYENNE + BASIC → ESSENTIEL +UPDATE formules_abonnement SET + plan_commercial = 'ESSENTIEL', + niveau_reporting = 'STANDARD', + api_access = FALSE, + federation_access = FALSE, + support_prioritaire = FALSE, + sla_garanti = '99.5%', + max_admins = 5 +WHERE code = 'BASIC' AND plage = 'MOYENNE'; + +-- MOYENNE + STANDARD → AVANCE +UPDATE formules_abonnement SET + plan_commercial = 'AVANCE', + niveau_reporting = 'AVANCE', + api_access = TRUE, + federation_access = FALSE, + support_prioritaire = FALSE, + sla_garanti = '99.5%', + max_admins = 15 +WHERE code = 'STANDARD' AND plage = 'MOYENNE'; + +-- MOYENNE + PREMIUM → PROFESSIONNEL +UPDATE formules_abonnement SET + plan_commercial = 'PROFESSIONNEL', + niveau_reporting = 'AVANCE', + api_access = TRUE, + federation_access = FALSE, + support_prioritaire = TRUE, + sla_garanti = '99.9%', + max_admins = NULL +WHERE code = 'PREMIUM' AND plage = 'MOYENNE'; + +-- GRANDE + BASIC → AVANCE +UPDATE formules_abonnement SET + plan_commercial = 'AVANCE', + niveau_reporting = 'AVANCE', + api_access = TRUE, + federation_access = FALSE, + support_prioritaire = FALSE, + sla_garanti = '99.5%', + max_admins = 10 +WHERE code = 'BASIC' AND plage = 'GRANDE'; + +-- GRANDE + STANDARD → PROFESSIONNEL +UPDATE formules_abonnement SET + plan_commercial = 'PROFESSIONNEL', + niveau_reporting = 'AVANCE', + api_access = TRUE, + federation_access = FALSE, + support_prioritaire = TRUE, + sla_garanti = '99.9%', + max_admins = NULL +WHERE code = 'STANDARD' AND plage = 'GRANDE'; + +-- GRANDE + PREMIUM → ENTERPRISE +UPDATE formules_abonnement SET + plan_commercial = 'ENTERPRISE', + niveau_reporting = 'AVANCE', + api_access = TRUE, + federation_access = TRUE, + support_prioritaire = TRUE, + sla_garanti = '99.9%', + max_admins = NULL +WHERE code = 'PREMIUM' AND plage = 'GRANDE'; + +-- TRES_GRANDE + BASIC → ENTERPRISE +UPDATE formules_abonnement SET + plan_commercial = 'ENTERPRISE', + niveau_reporting = 'AVANCE', + api_access = TRUE, + federation_access = TRUE, + support_prioritaire = TRUE, + sla_garanti = '99.9%', + max_admins = NULL +WHERE code = 'BASIC' AND plage = 'TRES_GRANDE'; + +-- TRES_GRANDE + STANDARD → ENTERPRISE +UPDATE formules_abonnement SET + plan_commercial = 'ENTERPRISE', + niveau_reporting = 'AVANCE', + api_access = TRUE, + federation_access = TRUE, + support_prioritaire = TRUE, + sla_garanti = '99.9%', + max_admins = NULL +WHERE code = 'STANDARD' AND plage = 'TRES_GRANDE'; + +-- TRES_GRANDE + PREMIUM → ENTERPRISE +UPDATE formules_abonnement SET + plan_commercial = 'ENTERPRISE', + niveau_reporting = 'AVANCE', + api_access = TRUE, + federation_access = TRUE, + support_prioritaire = TRUE, + sla_garanti = '99.9%', + max_admins = NULL +WHERE code = 'PREMIUM' AND plage = 'TRES_GRANDE'; + +-- ============================================================================ +-- 3. Enrichir les statuts de souscription_organisation +-- ============================================================================ + +-- Ajout des statuts de cycle de vie avancés +ALTER TABLE souscriptions_organisation ADD COLUMN IF NOT EXISTS date_expiration TIMESTAMP WITH TIME ZONE; +ALTER TABLE souscriptions_organisation ADD COLUMN IF NOT EXISTS jours_grace INTEGER DEFAULT 7; +ALTER TABLE souscriptions_organisation ADD COLUMN IF NOT EXISTS auto_renouvellement BOOLEAN DEFAULT FALSE; +ALTER TABLE souscriptions_organisation ADD COLUMN IF NOT EXISTS motif_suspension TEXT; + +-- ============================================================================ +-- 4. Index sur plan_commercial pour filtrage catalogue +-- ============================================================================ +CREATE INDEX IF NOT EXISTS idx_formule_plan_commercial ON formules_abonnement (plan_commercial); diff --git a/src/main/resources/db/migration/V20__Enrich_StatutMembre_And_Seed_Atomic_Permissions.sql b/src/main/resources/db/migration/V20__Enrich_StatutMembre_And_Seed_Atomic_Permissions.sql new file mode 100644 index 0000000..ba197f6 --- /dev/null +++ b/src/main/resources/db/migration/V20__Enrich_StatutMembre_And_Seed_Atomic_Permissions.sql @@ -0,0 +1,149 @@ +-- ============================================================================ +-- V20 — Enrichissement StatutMembre + Seed permissions atomiques +-- Les permissions suivent le format : MODULE > RESSOURCE > ACTION +-- ============================================================================ + +-- ============================================================================ +-- 1. Statut INVITE sur membre_organisation +-- La colonne statut_membre est VARCHAR(30), pas d'enum SQL → simple check de longueur +-- Aucune modification DDL nécessaire, le type Java StatutMembre.INVITE est suffisant +-- ============================================================================ + +-- Vérifier que la colonne statut_membre est assez large pour les nouveaux statuts +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'membre_organisation' + AND column_name = 'statut_membre' + AND character_maximum_length < 30 + ) THEN + ALTER TABLE membre_organisation ALTER COLUMN statut_membre TYPE VARCHAR(30); + END IF; +END $$; + +-- Ajouter les colonnes de cycle de vie enrichies +ALTER TABLE membre_organisation ADD COLUMN IF NOT EXISTS date_invitation TIMESTAMP WITH TIME ZONE; +ALTER TABLE membre_organisation ADD COLUMN IF NOT EXISTS date_expiration_invitation TIMESTAMP WITH TIME ZONE; +ALTER TABLE membre_organisation ADD COLUMN IF NOT EXISTS token_invitation VARCHAR(255); +ALTER TABLE membre_organisation ADD COLUMN IF NOT EXISTS invite_par UUID; +ALTER TABLE membre_organisation ADD COLUMN IF NOT EXISTS motif_archivage TEXT; + +-- ============================================================================ +-- 2. Seed permissions atomiques +-- Modules : ORGANISATION, MEMBRE, COTISATION, FINANCE, TONTINE, EPARGNE, +-- CREDIT, NOTIFICATION, AUDIT, RAPPORT, CONFIGURATION, AGRICULTURE, +-- ONG, CULTE, VOTE, COLLECTE, REGISTRE +-- Actions : CREATE, READ, UPDATE, DELETE, VALIDATE, EXPORT, IMPORT, SEND +-- ============================================================================ + +INSERT INTO permissions (id, code, module, ressource, action, libelle, description, actif, date_creation, date_modification, cree_par, modifie_par, version) +SELECT gen_random_uuid(), v.code, v.module, v.ressource, v.action, v.libelle, v.description, true, NOW(), NOW(), 'system', 'system', 0 +FROM (VALUES + + -- ── ORGANISATION ────────────────────────────────────────────────────────── + ('ORGANISATION > ORGANISATION > CREATE', 'ORGANISATION', 'ORGANISATION', 'CREATE', 'Créer une organisation', 'Créer une nouvelle organisation sur la plateforme'), + ('ORGANISATION > ORGANISATION > READ', 'ORGANISATION', 'ORGANISATION', 'READ', 'Consulter une organisation', 'Voir les détails d''une organisation'), + ('ORGANISATION > ORGANISATION > UPDATE', 'ORGANISATION', 'ORGANISATION', 'UPDATE', 'Modifier une organisation', 'Modifier les informations d''une organisation'), + ('ORGANISATION > ORGANISATION > DELETE', 'ORGANISATION', 'ORGANISATION', 'DELETE', 'Supprimer une organisation', 'Archiver ou supprimer une organisation'), + ('ORGANISATION > ORGANISATION > EXPORT', 'ORGANISATION', 'ORGANISATION', 'EXPORT', 'Exporter les données organisation','Exporter les données de l''organisation'), + + -- ── MEMBRE ──────────────────────────────────────────────────────────────── + ('MEMBRE > MEMBRE > CREATE', 'MEMBRE', 'MEMBRE', 'CREATE', 'Créer un membre', 'Ajouter un nouveau membre'), + ('MEMBRE > MEMBRE > READ', 'MEMBRE', 'MEMBRE', 'READ', 'Consulter un membre', 'Voir la fiche d''un membre'), + ('MEMBRE > MEMBRE > UPDATE', 'MEMBRE', 'MEMBRE', 'UPDATE', 'Modifier un membre', 'Modifier les informations d''un membre'), + ('MEMBRE > MEMBRE > DELETE', 'MEMBRE', 'MEMBRE', 'DELETE', 'Désactiver un membre', 'Désactiver ou archiver un membre'), + ('MEMBRE > MEMBRE > EXPORT', 'MEMBRE', 'MEMBRE', 'EXPORT', 'Exporter les membres', 'Exporter la liste des membres'), + ('MEMBRE > MEMBRE > IMPORT', 'MEMBRE', 'MEMBRE', 'IMPORT', 'Importer des membres', 'Importer une liste de membres'), + ('MEMBRE > INVITATION > SEND', 'MEMBRE', 'INVITATION', 'SEND', 'Inviter un membre', 'Envoyer une invitation à rejoindre l''organisation'), + ('MEMBRE > STATUT > UPDATE', 'MEMBRE', 'STATUT', 'UPDATE', 'Changer le statut d''un membre', 'Activer, suspendre, archiver un membre'), + + -- ── COTISATION ──────────────────────────────────────────────────────────── + ('COTISATION > COTISATION > CREATE', 'COTISATION', 'COTISATION', 'CREATE', 'Créer une cotisation', 'Enregistrer une cotisation'), + ('COTISATION > COTISATION > READ', 'COTISATION', 'COTISATION', 'READ', 'Consulter les cotisations', 'Voir les cotisations'), + ('COTISATION > COTISATION > UPDATE', 'COTISATION', 'COTISATION', 'UPDATE', 'Modifier une cotisation', 'Modifier une cotisation'), + ('COTISATION > COTISATION > DELETE', 'COTISATION', 'COTISATION', 'DELETE', 'Supprimer une cotisation', 'Supprimer une cotisation'), + ('COTISATION > COTISATION > VALIDATE', 'COTISATION', 'COTISATION', 'VALIDATE', 'Valider une cotisation', 'Valider le paiement d''une cotisation'), + ('COTISATION > COTISATION > EXPORT', 'COTISATION', 'COTISATION', 'EXPORT', 'Exporter les cotisations', 'Exporter le registre des cotisations'), + + -- ── FINANCE ─────────────────────────────────────────────────────────────── + ('FINANCE > TRANSACTION > CREATE', 'FINANCE', 'TRANSACTION', 'CREATE', 'Créer une transaction', 'Enregistrer une transaction financière'), + ('FINANCE > TRANSACTION > READ', 'FINANCE', 'TRANSACTION', 'READ', 'Consulter les transactions', 'Voir les transactions financières'), + ('FINANCE > TRANSACTION > VALIDATE', 'FINANCE', 'TRANSACTION', 'VALIDATE', 'Valider une transaction', 'Approuver une transaction en attente'), + ('FINANCE > BUDGET > READ', 'FINANCE', 'BUDGET', 'READ', 'Consulter le budget', 'Voir le budget de l''organisation'), + ('FINANCE > BUDGET > UPDATE', 'FINANCE', 'BUDGET', 'UPDATE', 'Modifier le budget', 'Modifier les lignes budgétaires'), + ('FINANCE > RAPPORT > READ', 'FINANCE', 'RAPPORT', 'READ', 'Consulter les rapports financiers','Voir les rapports et bilans financiers'), + ('FINANCE > RAPPORT > EXPORT', 'FINANCE', 'RAPPORT', 'EXPORT', 'Exporter les rapports financiers','Télécharger les rapports financiers'), + + -- ── TONTINE ─────────────────────────────────────────────────────────────── + ('TONTINE > TONTINE > CREATE', 'TONTINE', 'TONTINE', 'CREATE', 'Créer une tontine', 'Créer un nouveau cycle de tontine'), + ('TONTINE > TONTINE > READ', 'TONTINE', 'TONTINE', 'READ', 'Consulter une tontine', 'Voir les détails d''une tontine'), + ('TONTINE > TONTINE > UPDATE', 'TONTINE', 'TONTINE', 'UPDATE', 'Modifier une tontine', 'Modifier un cycle de tontine'), + ('TONTINE > ROTATION > MANAGE', 'TONTINE', 'ROTATION', 'MANAGE', 'Gérer les rotations', 'Gérer l''ordre de rotation des membres'), + ('TONTINE > COTISATION > CREATE','TONTINE', 'COTISATION', 'CREATE', 'Enregistrer une cotisation tontine', 'Enregistrer le paiement d''une cotisation de tontine'), + + -- ── EPARGNE ─────────────────────────────────────────────────────────────── + ('EPARGNE > COMPTE > CREATE', 'EPARGNE', 'COMPTE', 'CREATE', 'Ouvrir un compte épargne', 'Créer un compte épargne pour un membre'), + ('EPARGNE > COMPTE > READ', 'EPARGNE', 'COMPTE', 'READ', 'Consulter un compte épargne', 'Voir un compte épargne'), + ('EPARGNE > TRANSACTION > CREATE', 'EPARGNE', 'TRANSACTION', 'CREATE', 'Effectuer une transaction épargne','Dépôt ou retrait sur compte épargne'), + ('EPARGNE > TRANSACTION > READ', 'EPARGNE', 'TRANSACTION', 'READ', 'Consulter les transactions épargne','Voir l''historique des transactions'), + + -- ── CREDIT ──────────────────────────────────────────────────────────────── + ('CREDIT > DEMANDE > CREATE', 'CREDIT', 'DEMANDE', 'CREATE', 'Soumettre une demande de crédit', 'Déposer un dossier de crédit'), + ('CREDIT > DEMANDE > READ', 'CREDIT', 'DEMANDE', 'READ', 'Consulter une demande de crédit', 'Voir un dossier de crédit'), + ('CREDIT > DEMANDE > VALIDATE', 'CREDIT', 'DEMANDE', 'VALIDATE', 'Approuver/Rejeter un crédit', 'Instruire et décider sur un dossier de crédit'), + ('CREDIT > DECAISSEMENT > CREATE','CREDIT','DECAISSEMENT','CREATE','Décaisser un crédit', 'Exécuter le décaissement d''un crédit approuvé'), + + -- ── NOTIFICATION ───────────────────────────────────────────────────────── + ('NOTIFICATION > NOTIFICATION > CREATE', 'NOTIFICATION', 'NOTIFICATION', 'CREATE', 'Créer une notification', 'Envoyer une notification'), + ('NOTIFICATION > NOTIFICATION > READ', 'NOTIFICATION', 'NOTIFICATION', 'READ', 'Lire ses notifications', 'Voir ses propres notifications'), + ('NOTIFICATION > TEMPLATE > CREATE', 'NOTIFICATION', 'TEMPLATE', 'CREATE', 'Créer un template', 'Créer un template de notification'), + ('NOTIFICATION > GROUPE > SEND', 'NOTIFICATION', 'GROUPE', 'SEND', 'Envoyer en masse', 'Envoyer des notifications groupées'), + + -- ── AUDIT ───────────────────────────────────────────────────────────────── + ('AUDIT > LOG > READ', 'AUDIT', 'LOG', 'READ', 'Consulter les logs d''audit', 'Voir les logs et traces d''activité'), + ('AUDIT > LOG > EXPORT', 'AUDIT', 'LOG', 'EXPORT', 'Exporter les logs', 'Télécharger les logs d''audit'), + ('AUDIT > STATISTIQUES > READ', 'AUDIT', 'STATISTIQUES', 'READ', 'Voir les statistiques d''audit', 'Analyser les métriques d''activité'), + + -- ── RAPPORT ─────────────────────────────────────────────────────────────── + ('RAPPORT > RAPPORT > READ', 'RAPPORT', 'RAPPORT', 'READ', 'Consulter un rapport', 'Voir les rapports de l''organisation'), + ('RAPPORT > RAPPORT > EXPORT', 'RAPPORT', 'RAPPORT', 'EXPORT', 'Exporter un rapport', 'Télécharger un rapport PDF/Excel'), + ('RAPPORT > RAPPORT > CREATE', 'RAPPORT', 'RAPPORT', 'CREATE', 'Générer un rapport', 'Générer un nouveau rapport'), + + -- ── CONFIGURATION ───────────────────────────────────────────────────────── + ('CONFIGURATION > ORGANISATION > UPDATE', 'CONFIGURATION', 'ORGANISATION', 'UPDATE', 'Configurer l''organisation', 'Modifier les paramètres de l''organisation'), + ('CONFIGURATION > ROLE > MANAGE', 'CONFIGURATION', 'ROLE', 'MANAGE', 'Gérer les rôles', 'Assigner ou révoquer des rôles aux membres'), + ('CONFIGURATION > MODULE > MANAGE', 'CONFIGURATION', 'MODULE', 'MANAGE', 'Gérer les modules actifs', 'Activer ou désactiver des modules'), + + -- ── AGRICULTURE / COOPERATIVE ───────────────────────────────────────────── + ('AGRICULTURE > CAMPAGNE > CREATE', 'AGRICULTURE', 'CAMPAGNE', 'CREATE', 'Créer une campagne agricole', 'Créer et planifier une campagne'), + ('AGRICULTURE > CAMPAGNE > READ', 'AGRICULTURE', 'CAMPAGNE', 'READ', 'Consulter une campagne agricole', 'Voir les détails d''une campagne'), + ('AGRICULTURE > CAMPAGNE > UPDATE', 'AGRICULTURE', 'CAMPAGNE', 'UPDATE', 'Modifier une campagne agricole', 'Modifier une campagne en cours'), + + -- ── ONG / PROJETS ───────────────────────────────────────────────────────── + ('ONG > PROJET > CREATE', 'ONG', 'PROJET', 'CREATE', 'Créer un projet ONG', 'Créer et planifier un projet'), + ('ONG > PROJET > READ', 'ONG', 'PROJET', 'READ', 'Consulter un projet ONG', 'Voir les détails d''un projet'), + ('ONG > PROJET > UPDATE', 'ONG', 'PROJET', 'UPDATE', 'Modifier un projet ONG', 'Modifier un projet en cours'), + ('ONG > COLLECTE > CREATE', 'ONG', 'COLLECTE', 'CREATE', 'Lancer une collecte de fonds', 'Créer une campagne de collecte'), + + -- ── CULTE / DONS ────────────────────────────────────────────────────────── + ('CULTE > DON > CREATE', 'CULTE', 'DON', 'CREATE', 'Enregistrer un don religieux', 'Enregistrer un don cultuel'), + ('CULTE > DON > READ', 'CULTE', 'DON', 'READ', 'Consulter les dons religieux', 'Voir les dons et contributions cultuels'), + + -- ── VOTE ───────────────────────────────────────────────────────────────── + ('VOTE > CAMPAGNE > CREATE', 'VOTE', 'CAMPAGNE', 'CREATE', 'Créer une campagne de vote', 'Lancer une élection ou un référendum'), + ('VOTE > CAMPAGNE > READ', 'VOTE', 'CAMPAGNE', 'READ', 'Consulter une campagne vote', 'Voir une campagne de vote'), + ('VOTE > BULLETIN > CREATE', 'VOTE', 'BULLETIN', 'CREATE', 'Voter', 'Déposer son bulletin de vote'), + + -- ── REGISTRE / AGREMENT ─────────────────────────────────────────────────── + ('REGISTRE > AGREMENT > CREATE', 'REGISTRE', 'AGREMENT', 'CREATE', 'Enregistrer un agrément', 'Créer une accréditation professionnelle'), + ('REGISTRE > AGREMENT > READ', 'REGISTRE', 'AGREMENT', 'READ', 'Consulter les agréments', 'Voir les agréments et accréditations') + +) AS v(code, module, ressource, action, libelle, description) +WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE permissions.code = v.code); + +-- ============================================================================ +-- 3. Index sur les colonnes d'invitation +-- ============================================================================ +CREATE INDEX IF NOT EXISTS idx_mo_token_invitation ON membre_organisation (token_invitation); +CREATE INDEX IF NOT EXISTS idx_mo_date_invitation ON membre_organisation (date_invitation); diff --git a/src/main/resources/db/migration/V21__Add_Member_Lifecycle_Columns.sql b/src/main/resources/db/migration/V21__Add_Member_Lifecycle_Columns.sql new file mode 100644 index 0000000..7b500d0 --- /dev/null +++ b/src/main/resources/db/migration/V21__Add_Member_Lifecycle_Columns.sql @@ -0,0 +1,26 @@ +-- V21 : Colonnes cycle de vie membres (MemberLifecycleService) +-- Ajout de role_org sur membre_organisation (manquant de V20) +-- + index sur token_invitation (version courte 32 chars → VARCHAR(64)) + +-- Ajouter role_org si absent +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'membre_organisation' AND column_name = 'role_org' + ) THEN + ALTER TABLE membre_organisation ADD COLUMN role_org VARCHAR(50); + END IF; +END$$; + +-- Ajuster longueur token_invitation à 64 pour UUID sans tirets (32 chars) ou extensions futures +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'membre_organisation' AND column_name = 'token_invitation' + AND character_maximum_length = 255 + ) THEN + ALTER TABLE membre_organisation ALTER COLUMN token_invitation TYPE VARCHAR(64); + END IF; +END$$; diff --git a/src/main/resources/db/migration/V22__Bareme_Cotisation_Et_Auto_Generation.sql b/src/main/resources/db/migration/V22__Bareme_Cotisation_Et_Auto_Generation.sql new file mode 100644 index 0000000..2047882 --- /dev/null +++ b/src/main/resources/db/migration/V22__Bareme_Cotisation_Et_Auto_Generation.sql @@ -0,0 +1,31 @@ +-- V22 : Barème de cotisation par rôle + flag génération automatique mensuelle +-- Auteur : UnionFlow Team +-- Date : 2026-04-07 + +-- 1. Activation génération automatique dans les paramètres de cotisation +ALTER TABLE parametres_cotisation_organisation + ADD COLUMN IF NOT EXISTS generation_automatique_activee BOOLEAN NOT NULL DEFAULT FALSE; + +-- 2. Table des barèmes de cotisation par rôle fonctionnel +CREATE TABLE IF NOT EXISTS bareme_cotisation_role ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + organisation_id UUID NOT NULL, + role_org VARCHAR(50) NOT NULL, + montant_mensuel NUMERIC(12,2) NOT NULL DEFAULT 0.00, + montant_annuel NUMERIC(12,2) NOT NULL DEFAULT 0.00, + description VARCHAR(255), + -- BaseEntity columns + date_creation TIMESTAMP, + date_modification TIMESTAMP, + cree_par VARCHAR(255), + modifie_par VARCHAR(255), + version BIGINT DEFAULT 0, + actif BOOLEAN DEFAULT TRUE, + CONSTRAINT pk_bareme_cotisation_role PRIMARY KEY (id), + CONSTRAINT fk_bareme_cot_organisation + FOREIGN KEY (organisation_id) REFERENCES organisations(id) ON DELETE CASCADE, + CONSTRAINT uq_bareme_cot_org_role + UNIQUE (organisation_id, role_org) +); + +CREATE INDEX IF NOT EXISTS idx_bareme_cot_org ON bareme_cotisation_role (organisation_id); diff --git a/src/main/resources/db/migration/V23__Fix_SystemAlerts_Legacy_NotNull_Columns.sql b/src/main/resources/db/migration/V23__Fix_SystemAlerts_Legacy_NotNull_Columns.sql new file mode 100644 index 0000000..8077673 --- /dev/null +++ b/src/main/resources/db/migration/V23__Fix_SystemAlerts_Legacy_NotNull_Columns.sql @@ -0,0 +1,13 @@ +-- V23: Fix system_alerts legacy NOT NULL columns +-- +-- Contexte: L'entité SystemAlert a été refactorisée pour utiliser les colonnes +-- modernes (alert_type, level, title) ajoutées par hibernate.ddl-auto=update. +-- Les anciennes colonnes V1 (type_alerte, severite, titre) restaient NOT NULL +-- et provoquaient un ConstraintViolationException toutes les 60 secondes dans +-- AlertMonitoringService#monitorSystemMetrics. +-- +-- Fix: rendre ces colonnes obsolètes nullable pour que l'INSERT Hibernate réussisse. + +ALTER TABLE system_alerts ALTER COLUMN type_alerte DROP NOT NULL; +ALTER TABLE system_alerts ALTER COLUMN severite DROP NOT NULL; +ALTER TABLE system_alerts ALTER COLUMN titre DROP NOT NULL; diff --git a/src/test/java/dev/lions/unionflow/server/entity/SouscriptionQuotaOptionCTest.java b/src/test/java/dev/lions/unionflow/server/entity/SouscriptionQuotaOptionCTest.java new file mode 100644 index 0000000..0db83f1 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/entity/SouscriptionQuotaOptionCTest.java @@ -0,0 +1,331 @@ +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.TypeFormule; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests unitaires pour la logique de quota (Option C) et les champs de plan commercial. + * + *

Couvre : + *

    + *
  • {@link SouscriptionOrganisation#isActive()} — selon statut + dateFin
  • + *
  • {@link SouscriptionOrganisation#isQuotaDepasse()} — selon quotaMax et quotaUtilise
  • + *
  • {@link SouscriptionOrganisation#getPlacesRestantes()} — calcul des places libres
  • + *
  • {@link FormuleAbonnement#isIllimitee()} — maxMembres null = illimité
  • + *
  • {@link FormuleAbonnement#accepteNouveauMembre(int)} — contrôle admission
  • + *
  • Champs Option C (planCommercial, apiAccess, federationAccess, maxAdmins)
  • + *
+ */ +@DisplayName("SouscriptionOrganisation + FormuleAbonnement — Quota & Option C") +class SouscriptionQuotaOptionCTest { + + // ─── Builders ───────────────────────────────────────────────────────────── + + private static Organisation newOrganisation() { + Organisation o = new Organisation(); + o.setId(UUID.randomUUID()); + o.setNom("Association Test"); + o.setTypeOrganisation("ASSOCIATION"); + o.setStatut("ACTIVE"); + o.setEmail("test@asso.sn"); + return o; + } + + private static FormuleAbonnement newFormule(int maxMembres) { + FormuleAbonnement f = new FormuleAbonnement(); + f.setId(UUID.randomUUID()); + f.setCode(TypeFormule.STANDARD); + f.setPlage(PlageMembres.PETITE); + f.setLibelle("Standard Petite"); + f.setMaxMembres(maxMembres); + f.setPrixMensuel(BigDecimal.valueOf(7500)); + f.setPrixAnnuel(BigDecimal.valueOf(75000)); + return f; + } + + private static FormuleAbonnement newFormuleIllimitee() { + FormuleAbonnement f = new FormuleAbonnement(); + f.setId(UUID.randomUUID()); + f.setCode(TypeFormule.PREMIUM); + f.setPlage(PlageMembres.GRANDE); + f.setLibelle("Enterprise"); + f.setMaxMembres(null); // illimité + f.setPrixMensuel(BigDecimal.valueOf(30000)); + f.setPrixAnnuel(BigDecimal.valueOf(300000)); + return f; + } + + private static SouscriptionOrganisation newSouscription( + Organisation org, FormuleAbonnement formule, + StatutSouscription statut, LocalDate dateFin, + int quotaMax, int quotaUtilise) { + + SouscriptionOrganisation s = new SouscriptionOrganisation(); + s.setId(UUID.randomUUID()); + s.setOrganisation(org); + s.setFormule(formule); + s.setStatut(statut); + s.setDateDebut(LocalDate.now().minusMonths(1)); + s.setDateFin(dateFin); + s.setQuotaMax(quotaMax); + s.setQuotaUtilise(quotaUtilise); + return s; + } + + // ═════════════════════════════════════════════════════════════════════════ + // isActive() + // ═════════════════════════════════════════════════════════════════════════ + + @Test + @DisplayName("isActive: statut ACTIVE et dateFin future → true") + void isActive_activePlusFutureDateFin_returnsTrue() { + var s = newSouscription(newOrganisation(), newFormule(100), + StatutSouscription.ACTIVE, LocalDate.now().plusMonths(6), 100, 0); + + assertThat(s.isActive()).isTrue(); + } + + @Test + @DisplayName("isActive: statut ACTIVE mais dateFin hier → false (expirée)") + void isActive_activePlusExpiredDate_returnsFalse() { + var s = newSouscription(newOrganisation(), newFormule(100), + StatutSouscription.ACTIVE, LocalDate.now().minusDays(1), 100, 0); + + assertThat(s.isActive()).isFalse(); + } + + @Test + @DisplayName("isActive: statut SUSPENDUE → false") + void isActive_suspendue_returnsFalse() { + var s = newSouscription(newOrganisation(), newFormule(100), + StatutSouscription.SUSPENDUE, LocalDate.now().plusMonths(1), 100, 0); + + assertThat(s.isActive()).isFalse(); + } + + @Test + @DisplayName("isActive: statut EXPIREE → false") + void isActive_expiree_returnsFalse() { + var s = newSouscription(newOrganisation(), newFormule(100), + StatutSouscription.EXPIREE, LocalDate.now().plusMonths(1), 100, 0); + + assertThat(s.isActive()).isFalse(); + } + + @Test + @DisplayName("isActive: dateFin aujourd'hui → true (borne inclusive)") + void isActive_dateFin_today_returnsTrue() { + var s = newSouscription(newOrganisation(), newFormule(100), + StatutSouscription.ACTIVE, LocalDate.now(), 100, 0); + + assertThat(s.isActive()).isTrue(); + } + + // ═════════════════════════════════════════════════════════════════════════ + // isQuotaDepasse() + // ═════════════════════════════════════════════════════════════════════════ + + @Test + @DisplayName("isQuotaDepasse: quotaUtilise < quotaMax → false") + void isQuotaDepasse_utiliseInferieur_returnsFalse() { + var s = newSouscription(newOrganisation(), newFormule(100), + StatutSouscription.ACTIVE, LocalDate.now().plusMonths(1), 100, 50); + + assertThat(s.isQuotaDepasse()).isFalse(); + } + + @Test + @DisplayName("isQuotaDepasse: quotaUtilise == quotaMax → true (atteint)") + void isQuotaDepasse_utiliseEgalMax_returnsTrue() { + var s = newSouscription(newOrganisation(), newFormule(100), + StatutSouscription.ACTIVE, LocalDate.now().plusMonths(1), 100, 100); + + assertThat(s.isQuotaDepasse()).isTrue(); + } + + @Test + @DisplayName("isQuotaDepasse: quotaUtilise > quotaMax → true (dépassé)") + void isQuotaDepasse_utiliseSuperieur_returnsTrue() { + var s = newSouscription(newOrganisation(), newFormule(100), + StatutSouscription.ACTIVE, LocalDate.now().plusMonths(1), 100, 110); + + assertThat(s.isQuotaDepasse()).isTrue(); + } + + @Test + @DisplayName("isQuotaDepasse: quotaMax null (illimité) → false") + void isQuotaDepasse_quotaMaxNull_returnsFalse() { + var s = newSouscription(newOrganisation(), newFormuleIllimitee(), + StatutSouscription.ACTIVE, LocalDate.now().plusYears(1), 0, 500); + s.setQuotaMax(null); // explicitement illimité + + assertThat(s.isQuotaDepasse()).isFalse(); + } + + // ═════════════════════════════════════════════════════════════════════════ + // getPlacesRestantes() + // ═════════════════════════════════════════════════════════════════════════ + + @Test + @DisplayName("getPlacesRestantes: 100 max - 30 utilisés = 70 restantes") + void getPlacesRestantes_partialUsage_returnsCorrectValue() { + var s = newSouscription(newOrganisation(), newFormule(100), + StatutSouscription.ACTIVE, LocalDate.now().plusMonths(1), 100, 30); + + assertThat(s.getPlacesRestantes()).isEqualTo(70); + } + + @Test + @DisplayName("getPlacesRestantes: quota plein → 0 (jamais négatif)") + void getPlacesRestantes_quotaFull_returnsZero() { + var s = newSouscription(newOrganisation(), newFormule(100), + StatutSouscription.ACTIVE, LocalDate.now().plusMonths(1), 100, 100); + + assertThat(s.getPlacesRestantes()).isZero(); + } + + @Test + @DisplayName("getPlacesRestantes: quotaMax null (illimité) → Integer.MAX_VALUE") + void getPlacesRestantes_illimite_returnsMaxValue() { + var s = newSouscription(newOrganisation(), newFormuleIllimitee(), + StatutSouscription.ACTIVE, LocalDate.now().plusYears(1), 0, 0); + s.setQuotaMax(null); + + assertThat(s.getPlacesRestantes()).isEqualTo(Integer.MAX_VALUE); + } + + // ═════════════════════════════════════════════════════════════════════════ + // FormuleAbonnement — isIllimitee() + accepteNouveauMembre() + // ═════════════════════════════════════════════════════════════════════════ + + @Test + @DisplayName("FormuleAbonnement.isIllimitee: maxMembres null → true") + void formule_maxMembresNull_isIllimitee() { + var f = newFormuleIllimitee(); + assertThat(f.isIllimitee()).isTrue(); + } + + @Test + @DisplayName("FormuleAbonnement.isIllimitee: maxMembres défini → false") + void formule_maxMembresDefined_notIllimitee() { + var f = newFormule(200); + assertThat(f.isIllimitee()).isFalse(); + } + + @Test + @DisplayName("FormuleAbonnement.accepteNouveauMembre: quota non atteint → true") + void formule_accepteNouveauMembre_quotaNotReached_returnsTrue() { + var f = newFormule(100); + assertThat(f.accepteNouveauMembre(99)).isTrue(); + } + + @Test + @DisplayName("FormuleAbonnement.accepteNouveauMembre: quota atteint → false") + void formule_accepteNouveauMembre_quotaReached_returnsFalse() { + var f = newFormule(100); + assertThat(f.accepteNouveauMembre(100)).isFalse(); + } + + @Test + @DisplayName("FormuleAbonnement.accepteNouveauMembre: formule illimitée → toujours true") + void formule_accepteNouveauMembre_illimitee_alwaysTrue() { + var f = newFormuleIllimitee(); + assertThat(f.accepteNouveauMembre(10000)).isTrue(); + } + + // ═════════════════════════════════════════════════════════════════════════ + // Champs Option C sur FormuleAbonnement + // ═════════════════════════════════════════════════════════════════════════ + + @Test + @DisplayName("FormuleAbonnement Option C: plan ENTERPRISE — tous les droits activés") + void formule_enterprisePlan_allFeaturesEnabled() { + FormuleAbonnement f = new FormuleAbonnement(); + f.setCode(TypeFormule.PREMIUM); + f.setPlage(PlageMembres.GRANDE); + f.setLibelle("Enterprise"); + f.setMaxMembres(null); + f.setPrixMensuel(BigDecimal.valueOf(50000)); + f.setPrixAnnuel(BigDecimal.valueOf(500000)); + f.setPlanCommercial("ENTERPRISE"); + f.setNiveauReporting("AVANCE"); + f.setApiAccess(true); + f.setFederationAccess(true); + f.setSupportPrioritaire(true); + f.setSlaGaranti("99.9%"); + f.setMaxAdmins(null); // illimité + + assertThat(f.getPlanCommercial()).isEqualTo("ENTERPRISE"); + assertThat(f.getNiveauReporting()).isEqualTo("AVANCE"); + assertThat(f.getApiAccess()).isTrue(); + assertThat(f.getFederationAccess()).isTrue(); + assertThat(f.getSupportPrioritaire()).isTrue(); + assertThat(f.getSlaGaranti()).isEqualTo("99.9%"); + assertThat(f.getMaxAdmins()).isNull(); + } + + @Test + @DisplayName("FormuleAbonnement Option C: plan BASIC — accès limités") + void formule_basicPlan_limitedFeatures() { + FormuleAbonnement f = newFormule(50); + f.setPlanCommercial("MICRO"); + f.setNiveauReporting("BASIQUE"); + f.setApiAccess(false); + f.setFederationAccess(false); + f.setSupportPrioritaire(false); + f.setSlaGaranti("99.0%"); + f.setMaxAdmins(1); + + assertThat(f.getApiAccess()).isFalse(); + assertThat(f.getFederationAccess()).isFalse(); + assertThat(f.getSupportPrioritaire()).isFalse(); + assertThat(f.getMaxAdmins()).isEqualTo(1); + assertThat(f.getNiveauReporting()).isEqualTo("BASIQUE"); + } + + // ═════════════════════════════════════════════════════════════════════════ + // incrementerQuota() + decrementerQuota() + // ═════════════════════════════════════════════════════════════════════════ + + @Test + @DisplayName("incrementerQuota: 30 utilisés → 31 après incrémentation") + void incrementerQuota_increment() { + var s = newSouscription(newOrganisation(), newFormule(100), + StatutSouscription.ACTIVE, LocalDate.now().plusMonths(1), 100, 30); + + s.incrementerQuota(); + + assertThat(s.getQuotaUtilise()).isEqualTo(31); + } + + @Test + @DisplayName("decrementerQuota: 30 utilisés → 29 après décrémentation") + void decrementerQuota_decrement() { + var s = newSouscription(newOrganisation(), newFormule(100), + StatutSouscription.ACTIVE, LocalDate.now().plusMonths(1), 100, 30); + + s.decrementerQuota(); + + assertThat(s.getQuotaUtilise()).isEqualTo(29); + } + + @Test + @DisplayName("decrementerQuota: 0 utilisés → reste à 0 (pas négatif)") + void decrementerQuota_atZero_staysZero() { + var s = newSouscription(newOrganisation(), newFormule(100), + StatutSouscription.ACTIVE, LocalDate.now().plusMonths(1), 100, 0); + + s.decrementerQuota(); + + assertThat(s.getQuotaUtilise()).isZero(); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/resource/CompteAdherentResourceTest.java b/src/test/java/dev/lions/unionflow/server/resource/CompteAdherentResourceTest.java index cb7bd18..666fed5 100644 --- a/src/test/java/dev/lions/unionflow/server/resource/CompteAdherentResourceTest.java +++ b/src/test/java/dev/lions/unionflow/server/resource/CompteAdherentResourceTest.java @@ -225,6 +225,64 @@ class CompteAdherentResourceTest { verify(membreService, never()).activerMembre(any()); } + // ============================================================ + // Error cases + // ============================================================ + + @Test + @DisplayName("GET /api/membres/mon-compte sans authentification retourne 401") + void getMonCompte_sansAuthentification_returns401() { + given() + .when() + .get("/api/membres/mon-compte") + .then() + .statusCode(401); + } + + @Test + @TestSecurity(user = "membre@test.com", roles = {"MEMBRE"}) + @DisplayName("GET /api/membres/mon-compte retourne 500 quand le service lève une exception") + void getMonCompte_serviceException_returns500() { + when(compteAdherentService.getMonCompte()) + .thenThrow(new RuntimeException("Erreur base de données")); + + given() + .when() + .get("/api/membres/mon-compte") + .then() + .statusCode(500); + } + + @Test + @DisplayName("GET /api/membres/mon-statut sans authentification retourne 401") + void getMonStatut_sansAuthentification_returns401() { + given() + .when() + .get("/api/membres/mon-statut") + .then() + .statusCode(401); + } + + @Test + @TestSecurity(user = "membre@test.com", roles = {"MEMBRE"}) + @DisplayName("GET /api/membres/mon-statut retourne EN_ATTENTE_VALIDATION quand aucune org trouvée") + void getMonStatut_enAttenteMembreOrganisationAbsente_retourneEnAttente() { + UUID membreId = UUID.randomUUID(); + + Membre membre = membreFixture("membre@test.com", "EN_ATTENTE_VALIDATION"); + membre.setId(membreId); + + when(membreRepository.findByEmail("membre@test.com")).thenReturn(Optional.of(membre)); + when(membreOrganisationRepository.findFirstByMembreId(membreId)).thenReturn(Optional.empty()); + + given() + .when() + .get("/api/membres/mon-statut") + .then() + .statusCode(200) + .body("statutCompte", equalTo("EN_ATTENTE_VALIDATION")); + } + // ─── Helper ──────────────────────────────────────────────────────────────── private Membre membreFixture(String email, String statut) { diff --git a/src/test/java/dev/lions/unionflow/server/resource/CotisationResourceMockTest.java b/src/test/java/dev/lions/unionflow/server/resource/CotisationResourceMockTest.java index 96d979f..0fd32e1 100644 --- a/src/test/java/dev/lions/unionflow/server/resource/CotisationResourceMockTest.java +++ b/src/test/java/dev/lions/unionflow/server/resource/CotisationResourceMockTest.java @@ -8,7 +8,6 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; import dev.lions.unionflow.server.api.dto.cotisation.response.CotisationResponse; -import dev.lions.unionflow.server.api.dto.cotisation.response.CotisationSummaryResponse; import dev.lions.unionflow.server.service.CotisationService; import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; @@ -520,12 +519,15 @@ class CotisationResourceMockTest { @TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"}) @DisplayName("GET /api/cotisations/public retourne 200 avec données mockées") void getCotisationsPublic_withMockedData_returns200() { - CotisationSummaryResponse summary = new CotisationSummaryResponse( - UUID.randomUUID(), "COT-001", "Jean Dupont", - new BigDecimal("5000"), new BigDecimal("0"), - "EN_ATTENTE", "En attente", LocalDate.now().plusMonths(1), - LocalDate.now().getYear(), Boolean.TRUE - ); + CotisationResponse summary = CotisationResponse.builder() + .numeroReference("COT-001") + .nomMembre("Jean Dupont") + .montantDu(new BigDecimal("5000")) + .montantPaye(new BigDecimal("0")) + .statut("EN_ATTENTE") + .statutLibelle("En attente") + .dateEcheance(LocalDate.now().plusMonths(1)) + .build(); when(cotisationService.getAllCotisations(anyInt(), anyInt())) .thenReturn(List.of(summary)); when(cotisationService.getStatistiquesCotisations()) @@ -552,12 +554,15 @@ class CotisationResourceMockTest { @TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"}) @DisplayName("GET /api/cotisations/public avec totalCotisations null utilise content.size() (branche ligne 82)") void getCotisationsPublic_totalCotisationsNull_usesContentSize() { - CotisationSummaryResponse summary = new CotisationSummaryResponse( - UUID.randomUUID(), "COT-002", "Marie Dupont", - new BigDecimal("3000"), new BigDecimal("0"), - "EN_ATTENTE", "En attente", LocalDate.now().plusMonths(2), - LocalDate.now().getYear(), Boolean.FALSE - ); + CotisationResponse summary = CotisationResponse.builder() + .numeroReference("COT-002") + .nomMembre("Marie Dupont") + .montantDu(new BigDecimal("3000")) + .montantPaye(new BigDecimal("0")) + .statut("EN_ATTENTE") + .statutLibelle("En attente") + .dateEcheance(LocalDate.now().plusMonths(2)) + .build(); when(cotisationService.getAllCotisations(anyInt(), anyInt())) .thenReturn(List.of(summary)); // getStatistiquesCotisations() retourne une map SANS "totalCotisations" diff --git a/src/test/java/dev/lions/unionflow/server/resource/DashboardWebSocketEndpointTest.java b/src/test/java/dev/lions/unionflow/server/resource/DashboardWebSocketEndpointTest.java index 45bdee3..7bfaa7c 100644 --- a/src/test/java/dev/lions/unionflow/server/resource/DashboardWebSocketEndpointTest.java +++ b/src/test/java/dev/lions/unionflow/server/resource/DashboardWebSocketEndpointTest.java @@ -66,4 +66,35 @@ class DashboardWebSocketEndpointTest { endpoint.onClose(connection); // pas d'exception attendue } + + @Test + @DisplayName("onMessage avec message vide retourne ack") + void onMessage_emptyMessage_returnsAck() { + String result = endpoint.onMessage("", connection); + assertThat(result).isNotNull(); + assertThat(result).contains("ack"); + } + + @Test + @DisplayName("onMessage avec null retourne une réponse non nulle") + void onMessage_nullMessage_returnsNonNull() { + String result = endpoint.onMessage(null, connection); + assertThat(result).isNotNull(); + } + + @Test + @DisplayName("onOpen avec connection id null ne lève pas d'exception") + void onOpen_connectionIdNull_doesNotThrow() { + when(connection.id()).thenReturn(null); + String result = endpoint.onOpen(connection); + assertThat(result).isNotNull(); + assertThat(result).contains("connected"); + } + + @Test + @DisplayName("onMessage avec JSON inconnu retourne ack") + void onMessage_unknownJson_returnsAck() { + String result = endpoint.onMessage("{\"type\":\"unknown\",\"data\":{}}", connection); + assertThat(result).isNotNull(); + } } diff --git a/src/test/java/dev/lions/unionflow/server/resource/DemandeAideMockResourceTest.java b/src/test/java/dev/lions/unionflow/server/resource/DemandeAideMockResourceTest.java index 8f17c57..b9dbc13 100644 --- a/src/test/java/dev/lions/unionflow/server/resource/DemandeAideMockResourceTest.java +++ b/src/test/java/dev/lions/unionflow/server/resource/DemandeAideMockResourceTest.java @@ -155,6 +155,60 @@ class DemandeAideMockResourceTest { when(membreRepository.findByEmail(anyString())).thenReturn(Optional.of(membre)); } + // ------------------------------------------------------------------------- + // Error cases + // ------------------------------------------------------------------------- + + @Test + @DisplayName("GET /api/demandes-aide/mes sans authentification retourne 401") + void mesDemandes_sansAuthentification_returns401() { + given() + .when() + .get("/api/demandes-aide/mes") + .then() + .statusCode(401); + } + + @Test + @DisplayName("GET /api/demandes-aide sans authentification retourne 401") + void listerToutes_sansAuthentification_returns401() { + given() + .when() + .get("/api/demandes-aide") + .then() + .statusCode(401); + } + + @Test + @TestSecurity(user = "membre@test.com", roles = {"MEMBRE"}) + @DisplayName("GET /api/demandes-aide/{id} avec ID inexistant retourne 404") + void obtenirParId_notFound_returns404() { + UUID id = UUID.randomUUID(); + when(demandeAideService.obtenirParId(any(UUID.class))).thenReturn(null); + + given() + .pathParam("id", id) + .when() + .get("/api/demandes-aide/{id}") + .then() + .statusCode(404); + } + + @Test + @TestSecurity(user = "membre@test.com", roles = {"MEMBRE"}) + @DisplayName("PUT /api/demandes-aide/{id}/approuver avec rôle MEMBRE retourne 403") + void approuver_avecRoleMembre_returns403() { + UUID id = UUID.randomUUID(); + + given() + .queryParam("motif", "Test") + .pathParam("id", id) + .when() + .put("/api/demandes-aide/{id}/approuver") + .then() + .statusCode(403); + } + // ------------------------------------------------------------------------- // obtenirParId — response != null (found) // ------------------------------------------------------------------------- diff --git a/src/test/java/dev/lions/unionflow/server/resource/ExportResourceTest.java b/src/test/java/dev/lions/unionflow/server/resource/ExportResourceTest.java index 061d756..edc7592 100644 --- a/src/test/java/dev/lions/unionflow/server/resource/ExportResourceTest.java +++ b/src/test/java/dev/lions/unionflow/server/resource/ExportResourceTest.java @@ -251,6 +251,47 @@ class ExportResourceTest { .header("Content-Disposition", containsString("rapport-2026-03.pdf")); } + // ------------------------------------------------------------------------- + // Error cases + // ------------------------------------------------------------------------- + + @Test + @DisplayName("GET /api/export/cotisations/csv sans authentification retourne 401") + void exporterCotisationsCSV_sansAuthentification_returns401() { + given() + .when() + .get("/api/export/cotisations/csv") + .then() + .statusCode(401); + } + + @Test + @DisplayName("GET /api/export/rapport/mensuel sans authentification retourne 401") + void genererRapportMensuel_sansAuthentification_returns401() { + given() + .queryParam("annee", 2026) + .queryParam("mois", 3) + .when() + .get("/api/export/rapport/mensuel") + .then() + .statusCode(401); + } + + @Test + @TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"}) + @DisplayName("GET /api/export/cotisations/{id}/recu/pdf quand service lève exception retourne 500") + void genererRecuPDF_serviceException_returns500() { + when(exportService.genererRecuPaiementPDF(org.mockito.ArgumentMatchers.any(UUID.class))) + .thenThrow(new RuntimeException("Cotisation introuvable")); + + given() + .pathParam("cotisationId", COTISATION_ID) + .when() + .get("/api/export/cotisations/{cotisationId}/recu/pdf") + .then() + .statusCode(500); + } + @Test @TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"}) @DisplayName("GET /api/export/rapport/mensuel/pdf avec associationId retourne 200") diff --git a/src/test/java/dev/lions/unionflow/server/resource/FinanceWorkflowResourceTest.java b/src/test/java/dev/lions/unionflow/server/resource/FinanceWorkflowResourceTest.java index 1b10c65..d7863a4 100644 --- a/src/test/java/dev/lions/unionflow/server/resource/FinanceWorkflowResourceTest.java +++ b/src/test/java/dev/lions/unionflow/server/resource/FinanceWorkflowResourceTest.java @@ -208,4 +208,52 @@ class FinanceWorkflowResourceTest { .body("format", equalTo("csv")); } + // ------------------------------------------------------------------------- + // Error cases + // ------------------------------------------------------------------------- + + @Test + @DisplayName("GET /api/finance/stats sans authentification retourne 401") + void getStats_sansAuthentification_returns401() { + given() + .when() + .get(BASE_PATH + "/stats") + .then() + .statusCode(401); + } + + @Test + @DisplayName("GET /api/finance/audit-logs sans authentification retourne 401") + void getAuditLogs_sansAuthentification_returns401() { + given() + .when() + .get(BASE_PATH + "/audit-logs") + .then() + .statusCode(401); + } + + @Test + @TestSecurity(user = "membre@test.com", roles = {"MEMBRE"}) + @DisplayName("GET /api/finance/stats avec rôle MEMBRE retourne 403") + void getStats_avecRoleMembre_returns403() { + given() + .when() + .get(BASE_PATH + "/stats") + .then() + .statusCode(403); + } + + @Test + @TestSecurity(user = "membre@test.com", roles = {"MEMBRE"}) + @DisplayName("POST /api/finance/audit-logs/export avec rôle MEMBRE retourne 403") + void exportAuditLogs_avecRoleMembre_returns403() { + given() + .contentType(ContentType.JSON) + .body("{\"organizationId\":\"00000000-0000-0000-0000-000000000001\"}") + .when() + .post(BASE_PATH + "/audit-logs/export") + .then() + .statusCode(403); + } + } diff --git a/src/test/java/dev/lions/unionflow/server/resource/HealthResourceTest.java b/src/test/java/dev/lions/unionflow/server/resource/HealthResourceTest.java index e4bbd0c..306e614 100644 --- a/src/test/java/dev/lions/unionflow/server/resource/HealthResourceTest.java +++ b/src/test/java/dev/lions/unionflow/server/resource/HealthResourceTest.java @@ -26,4 +26,38 @@ class HealthResourceTest { .body("timestamp", notNullValue()) .body("message", equalTo("Serveur opérationnel")); } + + @Test + @DisplayName("GET /api/status avec Accept inconnu retourne 200 (endpoint toujours accessible)") + void getStatus_acceptHeaderDifferent_returns200() { + given() + .header("Accept", "application/json") + .when() + .get("/api/status") + .then() + .statusCode(200) + .body("status", equalTo("UP")); + } + + @Test + @DisplayName("GET /api/status/inexistant retourne 404") + void getStatusInexistant_returns404() { + given() + .when() + .get("/api/status/inexistant") + .then() + .statusCode(404); + } + + @Test + @DisplayName("POST /api/status retourne 405 (méthode non autorisée)") + void postStatus_returns405() { + given() + .contentType(ContentType.JSON) + .body("{}") + .when() + .post("/api/status") + .then() + .statusCode(405); + } } diff --git a/src/test/java/dev/lions/unionflow/server/resource/LogsMonitoringResourceCoverageTest.java b/src/test/java/dev/lions/unionflow/server/resource/LogsMonitoringResourceCoverageTest.java index 5c677ab..10ce7ea 100644 --- a/src/test/java/dev/lions/unionflow/server/resource/LogsMonitoringResourceCoverageTest.java +++ b/src/test/java/dev/lions/unionflow/server/resource/LogsMonitoringResourceCoverageTest.java @@ -57,4 +57,46 @@ class LogsMonitoringResourceCoverageTest { .statusCode(200) .body(containsString("Stack trace ligne 42")); } + + // ------------------------------------------------------------------------- + // Error cases + // ------------------------------------------------------------------------- + + @Test + @DisplayName("GET /api/logs/export sans authentification retourne 401") + void exportLogs_sansAuthentification_returns401() { + given() + .queryParam("level", "ERROR") + .when() + .get("/api/logs/export") + .then() + .statusCode(401); + } + + @Test + @TestSecurity(user = "membre@test.com", roles = {"MEMBRE"}) + @DisplayName("GET /api/logs/export avec rôle MEMBRE retourne 403") + void exportLogs_avecRoleMembre_returns403() { + given() + .queryParam("level", "ERROR") + .when() + .get("/api/logs/export") + .then() + .statusCode(403); + } + + @Test + @TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"}) + @DisplayName("GET /api/logs/export quand service lève exception retourne 500") + void exportLogs_serviceException_returns500() { + when(logsMonitoringService.searchLogs(any(LogSearchRequest.class))) + .thenThrow(new RuntimeException("Erreur de lecture des logs")); + + given() + .queryParam("level", "ERROR") + .when() + .get("/api/logs/export") + .then() + .statusCode(500); + } } diff --git a/src/test/java/dev/lions/unionflow/server/resource/MembreDashboardMockResourceTest.java b/src/test/java/dev/lions/unionflow/server/resource/MembreDashboardMockResourceTest.java index b7decc2..d45b191 100644 --- a/src/test/java/dev/lions/unionflow/server/resource/MembreDashboardMockResourceTest.java +++ b/src/test/java/dev/lions/unionflow/server/resource/MembreDashboardMockResourceTest.java @@ -58,4 +58,43 @@ class MembreDashboardMockResourceTest { .then() .statusCode(200); } + + // ========================================================================= + // Error cases + // ========================================================================= + + @Test + @DisplayName("GET /api/dashboard/membre/me sans authentification retourne 401") + void getMonDashboard_sansAuthentification_returns401() { + given() + .when() + .get("/api/dashboard/membre/me") + .then() + .statusCode(anyOf(equalTo(401), equalTo(403))); + } + + @Test + @TestSecurity(user = "membre@unionflow.com", roles = {"MEMBRE"}) + @DisplayName("GET /api/dashboard/membre/me quand service lève exception retourne 500") + void getMonDashboard_serviceException_returns500() { + when(dashboardService.getDashboardData()) + .thenThrow(new RuntimeException("Erreur interne dashboard")); + + given() + .when() + .get("/api/dashboard/membre/me") + .then() + .statusCode(500); + } + + @Test + @TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"}) + @DisplayName("GET /api/dashboard/membre/me avec rôle ADMIN retourne 403") + void getMonDashboard_avecRoleAdmin_returns403() { + given() + .when() + .get("/api/dashboard/membre/me") + .then() + .statusCode(403); + } } diff --git a/src/test/java/dev/lions/unionflow/server/resource/MembreResourceLifecycleRbacTest.java b/src/test/java/dev/lions/unionflow/server/resource/MembreResourceLifecycleRbacTest.java new file mode 100644 index 0000000..0131c9c --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/resource/MembreResourceLifecycleRbacTest.java @@ -0,0 +1,371 @@ +package dev.lions.unionflow.server.resource; + +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.entity.Organisation; +import dev.lions.unionflow.server.repository.MembreOrganisationRepository; +import dev.lions.unionflow.server.service.MemberLifecycleService; +import dev.lions.unionflow.server.service.MembreKeycloakSyncService; +import dev.lions.unionflow.server.service.MembreService; +import dev.lions.unionflow.server.service.MembreSuiviService; +import dev.lions.unionflow.server.service.OrganisationService; +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.notNullValue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests RBAC pour les endpoints lifecycle du cycle de vie des membres. + * + *

Vérifie que : + *

    + *
  • MEMBRE ne peut pas activer/suspendre/radier (403)
  • + *
  • ADMIN_ORGANISATION peut activer/suspendre/radier (200)
  • + *
  • SUPER_ADMIN peut accéder à tous les endpoints admin (200)
  • + *
  • accepter-invitation est PermitAll (accessible sans auth)
  • + *
+ */ +@QuarkusTest +@DisplayName("MembreResource — RBAC lifecycle endpoints") +class MembreResourceLifecycleRbacTest { + + @InjectMock + MembreService membreService; + + @InjectMock + MembreKeycloakSyncService keycloakSyncService; + + @InjectMock + MembreSuiviService membreSuiviService; + + @InjectMock + OrganisationService organisationService; + + @InjectMock + MemberLifecycleService memberLifecycleService; + + @InjectMock + MembreOrganisationRepository membreOrgRepository; + + @InjectMock + org.eclipse.microprofile.jwt.JsonWebToken jwt; + + // ─── Helper ────────────────────────────────────────────────────────────── + + private MembreOrganisation buildLien(UUID id, StatutMembre statut) { + Membre membre = new Membre(); + membre.setId(UUID.randomUUID()); + membre.setNom("Test"); + membre.setPrenom("User"); + + Organisation org = new Organisation(); + org.setId(UUID.randomUUID()); + org.setNom("Org Test"); + + MembreOrganisation lien = MembreOrganisation.builder() + .membre(membre) + .organisation(org) + .statutMembre(statut) + .build(); + lien.setId(id); + return lien; + } + + // ═════════════════════════════════════════════════════════════════════════ + // activer-adhesion (PUT /{membreOrgId}/activer-adhesion) + // ═════════════════════════════════════════════════════════════════════════ + + @Test + @TestSecurity(user = "membre@test.com", roles = {"MEMBRE"}) + @DisplayName("PUT activer-adhesion: MEMBRE → 403 Forbidden") + void activerAdhesion_membreRole_returns403() { + given() + .contentType(ContentType.JSON) + .body(Map.of("motif", "test")) + .when() + .put("/api/membres/" + UUID.randomUUID() + "/activer-adhesion") + .then() + .statusCode(403); + } + + @Test + @TestSecurity(user = "admin@test.com", roles = {"ADMIN_ORGANISATION"}) + @DisplayName("PUT activer-adhesion: ADMIN_ORGANISATION → 200") + void activerAdhesion_adminOrganisation_returns200() { + UUID membreOrgId = UUID.randomUUID(); + var lien = buildLien(membreOrgId, StatutMembre.EN_ATTENTE_VALIDATION); + lien.setStatutMembre(StatutMembre.ACTIF); // Résultat après activation + + when(memberLifecycleService.activerMembre(any(UUID.class), any(), any())) + .thenReturn(lien); + when(jwt.getSubject()).thenReturn(UUID.randomUUID().toString()); + + given() + .contentType(ContentType.JSON) + .body(Map.of("motif", "validation")) + .when() + .put("/api/membres/" + membreOrgId + "/activer-adhesion") + .then() + .statusCode(200); + } + + @Test + @TestSecurity(user = "superadmin@test.com", roles = {"SUPER_ADMIN"}) + @DisplayName("PUT activer-adhesion: SUPER_ADMIN → 200") + void activerAdhesion_superAdmin_returns200() { + UUID membreOrgId = UUID.randomUUID(); + var lien = buildLien(membreOrgId, StatutMembre.ACTIF); + + when(memberLifecycleService.activerMembre(any(UUID.class), any(), any())) + .thenReturn(lien); + when(jwt.getSubject()).thenReturn(UUID.randomUUID().toString()); + + given() + .contentType(ContentType.JSON) + .body(Map.of()) + .when() + .put("/api/membres/" + membreOrgId + "/activer-adhesion") + .then() + .statusCode(200); + } + + // ═════════════════════════════════════════════════════════════════════════ + // suspendre-adhesion (PUT /{membreOrgId}/suspendre-adhesion) + // ═════════════════════════════════════════════════════════════════════════ + + @Test + @TestSecurity(user = "membre@test.com", roles = {"MEMBRE"}) + @DisplayName("PUT suspendre-adhesion: MEMBRE → 403 Forbidden") + void suspendrAdhesion_membreRole_returns403() { + given() + .contentType(ContentType.JSON) + .body(Map.of("motif", "test")) + .when() + .put("/api/membres/" + UUID.randomUUID() + "/suspendre-adhesion") + .then() + .statusCode(403); + } + + @Test + @TestSecurity(user = "admin@test.com", roles = {"ADMIN_ORGANISATION"}) + @DisplayName("PUT suspendre-adhesion: ADMIN_ORGANISATION → 200") + void suspendrAdhesion_adminOrganisation_returns200() { + UUID membreOrgId = UUID.randomUUID(); + var lien = buildLien(membreOrgId, StatutMembre.SUSPENDU); + + when(memberLifecycleService.suspendreMembre(any(UUID.class), any(), any())) + .thenReturn(lien); + when(jwt.getSubject()).thenReturn(UUID.randomUUID().toString()); + + given() + .contentType(ContentType.JSON) + .body(Map.of("motif", "manquements répétés")) + .when() + .put("/api/membres/" + membreOrgId + "/suspendre-adhesion") + .then() + .statusCode(200); + } + + // ═════════════════════════════════════════════════════════════════════════ + // radier-adhesion (PUT /{membreOrgId}/radier-adhesion) + // ═════════════════════════════════════════════════════════════════════════ + + @Test + @TestSecurity(user = "membre@test.com", roles = {"MEMBRE"}) + @DisplayName("PUT radier-adhesion: MEMBRE → 403 Forbidden") + void radierAdhesion_membreRole_returns403() { + given() + .contentType(ContentType.JSON) + .body(Map.of()) + .when() + .put("/api/membres/" + UUID.randomUUID() + "/radier-adhesion") + .then() + .statusCode(403); + } + + @Test + @TestSecurity(user = "admin@test.com", roles = {"ADMIN"}) + @DisplayName("PUT radier-adhesion: ADMIN → 200") + void radierAdhesion_admin_returns200() { + UUID membreOrgId = UUID.randomUUID(); + var lien = buildLien(membreOrgId, StatutMembre.RADIE); + + when(memberLifecycleService.radierMembre(any(UUID.class), any(), any())) + .thenReturn(lien); + when(jwt.getSubject()).thenReturn(UUID.randomUUID().toString()); + + given() + .contentType(ContentType.JSON) + .body(Map.of("motif", "exclusion définitive")) + .when() + .put("/api/membres/" + membreOrgId + "/radier-adhesion") + .then() + .statusCode(200); + } + + // ═════════════════════════════════════════════════════════════════════════ + // inviter-organisation (PUT /{membreId}/inviter-organisation) + // ═════════════════════════════════════════════════════════════════════════ + + @Test + @TestSecurity(user = "user@test.com", roles = {"USER"}) + @DisplayName("PUT inviter-organisation: USER → 403 Forbidden") + void inviterOrganisation_userRole_returns403() { + given() + .contentType(ContentType.JSON) + .queryParam("organisationId", UUID.randomUUID().toString()) + .when() + .put("/api/membres/" + UUID.randomUUID() + "/inviter-organisation") + .then() + .statusCode(403); + } + + @Test + @TestSecurity(user = "admin@test.com", roles = {"ADMIN_ORGANISATION"}) + @DisplayName("PUT inviter-organisation: ADMIN_ORGANISATION avec membre inexistant → 404") + void inviterOrganisation_adminOrganisation_membreNotFound_returns404() { + UUID membreId = UUID.randomUUID(); + UUID orgId = UUID.randomUUID(); + + when(membreService.trouverParId(membreId)).thenReturn(Optional.empty()); + + given() + .contentType(ContentType.JSON) + .queryParam("organisationId", orgId.toString()) + .when() + .put("/api/membres/" + membreId + "/inviter-organisation") + .then() + .statusCode(404); + } + + // ═════════════════════════════════════════════════════════════════════════ + // accepter-invitation (POST /accepter-invitation/{token}) — PermitAll + // ═════════════════════════════════════════════════════════════════════════ + + @Test + @DisplayName("POST accepter-invitation: sans authentification → PermitAll (token invalide → 400 ou 404)") + void accepterInvitation_noAuth_permitAll() { + // Pas d'auth — le endpoint est PermitAll + when(memberLifecycleService.accepterInvitation(anyString())) + .thenThrow(new IllegalArgumentException("Invitation introuvable ou déjà utilisée.")); + + given() + .contentType(ContentType.JSON) + .when() + .post("/api/membres/accepter-invitation/invalidtoken123") + .then() + .statusCode(400); // BadRequest pour IllegalArgumentException + } + + @Test + @TestSecurity(user = "membre@test.com", roles = {"MEMBRE"}) + @DisplayName("POST accepter-invitation: MEMBRE avec token valide → 200") + void accepterInvitation_membre_validToken_returns200() { + String token = "validtoken1234567890abcdefghi12"; + var lien = buildLien(UUID.randomUUID(), StatutMembre.EN_ATTENTE_VALIDATION); + + when(memberLifecycleService.accepterInvitation(token)).thenReturn(lien); + + given() + .contentType(ContentType.JSON) + .when() + .post("/api/membres/accepter-invitation/" + token) + .then() + .statusCode(200) + .body("statut", notNullValue()); + } + + // ═════════════════════════════════════════════════════════════════════════ + // adhesion statut (GET /{membreId}/adhesion) — lecture multi-rôle + // ═════════════════════════════════════════════════════════════════════════ + + @Test + @TestSecurity(user = "membre@test.com", roles = {"MEMBRE"}) + @DisplayName("GET /{membreId}/adhesion: MEMBRE peut consulter son statut (200 ou 404)") + void getAdhesionStatut_membreRole_allowedToRead() { + UUID membreId = UUID.randomUUID(); + UUID orgId = UUID.randomUUID(); + + when(membreOrgRepository.findByMembreIdAndOrganisationId(any(), any())) + .thenReturn(Optional.empty()); + + given() + .queryParam("organisationId", orgId.toString()) + .when() + .get("/api/membres/" + membreId + "/adhesion") + .then() + .statusCode(404); // Not found, mais pas 403 + } + + @Test + @TestSecurity(user = "admin@test.com", roles = {"ADMIN_ORGANISATION"}) + @DisplayName("GET /{membreId}/adhesion: ADMIN_ORGANISATION peut consulter (200)") + void getAdhesionStatut_adminOrganisation_returns200() { + UUID membreId = UUID.randomUUID(); + UUID orgId = UUID.randomUUID(); + var lien = buildLien(UUID.randomUUID(), StatutMembre.ACTIF); + + when(membreOrgRepository.findByMembreIdAndOrganisationId(any(), any())) + .thenReturn(Optional.of(lien)); + + given() + .queryParam("organisationId", orgId.toString()) + .when() + .get("/api/membres/" + membreId + "/adhesion") + .then() + .statusCode(200) + .body("statut", notNullValue()); + } + + // ═════════════════════════════════════════════════════════════════════════ + // archiver-adhesion (PUT /{membreOrgId}/archiver-adhesion) + // ═════════════════════════════════════════════════════════════════════════ + + @Test + @TestSecurity(user = "membre@test.com", roles = {"MEMBRE"}) + @DisplayName("PUT archiver-adhesion: MEMBRE → 403 Forbidden") + void archiverAdhesion_membreRole_returns403() { + given() + .contentType(ContentType.JSON) + .body(Map.of()) + .when() + .put("/api/membres/" + UUID.randomUUID() + "/archiver-adhesion") + .then() + .statusCode(403); + } + + @Test + @TestSecurity(user = "admin@test.com", roles = {"ADMIN_ORGANISATION"}) + @DisplayName("PUT archiver-adhesion: ADMIN_ORGANISATION → 200") + void archiverAdhesion_adminOrganisation_returns200() { + UUID membreOrgId = UUID.randomUUID(); + var lien = buildLien(membreOrgId, StatutMembre.ARCHIVE); + + when(memberLifecycleService.archiverMembre(any(UUID.class), any())) + .thenReturn(lien); + + given() + .contentType(ContentType.JSON) + .body(Map.of("motif", "archivage annuel")) + .when() + .put("/api/membres/" + membreOrgId + "/archiver-adhesion") + .then() + .statusCode(200); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/resource/OrganisationResourceLambdaFilterTest.java b/src/test/java/dev/lions/unionflow/server/resource/OrganisationResourceLambdaFilterTest.java index 28e3637..bbc23a5 100644 --- a/src/test/java/dev/lions/unionflow/server/resource/OrganisationResourceLambdaFilterTest.java +++ b/src/test/java/dev/lions/unionflow/server/resource/OrganisationResourceLambdaFilterTest.java @@ -303,4 +303,47 @@ class OrganisationResourceLambdaFilterTest { .then() .statusCode(200); } + + // ========================================================================= + // Error cases + // ========================================================================= + + @Test + @DisplayName("GET /api/organisations sans authentification retourne 401") + void listerOrganisations_sansAuthentification_returns401() { + given() + .when() + .get("/api/organisations") + .then() + .statusCode(401); + } + + @Test + @TestSecurity(user = "orgadmin@test.com", roles = {"ADMIN_ORGANISATION"}) + @DisplayName("GET /api/organisations quand service lève exception retourne 500") + void listerOrganisations_serviceException_returns500() { + when(organisationService.listerOrganisationsPourUtilisateur(any())) + .thenThrow(new RuntimeException("Erreur base de données")); + + given() + .when() + .get("/api/organisations") + .then() + .statusCode(500); + } + + @Test + @TestSecurity(user = "orgadmin@test.com", roles = {"ADMIN_ORGANISATION"}) + @DisplayName("GET /api/organisations/{id} avec ID inexistant retourne 404") + void obtenirOrganisation_notFound_returns404() { + when(organisationService.trouverParId(any(UUID.class))) + .thenReturn(java.util.Optional.empty()); + + given() + .pathParam("id", UUID.randomUUID()) + .when() + .get("/api/organisations/{id}") + .then() + .statusCode(404); + } } diff --git a/src/test/java/dev/lions/unionflow/server/resource/OrganisationResourceMissingBranchesTest.java b/src/test/java/dev/lions/unionflow/server/resource/OrganisationResourceMissingBranchesTest.java index 8cdcc6d..6a54884 100644 --- a/src/test/java/dev/lions/unionflow/server/resource/OrganisationResourceMissingBranchesTest.java +++ b/src/test/java/dev/lions/unionflow/server/resource/OrganisationResourceMissingBranchesTest.java @@ -214,4 +214,50 @@ class OrganisationResourceMissingBranchesTest { assertThat(result).isNotNull(); } + + // ========================================================================= + // Error cases + // ========================================================================= + + @Test + @TestSecurity(user = "membre@test.com", roles = {"MEMBRE"}) + @DisplayName("listerMesOrganisations — service lève exception → exception propagée") + void listerMesOrganisations_serviceException_propagee() { + Principal principal = () -> "membre@test.com"; + when(securityIdentity.getPrincipal()).thenReturn(principal); + when(organisationService.listerOrganisationsPourUtilisateur(anyString())) + .thenThrow(new RuntimeException("Erreur base de données")); + + org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, + () -> organisationResource.listerMesOrganisations()); + } + + @Test + @TestSecurity(user = "superadmin@test.com", roles = {"SUPER_ADMIN"}) + @DisplayName("listerOrganisations — SUPER_ADMIN + service retourne liste vide → résultat non null") + void listerOrganisations_superAdmin_listeVide_retourneResultatNonNull() { + when(securityIdentity.getRoles()).thenReturn(Set.of("SUPER_ADMIN")); + when(organisationService.listerOrganisationsActives(anyInt(), anyInt())).thenReturn(List.of()); + when(organisationService.compterOrganisationsActives()).thenReturn(0L); + + PagedResponse result = organisationResource.listerOrganisations( + 0, 20, null); + + assertThat(result).isNotNull(); + assertThat(result.getData()).isEmpty(); + } + + @Test + @TestSecurity(user = "orgadmin@test.com", roles = {"ADMIN_ORGANISATION"}) + @DisplayName("listerOrganisations — ADMIN_ORGANISATION + service lève exception → exception propagée") + void listerOrganisations_adminOrg_serviceException_propagee() { + when(securityIdentity.getRoles()).thenReturn(Set.of("ADMIN_ORGANISATION")); + Principal principal = () -> "orgadmin@test.com"; + when(securityIdentity.getPrincipal()).thenReturn(principal); + when(organisationService.listerOrganisationsPourUtilisateur(anyString())) + .thenThrow(new RuntimeException("Accès base de données impossible")); + + org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, + () -> organisationResource.listerOrganisations(0, 20, null)); + } } diff --git a/src/test/java/dev/lions/unionflow/server/resource/PreferencesResourceTest.java b/src/test/java/dev/lions/unionflow/server/resource/PreferencesResourceTest.java index 1429ac7..5e29ab1 100644 --- a/src/test/java/dev/lions/unionflow/server/resource/PreferencesResourceTest.java +++ b/src/test/java/dev/lions/unionflow/server/resource/PreferencesResourceTest.java @@ -63,4 +63,46 @@ class PreferencesResourceTest { .then() .statusCode(200); } + + // ------------------------------------------------------------------------- + // Error cases + // ------------------------------------------------------------------------- + + @Test + @DisplayName("GET /api/preferences/{id} sans authentification retourne 401") + void obtenirPreferences_sansAuthentification_returns401() { + given() + .pathParam("utilisateurId", UUID.randomUUID()) + .when() + .get("/api/preferences/{utilisateurId}") + .then() + .statusCode(401); + } + + @Test + @TestSecurity(user = "membre@unionflow.com", roles = { "MEMBRE" }) + @DisplayName("PUT /api/preferences/{id} avec rôle MEMBRE retourne 403") + void mettreAJourPreferences_avecRoleMembre_returns403() { + given() + .pathParam("utilisateurId", UUID.randomUUID()) + .contentType(ContentType.JSON) + .body("{}") + .when() + .put("/api/preferences/{utilisateurId}") + .then() + .statusCode(403); + } + + @Test + @DisplayName("PUT /api/preferences/{id} sans authentification retourne 401") + void mettreAJourPreferences_sansAuthentification_returns401() { + given() + .pathParam("utilisateurId", UUID.randomUUID()) + .contentType(ContentType.JSON) + .body("{}") + .when() + .put("/api/preferences/{utilisateurId}") + .then() + .statusCode(401); + } } diff --git a/src/test/java/dev/lions/unionflow/server/resource/PropositionAideMockResourceTest.java b/src/test/java/dev/lions/unionflow/server/resource/PropositionAideMockResourceTest.java index 9530941..f33f3ab 100644 --- a/src/test/java/dev/lions/unionflow/server/resource/PropositionAideMockResourceTest.java +++ b/src/test/java/dev/lions/unionflow/server/resource/PropositionAideMockResourceTest.java @@ -150,4 +150,64 @@ class PropositionAideMockResourceTest { .statusCode(200) .body("titre", equalTo("Aide trouvée")); } + + // ========================================================================= + // Error cases + // ========================================================================= + + @Test + @DisplayName("GET /api/propositions-aide sans authentification retourne 401") + void listerToutes_sansAuthentification_returns401() { + given() + .when() + .get("/api/propositions-aide") + .then() + .statusCode(401); + } + + @Test + @DisplayName("GET /api/propositions-aide/{id} sans authentification retourne 401") + void obtenirParId_sansAuthentification_returns401() { + given() + .pathParam("id", "id-quelconque") + .when() + .get("/api/propositions-aide/{id}") + .then() + .statusCode(401); + } + + @Test + @TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"}) + @DisplayName("GET /api/propositions-aide/{id} — service retourne null → 404 (branche not found)") + void obtenirParId_notFound_returns404() { + when(propositionAideService.obtenirParId("id-inexistant")).thenReturn(null); + + given() + .pathParam("id", "id-inexistant") + .when() + .get("/api/propositions-aide/{id}") + .then() + .statusCode(404); + } + + @Test + @TestSecurity(user = "membre@unionflow.com", roles = {"MEMBRE"}) + @DisplayName("PUT /api/propositions-aide/{id} avec rôle MEMBRE retourne 403") + void mettreAJour_avecRoleMembre_returns403() { + String body = """ + { + "typeAide": "AIDE_FRAIS_MEDICAUX", + "titre": "Aide non autorisée" + } + """; + + given() + .contentType(ContentType.JSON) + .pathParam("id", "prop-id-001") + .body(body) + .when() + .put("/api/propositions-aide/{id}") + .then() + .statusCode(403); + } } diff --git a/src/test/java/dev/lions/unionflow/server/resource/SouscriptionResourceMockTest.java b/src/test/java/dev/lions/unionflow/server/resource/SouscriptionResourceMockTest.java new file mode 100644 index 0000000..7434bb9 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/resource/SouscriptionResourceMockTest.java @@ -0,0 +1,720 @@ +package dev.lions.unionflow.server.resource; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; + +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.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; +import io.restassured.http.ContentType; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.NotFoundException; +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Tests mock pour SouscriptionResource — couvre tous les endpoints. + * + *

Catégories testées : + *

    + *
  • Endpoints publics (PermitAll) : GET /formules, POST /confirmer-paiement
  • + *
  • Endpoints @Authenticated : GET /ma-souscription, POST /demande, POST /{id}/initier-paiement
  • + *
  • Endpoints SUPER_ADMIN : GET /admin/toutes, GET /admin/organisation/{id}/active, + * GET /admin/en-attente, POST /admin/{id}/approuver, POST /admin/{id}/rejeter
  • + *
+ */ +@QuarkusTest +@DisplayName("SouscriptionResource (mock)") +class SouscriptionResourceMockTest { + + @InjectMock + SouscriptionService souscriptionService; + + @InjectMock + SecuriteHelper securiteHelper; + + // ── Helper ──────────────────────────────────────────────────────────────── + + private SouscriptionStatutResponse buildStatut(String statutValidation) { + SouscriptionStatutResponse r = new SouscriptionStatutResponse(); + r.setSouscriptionId(UUID.randomUUID().toString()); + r.setStatutValidation(statutValidation); + r.setStatutLibelle(statutValidation); + r.setMontantTotal(new BigDecimal("10000")); + r.setOrganisationId(UUID.randomUUID().toString()); + r.setOrganisationNom("Org Test"); + return r; + } + + private FormuleAbonnementResponse buildFormule() { + FormuleAbonnementResponse f = new FormuleAbonnementResponse(); + f.setCode("BASIC"); + f.setLibelle("Formule Basic"); + f.setDescription("Formule de base"); + f.setPlage("PETITE"); + f.setPrixMensuel(new BigDecimal("3000")); + return f; + } + + // ── GET /api/souscriptions/formules — PUBLIC ────────────────────────────── + + @Test + @DisplayName("GET /formules — retourne 200 avec la liste des formules (PermitAll)") + void getFormules_success_returns200() { + when(souscriptionService.getFormules()).thenReturn(List.of(buildFormule())); + + given() + .when() + .get("/api/souscriptions/formules") + .then() + .statusCode(200); + } + + @Test + @DisplayName("GET /formules — retourne 200 même avec liste vide (PermitAll)") + void getFormules_listeVide_returns200() { + when(souscriptionService.getFormules()).thenReturn(List.of()); + + given() + .when() + .get("/api/souscriptions/formules") + .then() + .statusCode(200); + } + + // ── POST /api/souscriptions/confirmer-paiement — PUBLIC ────────────────── + + @Test + @DisplayName("POST /confirmer-paiement — retourne 200 quand paiement confirmé avec succès") + void confirmerPaiement_success_returns200() { + UUID souscriptionId = UUID.randomUUID(); + doNothing().when(souscriptionService).confirmerPaiement(eq(souscriptionId), anyString()); + + given() + .queryParam("id", souscriptionId.toString()) + .queryParam("wave_id", "WAVE-TXN-12345") + .when() + .post("/api/souscriptions/confirmer-paiement") + .then() + .statusCode(200) + .body("message", equalTo("Paiement confirmé — compte activé")); + } + + @Test + @DisplayName("POST /confirmer-paiement — retourne 200 sans wave_id (optionnel)") + void confirmerPaiement_sansWaveId_returns200() { + UUID souscriptionId = UUID.randomUUID(); + doNothing().when(souscriptionService).confirmerPaiement(eq(souscriptionId), isNull()); + + given() + .queryParam("id", souscriptionId.toString()) + .when() + .post("/api/souscriptions/confirmer-paiement") + .then() + .statusCode(200); + } + + @Test + @DisplayName("POST /confirmer-paiement — retourne 400 si paramètre id manquant") + void confirmerPaiement_idManquant_returns400() { + given() + .queryParam("wave_id", "WAVE-TXN-99") + .when() + .post("/api/souscriptions/confirmer-paiement") + .then() + .statusCode(400); + } + + @Test + @DisplayName("POST /confirmer-paiement — retourne 400 si service lève BadRequestException (statut invalide)") + void confirmerPaiement_statutInvalide_returns400() { + UUID souscriptionId = UUID.randomUUID(); + doThrow(new BadRequestException("Impossible de confirmer depuis le statut: REJETEE")) + .when(souscriptionService).confirmerPaiement(eq(souscriptionId), anyString()); + + given() + .queryParam("id", souscriptionId.toString()) + .queryParam("wave_id", "WAVE-TXN-BAD") + .when() + .post("/api/souscriptions/confirmer-paiement") + .then() + .statusCode(400); + } + + @Test + @DisplayName("POST /confirmer-paiement — retourne 404 si souscription introuvable") + void confirmerPaiement_souscriptionInconnue_returns404() { + UUID souscriptionId = UUID.randomUUID(); + doThrow(new NotFoundException("Souscription introuvable: " + souscriptionId)) + .when(souscriptionService).confirmerPaiement(eq(souscriptionId), anyString()); + + given() + .queryParam("id", souscriptionId.toString()) + .queryParam("wave_id", "WAVE-TXN-INCO") + .when() + .post("/api/souscriptions/confirmer-paiement") + .then() + .statusCode(404); + } + + // ── GET /api/souscriptions/ma-souscription — @Authenticated ────────────── + + @Test + @TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"}) + @DisplayName("GET /ma-souscription — retourne 200 quand souscription trouvée") + void getMaSouscription_success_returns200() { + SouscriptionStatutResponse statut = buildStatut("ACTIVE"); + when(souscriptionService.getMaSouscription()).thenReturn(statut); + + given() + .when() + .get("/api/souscriptions/ma-souscription") + .then() + .statusCode(200); + } + + @Test + @TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"}) + @DisplayName("GET /ma-souscription — retourne 404 si aucune souscription pour ce membre") + void getMaSouscription_aucuneSouscription_returns404() { + when(souscriptionService.getMaSouscription()) + .thenThrow(new NotFoundException("Aucune souscription trouvée pour ce membre")); + + given() + .when() + .get("/api/souscriptions/ma-souscription") + .then() + .statusCode(404); + } + + @Test + @DisplayName("GET /ma-souscription — retourne 401 sans authentification") + void getMaSouscription_nonAuthentifie_returns401() { + given() + .when() + .get("/api/souscriptions/ma-souscription") + .then() + .statusCode(401); + } + + // ── POST /api/souscriptions/demande — @Authenticated ───────────────────── + + @Test + @TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"}) + @DisplayName("POST /demande — retourne 201 quand demande créée avec succès") + void creerDemande_success_returns201() { + SouscriptionStatutResponse statut = buildStatut("EN_ATTENTE_PAIEMENT"); + when(souscriptionService.creerDemande(any(SouscriptionDemandeRequest.class))).thenReturn(statut); + + String body = """ + { + "typeFormule": "BASIC", + "plageMembres": "PETITE", + "typePeriode": "MENSUEL", + "typeOrganisation": "ASSOCIATION", + "organisationId": "%s" + } + """.formatted(UUID.randomUUID()); + + given() + .contentType(ContentType.JSON) + .body(body) + .when() + .post("/api/souscriptions/demande") + .then() + .statusCode(201); + } + + @Test + @TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"}) + @DisplayName("POST /demande — retourne 400 si organisation introuvable") + void creerDemande_orgInconnue_returns400() { + when(souscriptionService.creerDemande(any(SouscriptionDemandeRequest.class))) + .thenThrow(new NotFoundException("Organisation introuvable")); + + String body = """ + { + "typeFormule": "BASIC", + "plageMembres": "PETITE", + "typePeriode": "MENSUEL", + "organisationId": "%s" + } + """.formatted(UUID.randomUUID()); + + given() + .contentType(ContentType.JSON) + .body(body) + .when() + .post("/api/souscriptions/demande") + .then() + .statusCode(404); + } + + @Test + @TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"}) + @DisplayName("POST /demande — retourne 400 si souscription déjà existante pour l'organisation") + void creerDemande_souscriptionExistante_returns400() { + when(souscriptionService.creerDemande(any(SouscriptionDemandeRequest.class))) + .thenThrow(new BadRequestException("Une souscription en cours existe déjà pour cette organisation")); + + String body = """ + { + "typeFormule": "BASIC", + "plageMembres": "PETITE", + "typePeriode": "MENSUEL", + "organisationId": "%s" + } + """.formatted(UUID.randomUUID()); + + given() + .contentType(ContentType.JSON) + .body(body) + .when() + .post("/api/souscriptions/demande") + .then() + .statusCode(400); + } + + @Test + @DisplayName("POST /demande — retourne 401 sans authentification") + void creerDemande_nonAuthentifie_returns401() { + given() + .contentType(ContentType.JSON) + .body("{}") + .when() + .post("/api/souscriptions/demande") + .then() + .statusCode(401); + } + + // ── POST /api/souscriptions/{id}/initier-paiement — @Authenticated ─────── + + @Test + @TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"}) + @DisplayName("POST /{id}/initier-paiement — retourne 200 avec waveLaunchUrl quand session créée") + void initierPaiement_success_returns200() { + UUID souscriptionId = UUID.randomUUID(); + SouscriptionStatutResponse statut = buildStatut("PAIEMENT_INITIE"); + statut.setWaveLaunchUrl("https://pay.wave.com/checkout/abc123"); + when(souscriptionService.initierPaiementWave(souscriptionId)).thenReturn(statut); + + given() + .contentType(ContentType.JSON) + .pathParam("id", souscriptionId.toString()) + .when() + .post("/api/souscriptions/{id}/initier-paiement") + .then() + .statusCode(200); + } + + @Test + @TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"}) + @DisplayName("POST /{id}/initier-paiement — retourne 404 si souscription introuvable") + void initierPaiement_souscriptionInconnue_returns404() { + UUID souscriptionId = UUID.randomUUID(); + when(souscriptionService.initierPaiementWave(souscriptionId)) + .thenThrow(new NotFoundException("Souscription introuvable: " + souscriptionId)); + + given() + .contentType(ContentType.JSON) + .pathParam("id", souscriptionId.toString()) + .when() + .post("/api/souscriptions/{id}/initier-paiement") + .then() + .statusCode(404); + } + + @Test + @TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"}) + @DisplayName("POST /{id}/initier-paiement — retourne 400 si statut invalide pour paiement") + void initierPaiement_statutInvalide_returns400() { + UUID souscriptionId = UUID.randomUUID(); + when(souscriptionService.initierPaiementWave(souscriptionId)) + .thenThrow(new BadRequestException("Impossible d'initier le paiement depuis le statut: VALIDEE")); + + given() + .contentType(ContentType.JSON) + .pathParam("id", souscriptionId.toString()) + .when() + .post("/api/souscriptions/{id}/initier-paiement") + .then() + .statusCode(400); + } + + @Test + @DisplayName("POST /{id}/initier-paiement — retourne 401 sans authentification") + void initierPaiement_nonAuthentifie_returns401() { + given() + .contentType(ContentType.JSON) + .pathParam("id", UUID.randomUUID().toString()) + .when() + .post("/api/souscriptions/{id}/initier-paiement") + .then() + .statusCode(401); + } + + // ── GET /api/souscriptions/admin/toutes — SUPER_ADMIN ──────────────────── + + @Test + @TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"}) + @DisplayName("GET /admin/toutes — retourne 200 avec liste complète") + void getSouscriptionsToutes_success_returns200() { + when(souscriptionService.listerToutes(isNull(), eq(0), eq(1000))) + .thenReturn(List.of(buildStatut("ACTIVE"), buildStatut("EN_ATTENTE_PAIEMENT"))); + + given() + .when() + .get("/api/souscriptions/admin/toutes") + .then() + .statusCode(200); + } + + @Test + @TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"}) + @DisplayName("GET /admin/toutes — retourne 200 avec filtre organisationId") + void getSouscriptionsToutes_avecFiltreOrg_returns200() { + UUID orgId = UUID.randomUUID(); + when(souscriptionService.listerToutes(eq(orgId), anyInt(), anyInt())) + .thenReturn(List.of(buildStatut("ACTIVE"))); + + given() + .queryParam("organisationId", orgId.toString()) + .when() + .get("/api/souscriptions/admin/toutes") + .then() + .statusCode(200); + } + + @Test + @TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"}) + @DisplayName("GET /admin/toutes — retourne 200 avec liste vide") + void getSouscriptionsToutes_listeVide_returns200() { + when(souscriptionService.listerToutes(isNull(), anyInt(), anyInt())) + .thenReturn(List.of()); + + given() + .when() + .get("/api/souscriptions/admin/toutes") + .then() + .statusCode(200); + } + + @Test + @TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"}) + @DisplayName("GET /admin/toutes — retourne 403 si rôle insuffisant (ADMIN_ORGANISATION)") + void getSouscriptionsToutes_roleInsuffisant_returns403() { + given() + .when() + .get("/api/souscriptions/admin/toutes") + .then() + .statusCode(403); + } + + // ── GET /api/souscriptions/admin/organisation/{id}/active — SUPER_ADMIN ── + + @Test + @TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"}) + @DisplayName("GET /admin/organisation/{orgId}/active — retourne 200 quand souscription active trouvée") + void getActiveParOrganisation_success_returns200() { + UUID orgId = UUID.randomUUID(); + when(souscriptionService.obtenirActiveParOrganisation(orgId)) + .thenReturn(buildStatut("ACTIVE")); + + given() + .pathParam("organisationId", orgId.toString()) + .when() + .get("/api/souscriptions/admin/organisation/{organisationId}/active") + .then() + .statusCode(200); + } + + @Test + @TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"}) + @DisplayName("GET /admin/organisation/{orgId}/active — retourne 404 si aucune souscription active") + void getActiveParOrganisation_aucuneActive_returns404() { + UUID orgId = UUID.randomUUID(); + when(souscriptionService.obtenirActiveParOrganisation(orgId)).thenReturn(null); + + given() + .pathParam("organisationId", orgId.toString()) + .when() + .get("/api/souscriptions/admin/organisation/{organisationId}/active") + .then() + .statusCode(404); + } + + @Test + @TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"}) + @DisplayName("GET /admin/organisation/{orgId}/active — retourne 403 si rôle insuffisant") + void getActiveParOrganisation_roleInsuffisant_returns403() { + given() + .pathParam("organisationId", UUID.randomUUID().toString()) + .when() + .get("/api/souscriptions/admin/organisation/{organisationId}/active") + .then() + .statusCode(403); + } + + // ── GET /api/souscriptions/admin/en-attente — SUPER_ADMIN ──────────────── + + @Test + @TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"}) + @DisplayName("GET /admin/en-attente — retourne 200 avec liste des souscriptions en attente") + void getSouscriptionsEnAttente_success_returns200() { + when(souscriptionService.getSouscriptionsEnAttenteValidation()) + .thenReturn(List.of(buildStatut("PAIEMENT_CONFIRME"))); + + given() + .when() + .get("/api/souscriptions/admin/en-attente") + .then() + .statusCode(200); + } + + @Test + @TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"}) + @DisplayName("GET /admin/en-attente — retourne 200 avec liste vide si aucune en attente") + void getSouscriptionsEnAttente_listeVide_returns200() { + when(souscriptionService.getSouscriptionsEnAttenteValidation()).thenReturn(List.of()); + + given() + .when() + .get("/api/souscriptions/admin/en-attente") + .then() + .statusCode(200); + } + + @Test + @TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"}) + @DisplayName("GET /admin/en-attente — retourne 403 si rôle insuffisant (ADMIN_ORGANISATION)") + void getSouscriptionsEnAttente_roleInsuffisant_returns403() { + given() + .when() + .get("/api/souscriptions/admin/en-attente") + .then() + .statusCode(403); + } + + @Test + @DisplayName("GET /admin/en-attente — retourne 401 sans authentification") + void getSouscriptionsEnAttente_nonAuthentifie_returns401() { + given() + .when() + .get("/api/souscriptions/admin/en-attente") + .then() + .statusCode(401); + } + + // ── POST /api/souscriptions/admin/{id}/approuver — SUPER_ADMIN ─────────── + + @Test + @TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"}) + @DisplayName("POST /admin/{id}/approuver — retourne 200 quand approbation réussie") + void approuver_success_returns200() { + UUID souscriptionId = UUID.randomUUID(); + UUID superAdminId = UUID.randomUUID(); + when(securiteHelper.resolveMembreId()).thenReturn(superAdminId); + doNothing().when(souscriptionService).approuver(souscriptionId, superAdminId); + + given() + .contentType(ContentType.JSON) + .pathParam("id", souscriptionId.toString()) + .when() + .post("/api/souscriptions/admin/{id}/approuver") + .then() + .statusCode(200) + .body("message", equalTo("Souscription approuvée — compte activé")); + } + + @Test + @TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"}) + @DisplayName("POST /admin/{id}/approuver — retourne 404 si souscription introuvable") + void approuver_souscriptionInconnue_returns404() { + UUID souscriptionId = UUID.randomUUID(); + UUID superAdminId = UUID.randomUUID(); + when(securiteHelper.resolveMembreId()).thenReturn(superAdminId); + doThrow(new NotFoundException("Souscription introuvable: " + souscriptionId)) + .when(souscriptionService).approuver(souscriptionId, superAdminId); + + given() + .contentType(ContentType.JSON) + .pathParam("id", souscriptionId.toString()) + .when() + .post("/api/souscriptions/admin/{id}/approuver") + .then() + .statusCode(404); + } + + @Test + @TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"}) + @DisplayName("POST /admin/{id}/approuver — retourne 400 si statut non approuvable") + void approuver_statutNonApprouvable_returns400() { + UUID souscriptionId = UUID.randomUUID(); + UUID superAdminId = UUID.randomUUID(); + when(securiteHelper.resolveMembreId()).thenReturn(superAdminId); + doThrow(new BadRequestException("Impossible d'approuver depuis le statut: EN_ATTENTE_PAIEMENT")) + .when(souscriptionService).approuver(souscriptionId, superAdminId); + + given() + .contentType(ContentType.JSON) + .pathParam("id", souscriptionId.toString()) + .when() + .post("/api/souscriptions/admin/{id}/approuver") + .then() + .statusCode(400); + } + + @Test + @TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"}) + @DisplayName("POST /admin/{id}/approuver — retourne 403 si rôle insuffisant") + void approuver_roleInsuffisant_returns403() { + given() + .contentType(ContentType.JSON) + .pathParam("id", UUID.randomUUID().toString()) + .when() + .post("/api/souscriptions/admin/{id}/approuver") + .then() + .statusCode(403); + } + + // ── POST /api/souscriptions/admin/{id}/rejeter — SUPER_ADMIN ───────────── + + @Test + @TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"}) + @DisplayName("POST /admin/{id}/rejeter — retourne 200 quand rejet réussi avec commentaire") + void rejeter_success_returns200() { + UUID souscriptionId = UUID.randomUUID(); + UUID superAdminId = UUID.randomUUID(); + when(securiteHelper.resolveMembreId()).thenReturn(superAdminId); + doNothing().when(souscriptionService).rejeter(eq(souscriptionId), eq(superAdminId), anyString()); + + given() + .contentType(ContentType.JSON) + .pathParam("id", souscriptionId.toString()) + .body(Map.of("commentaire", "Documents manquants — dossier incomplet")) + .when() + .post("/api/souscriptions/admin/{id}/rejeter") + .then() + .statusCode(200) + .body("message", equalTo("Souscription rejetée")); + } + + @Test + @TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"}) + @DisplayName("POST /admin/{id}/rejeter — retourne 400 si commentaire absent") + void rejeter_sansCommentaire_returns400() { + UUID souscriptionId = UUID.randomUUID(); + when(securiteHelper.resolveMembreId()).thenReturn(UUID.randomUUID()); + + given() + .contentType(ContentType.JSON) + .pathParam("id", souscriptionId.toString()) + .body(Map.of()) + .when() + .post("/api/souscriptions/admin/{id}/rejeter") + .then() + .statusCode(400); + } + + @Test + @TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"}) + @DisplayName("POST /admin/{id}/rejeter — retourne 400 si commentaire vide (blank)") + void rejeter_commentaireVide_returns400() { + UUID souscriptionId = UUID.randomUUID(); + when(securiteHelper.resolveMembreId()).thenReturn(UUID.randomUUID()); + + given() + .contentType(ContentType.JSON) + .pathParam("id", souscriptionId.toString()) + .body(Map.of("commentaire", " ")) + .when() + .post("/api/souscriptions/admin/{id}/rejeter") + .then() + .statusCode(400); + } + + @Test + @TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"}) + @DisplayName("POST /admin/{id}/rejeter — retourne 404 si souscription introuvable") + void rejeter_souscriptionInconnue_returns404() { + UUID souscriptionId = UUID.randomUUID(); + UUID superAdminId = UUID.randomUUID(); + when(securiteHelper.resolveMembreId()).thenReturn(superAdminId); + doThrow(new NotFoundException("Souscription introuvable: " + souscriptionId)) + .when(souscriptionService).rejeter(eq(souscriptionId), eq(superAdminId), anyString()); + + given() + .contentType(ContentType.JSON) + .pathParam("id", souscriptionId.toString()) + .body(Map.of("commentaire", "Motif de rejet")) + .when() + .post("/api/souscriptions/admin/{id}/rejeter") + .then() + .statusCode(404); + } + + @Test + @TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"}) + @DisplayName("POST /admin/{id}/rejeter — retourne 400 si souscription déjà dans état terminal") + void rejeter_etatTerminal_returns400() { + UUID souscriptionId = UUID.randomUUID(); + UUID superAdminId = UUID.randomUUID(); + when(securiteHelper.resolveMembreId()).thenReturn(superAdminId); + doThrow(new BadRequestException("La souscription est déjà dans un état terminal: REJETEE")) + .when(souscriptionService).rejeter(eq(souscriptionId), eq(superAdminId), anyString()); + + given() + .contentType(ContentType.JSON) + .pathParam("id", souscriptionId.toString()) + .body(Map.of("commentaire", "Motif de rejet")) + .when() + .post("/api/souscriptions/admin/{id}/rejeter") + .then() + .statusCode(400); + } + + @Test + @TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"}) + @DisplayName("POST /admin/{id}/rejeter — retourne 403 si rôle insuffisant") + void rejeter_roleInsuffisant_returns403() { + given() + .contentType(ContentType.JSON) + .pathParam("id", UUID.randomUUID().toString()) + .body(Map.of("commentaire", "Rejet non autorisé")) + .when() + .post("/api/souscriptions/admin/{id}/rejeter") + .then() + .statusCode(403); + } + + @Test + @DisplayName("POST /admin/{id}/rejeter — retourne 401 sans authentification") + void rejeter_nonAuthentifie_returns401() { + given() + .contentType(ContentType.JSON) + .pathParam("id", UUID.randomUUID().toString()) + .body(Map.of("commentaire", "test")) + .when() + .post("/api/souscriptions/admin/{id}/rejeter") + .then() + .statusCode(401); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/resource/WaveRedirectResourceMockDisabledUnitTest.java b/src/test/java/dev/lions/unionflow/server/resource/WaveRedirectResourceMockDisabledUnitTest.java index fb6d1f1..4ecb33f 100644 --- a/src/test/java/dev/lions/unionflow/server/resource/WaveRedirectResourceMockDisabledUnitTest.java +++ b/src/test/java/dev/lions/unionflow/server/resource/WaveRedirectResourceMockDisabledUnitTest.java @@ -118,6 +118,54 @@ class WaveRedirectResourceMockDisabledUnitTest { assertThat(location).isNotNull().contains("success").contains(ref); } + // ========================================================================= + // Error cases / edge cases + // ========================================================================= + + /** + * success() avec ref null et mockEnabled=false : la condition mockEnabled && ref != null + * est fausse → branche false, retourne 303 sans appel de service. + */ + @Test + @DisplayName("success avec mockEnabled=false + ref null → 303 (branche mockEnabled false, ref null)") + void success_mockDisabled_nullRef_returns303() { + Response response = resource.success(null); + + assertThat(response.getStatus()).isEqualTo(303); + String location = response.getHeaderString("Location"); + assertThat(location).isNotNull(); + assertThat(location).startsWith("unionflow://"); + } + + /** + * success() avec ref blank et mockEnabled=false : la condition mockEnabled && ref != null + * est fausse (mockEnabled=false) → retourne 303. + */ + @Test + @DisplayName("success avec mockEnabled=false + ref blank → 303 (branche mockEnabled false, ref blank)") + void success_mockDisabled_blankRef_returns303() { + Response response = resource.success(" "); + + assertThat(response.getStatus()).isEqualTo(303); + String location = response.getHeaderString("Location"); + assertThat(location).isNotNull(); + } + + /** + * mockComplete() avec un ref long (UUID complet) : vérifie que la construction du deep link + * ne tronque pas le ref et inclut bien sa valeur complète. + */ + @Test + @DisplayName("mockComplete avec ref UUID complet → deep link contient le ref intégralement") + void mockComplete_mockDisabled_fullUuidRef_deepLinkContainsRef() { + String longRef = "12345678-1234-1234-1234-123456789012"; + Response response = resource.mockComplete(longRef); + + assertThat(response.getStatus()).isEqualTo(303); + String location = response.getHeaderString("Location"); + assertThat(location).isNotNull().contains("error"); + } + // ========================================================================= // Helper // ========================================================================= diff --git a/src/test/java/dev/lions/unionflow/server/resource/mutuelle/credit/DemandeCreditMockResourceTest.java b/src/test/java/dev/lions/unionflow/server/resource/mutuelle/credit/DemandeCreditMockResourceTest.java index 625625a..9902d56 100644 --- a/src/test/java/dev/lions/unionflow/server/resource/mutuelle/credit/DemandeCreditMockResourceTest.java +++ b/src/test/java/dev/lions/unionflow/server/resource/mutuelle/credit/DemandeCreditMockResourceTest.java @@ -180,4 +180,61 @@ class DemandeCreditMockResourceTest { .then() .statusCode(200); } + + // ------------------------------------------------------------------------- + // Error cases + // ------------------------------------------------------------------------- + + @Test + @DisplayName("POST /api/v1/mutuelle/credits sans authentification retourne 401") + void soumettreDemande_sansAuthentification_returns401() { + String body = """ + { + "membreId": "%s", + "typeCredit": "CONSOMMATION", + "montantDemande": 25000, + "dureeMois": 12, + "justificationDetaillee": "Besoin urgent" + } + """.formatted(UUID.randomUUID()); + + given() + .contentType(ContentType.JSON) + .body(body) + .when() + .post("/api/v1/mutuelle/credits") + .then() + .statusCode(401); + } + + @Test + @TestSecurity(user = "membre@unionflow.com", roles = {"MEMBRE"}) + @DisplayName("POST /{id}/approbation avec rôle MEMBRE retourne 403") + void approuver_avecRoleMembre_returns403() { + given() + .contentType(ContentType.JSON) + .pathParam("id", UUID.randomUUID()) + .queryParam("montant", "50000") + .queryParam("duree", "12") + .queryParam("taux", "5.0") + .when() + .post("/api/v1/mutuelle/credits/{id}/approbation") + .then() + .statusCode(403); + } + + @Test + @TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP"}) + @DisplayName("GET /{id} — service lève exception retourne 500") + void getDemandeById_serviceException_returns500() { + when(demandeCreditService.getDemandeById(any(UUID.class))) + .thenThrow(new RuntimeException("Crédit introuvable")); + + given() + .pathParam("id", UUID.randomUUID()) + .when() + .get("/api/v1/mutuelle/credits/{id}") + .then() + .statusCode(500); + } } diff --git a/src/test/java/dev/lions/unionflow/server/resource/mutuelle/epargne/TransactionEpargneResourceMockTest.java b/src/test/java/dev/lions/unionflow/server/resource/mutuelle/epargne/TransactionEpargneResourceMockTest.java index 5e9abdf..be61fcd 100644 --- a/src/test/java/dev/lions/unionflow/server/resource/mutuelle/epargne/TransactionEpargneResourceMockTest.java +++ b/src/test/java/dev/lions/unionflow/server/resource/mutuelle/epargne/TransactionEpargneResourceMockTest.java @@ -50,4 +50,69 @@ class TransactionEpargneResourceMockTest { .then() .statusCode(201); } + + // ------------------------------------------------------------------------- + // Error cases + // ------------------------------------------------------------------------- + + @Test + @DisplayName("POST /api/v1/epargne/transactions/transfert sans authentification retourne 401") + void transferer_sansAuthentification_returns401() { + String body = String.format(""" + { + "compteId": "%s", + "typeTransaction": "TRANSFERT_SORTANT", + "montant": 5000, + "compteDestinationId": "%s", + "motif": "Transfert test" + } + """, UUID.randomUUID(), UUID.randomUUID()); + + given() + .contentType(ContentType.JSON) + .body(body) + .when() + .post("/api/v1/epargne/transactions/transfert") + .then() + .statusCode(401); + } + + @Test + @TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"}) + @DisplayName("POST /api/v1/epargne/transactions/transfert quand service lève exception retourne 500") + void transferer_serviceException_returns500() { + when(transactionEpargneService.transferer(any())) + .thenThrow(new RuntimeException("Solde insuffisant pour le transfert")); + + String body = String.format(""" + { + "compteId": "%s", + "typeTransaction": "TRANSFERT_SORTANT", + "montant": 5000, + "compteDestinationId": "%s", + "motif": "Transfert erreur" + } + """, UUID.randomUUID(), UUID.randomUUID()); + + given() + .contentType(ContentType.JSON) + .body(body) + .when() + .post("/api/v1/epargne/transactions/transfert") + .then() + .statusCode(500); + } + + @Test + @TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"}) + @DisplayName("POST /api/v1/epargne/transactions/transfert avec corps vide retourne 400") + void transferer_corpsVide_returns400() { + given() + .contentType(ContentType.JSON) + .body("{}") + .when() + .post("/api/v1/epargne/transactions/transfert") + .then() + .statusCode(400); + } } diff --git a/src/test/java/dev/lions/unionflow/server/security/OrganisationContextFilterMultiOrgTest.java b/src/test/java/dev/lions/unionflow/server/security/OrganisationContextFilterMultiOrgTest.java new file mode 100644 index 0000000..64fbac3 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/security/OrganisationContextFilterMultiOrgTest.java @@ -0,0 +1,217 @@ +package dev.lions.unionflow.server.security; + +import dev.lions.unionflow.server.entity.Membre; +import dev.lions.unionflow.server.entity.MembreOrganisation; +import dev.lions.unionflow.server.entity.Organisation; +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.api.enums.membre.StatutMembre; +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Optional; +import java.util.UUID; + +import static io.restassured.RestAssured.given; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** + * Tests d'intégration pour {@link OrganisationContextFilter}. + * + *

Vérifie le comportement du filtre multi-org selon le header + * {@code X-Active-Organisation-Id} : + *

    + *
  • Absent → passe (pas de contexte org requis)
  • + *
  • UUID invalide → 400 Bad Request
  • + *
  • Organisation inexistante → 404 Not Found
  • + *
  • Utilisateur non membre de l'org → 403 Forbidden
  • + *
  • Membre actif → contexte résolu, requête passée
  • + *
  • SUPER_ADMIN → toujours autorisé (bypass membre check)
  • + *
+ * + *

Utilise l'endpoint {@code GET /api/v1/dashboard/health} (PermitAll-like) + * comme cible neutre pour tester le filtre. + */ +@QuarkusTest +@DisplayName("OrganisationContextFilter — multi-org header validation") +class OrganisationContextFilterMultiOrgTest { + + @InjectMock + OrganisationRepository organisationRepository; + + @InjectMock + MembreRepository membreRepository; + + @InjectMock + MembreOrganisationRepository membreOrganisationRepository; + + // ─── Aucun header ──────────────────────────────────────────────────────── + + @Test + @TestSecurity(user = "admin@test.com", roles = {"ADMIN"}) + @DisplayName("Sans header X-Active-Organisation-Id : filtre passe, endpoint répond") + void noHeader_filterPasses_dashboardReachable() { + given() + .when() + .get("/api/v1/dashboard/health") + .then() + .statusCode(200); + } + + // ─── UUID invalide ──────────────────────────────────────────────────────── + + @Test + @TestSecurity(user = "admin@test.com", roles = {"ADMIN"}) + @DisplayName("Header avec UUID invalide → 400 Bad Request") + void invalidUuidHeader_returns400() { + given() + .header("X-Active-Organisation-Id", "not-a-valid-uuid") + .when() + .get("/api/v1/dashboard/health") + .then() + .statusCode(400); + } + + // ─── Organisation introuvable ───────────────────────────────────────────── + + @Test + @TestSecurity(user = "admin@test.com", roles = {"ADMIN_ORGANISATION"}) + @DisplayName("Header avec UUID valide mais org inexistante → 404 Not Found") + void validUuidButOrgNotFound_returns404() { + UUID unknownOrgId = UUID.randomUUID(); + when(organisationRepository.findByIdOptional(unknownOrgId)).thenReturn(Optional.empty()); + + given() + .header("X-Active-Organisation-Id", unknownOrgId.toString()) + .when() + .get("/api/v1/dashboard/health") + .then() + .statusCode(404); + } + + // ─── Utilisateur non membre ─────────────────────────────────────────────── + + @Test + @TestSecurity(user = "membre@test.com", roles = {"MEMBRE"}) + @DisplayName("Header valide + utilisateur non membre de l'org → 403 Forbidden") + void memberNotInOrg_returns403() { + UUID orgId = UUID.randomUUID(); + Organisation org = new Organisation(); + org.setId(orgId); + org.setNom("Org Tierce"); + + Membre membre = new Membre(); + membre.setId(UUID.randomUUID()); + membre.setEmail("membre@test.com"); + + when(organisationRepository.findByIdOptional(orgId)).thenReturn(Optional.of(org)); + when(membreRepository.findByEmail("membre@test.com")).thenReturn(Optional.of(membre)); + when(membreOrganisationRepository.findByMembreIdAndOrganisationId(membre.getId(), orgId)) + .thenReturn(Optional.empty()); // Pas membre + + given() + .header("X-Active-Organisation-Id", orgId.toString()) + .when() + .get("/api/v1/dashboard/health") + .then() + .statusCode(403); + } + + // ─── Membre non actif ───────────────────────────────────────────────────── + + @Test + @TestSecurity(user = "suspendu@test.com", roles = {"MEMBRE"}) + @DisplayName("Header valide + membre suspendu dans l'org → 403 Forbidden") + void memberSuspended_returns403() { + UUID orgId = UUID.randomUUID(); + Organisation org = new Organisation(); + org.setId(orgId); + org.setNom("Org Test"); + + Membre membre = new Membre(); + membre.setId(UUID.randomUUID()); + membre.setEmail("suspendu@test.com"); + + MembreOrganisation membreOrg = MembreOrganisation.builder() + .membre(membre) + .organisation(org) + .statutMembre(StatutMembre.SUSPENDU) + .build(); + membreOrg.setId(UUID.randomUUID()); + + when(organisationRepository.findByIdOptional(orgId)).thenReturn(Optional.of(org)); + when(membreRepository.findByEmail("suspendu@test.com")).thenReturn(Optional.of(membre)); + when(membreOrganisationRepository.findByMembreIdAndOrganisationId(membre.getId(), orgId)) + .thenReturn(Optional.of(membreOrg)); + + given() + .header("X-Active-Organisation-Id", orgId.toString()) + .when() + .get("/api/v1/dashboard/health") + .then() + .statusCode(403); + } + + // ─── SUPER_ADMIN bypass ─────────────────────────────────────────────────── + + @Test + @TestSecurity(user = "superadmin@test.com", roles = {"SUPER_ADMIN", "ADMIN"}) + @DisplayName("SUPER_ADMIN avec header valide → bypass check membre, contexte résolu (200)") + void superAdmin_validOrgId_bypassesMemberCheck() { + UUID orgId = UUID.randomUUID(); + Organisation org = new Organisation(); + org.setId(orgId); + org.setNom("Org Admin"); + + when(organisationRepository.findByIdOptional(orgId)).thenReturn(Optional.of(org)); + // Pas besoin de mocker membreRepository — SUPER_ADMIN bypass + + given() + .header("X-Active-Organisation-Id", orgId.toString()) + .when() + .get("/api/v1/dashboard/health") + .then() + .statusCode(200); + } + + // ─── Membre actif ───────────────────────────────────────────────────────── + + @Test + @TestSecurity(user = "actif@test.com", roles = {"MEMBRE"}) + @DisplayName("Header valide + membre ACTIF dans l'org → contexte résolu (200)") + void activeMember_validOrgId_contextResolved() { + UUID orgId = UUID.randomUUID(); + Organisation org = new Organisation(); + org.setId(orgId); + org.setNom("Ma Tontine"); + + Membre membre = new Membre(); + membre.setId(UUID.randomUUID()); + membre.setEmail("actif@test.com"); + + MembreOrganisation membreOrg = MembreOrganisation.builder() + .membre(membre) + .organisation(org) + .statutMembre(StatutMembre.ACTIF) + .build(); + membreOrg.setId(UUID.randomUUID()); + + when(organisationRepository.findByIdOptional(orgId)).thenReturn(Optional.of(org)); + when(membreRepository.findByEmail("actif@test.com")).thenReturn(Optional.of(membre)); + when(membreOrganisationRepository.findByMembreIdAndOrganisationId(membre.getId(), orgId)) + .thenReturn(Optional.of(membreOrg)); + + given() + .header("X-Active-Organisation-Id", orgId.toString()) + .when() + .get("/api/v1/dashboard/health") + .then() + .statusCode(200); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/security/OrganisationContextHolderTest.java b/src/test/java/dev/lions/unionflow/server/security/OrganisationContextHolderTest.java new file mode 100644 index 0000000..3396200 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/security/OrganisationContextHolderTest.java @@ -0,0 +1,85 @@ +package dev.lions.unionflow.server.security; + +import dev.lions.unionflow.server.entity.Organisation; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests unitaires pour {@link OrganisationContextHolder}. + * + *

Couvre la logique de résolution du contexte multi-organisation. + */ +@DisplayName("OrganisationContextHolder — logique du contexte multi-org") +class OrganisationContextHolderTest { + + @Test + @DisplayName("hasContext: non résolu et orgId null → false") + void hasContext_notResolvedAndNullOrgId_returnsFalse() { + OrganisationContextHolder holder = new OrganisationContextHolder(); + assertThat(holder.hasContext()).isFalse(); + } + + @Test + @DisplayName("hasContext: résolu mais orgId null → false") + void hasContext_resolvedButNullOrgId_returnsFalse() { + OrganisationContextHolder holder = new OrganisationContextHolder(); + holder.setResolved(true); + holder.setOrganisationId(null); + + assertThat(holder.hasContext()).isFalse(); + } + + @Test + @DisplayName("hasContext: orgId défini mais non résolu → false") + void hasContext_orgIdSetButNotResolved_returnsFalse() { + OrganisationContextHolder holder = new OrganisationContextHolder(); + holder.setOrganisationId(UUID.randomUUID()); + holder.setResolved(false); + + assertThat(holder.hasContext()).isFalse(); + } + + @Test + @DisplayName("hasContext: résolu ET orgId défini → true") + void hasContext_resolvedAndOrgIdSet_returnsTrue() { + OrganisationContextHolder holder = new OrganisationContextHolder(); + holder.setOrganisationId(UUID.randomUUID()); + holder.setResolved(true); + + assertThat(holder.hasContext()).isTrue(); + } + + @Test + @DisplayName("setOrganisation: organisation correctement stockée") + void setOrganisation_storesCorrectly() { + OrganisationContextHolder holder = new OrganisationContextHolder(); + Organisation org = new Organisation(); + UUID orgId = UUID.randomUUID(); + org.setId(orgId); + org.setNom("Tontine Dakar"); + + holder.setOrganisation(org); + holder.setOrganisationId(orgId); + holder.setResolved(true); + + assertThat(holder.getOrganisation()).isSameAs(org); + assertThat(holder.getOrganisationId()).isEqualTo(orgId); + assertThat(holder.isResolved()).isTrue(); + assertThat(holder.hasContext()).isTrue(); + } + + @Test + @DisplayName("État initial: tous les champs sont null/false") + void initialState_allFieldsAreDefault() { + OrganisationContextHolder holder = new OrganisationContextHolder(); + + assertThat(holder.getOrganisationId()).isNull(); + assertThat(holder.getOrganisation()).isNull(); + assertThat(holder.isResolved()).isFalse(); + assertThat(holder.hasContext()).isFalse(); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/service/CotisationServiceAdminBranchesTest.java b/src/test/java/dev/lions/unionflow/server/service/CotisationServiceAdminBranchesTest.java index 78d457c..1191f6a 100644 --- a/src/test/java/dev/lions/unionflow/server/service/CotisationServiceAdminBranchesTest.java +++ b/src/test/java/dev/lions/unionflow/server/service/CotisationServiceAdminBranchesTest.java @@ -9,7 +9,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import dev.lions.unionflow.server.api.dto.cotisation.response.CotisationSummaryResponse; +import dev.lions.unionflow.server.api.dto.cotisation.response.CotisationResponse; import dev.lions.unionflow.server.entity.Cotisation; import dev.lions.unionflow.server.entity.Membre; import dev.lions.unionflow.server.entity.Organisation; @@ -108,10 +108,10 @@ class CotisationServiceAdminBranchesTest { any(), any(Page.class), any(Sort.class))) .thenReturn(List.of(cotisation)); - List result = cotisationService.getMesCotisations(0, 10); + List result = cotisationService.getMesCotisations(0, 10); assertThat(result).isNotNull().hasSize(1); - assertThat(result.get(0).id()).isEqualTo(cotisation.getId()); + assertThat(result.get(0).getId()).isEqualTo(cotisation.getId()); } @Test @@ -122,7 +122,7 @@ class CotisationServiceAdminBranchesTest { when(organisationService.listerOrganisationsPourUtilisateur("admin@unionflow.com")) .thenReturn(Collections.emptyList()); - List result = cotisationService.getMesCotisations(0, 10); + List result = cotisationService.getMesCotisations(0, 10); assertThat(result).isNotNull().isEmpty(); } @@ -143,7 +143,7 @@ class CotisationServiceAdminBranchesTest { any(), any(Page.class), any(Sort.class))) .thenReturn(Collections.emptyList()); - List result = cotisationService.getMesCotisations(0, 10); + List result = cotisationService.getMesCotisations(0, 10); assertThat(result).isNotNull().isEmpty(); } @@ -156,7 +156,7 @@ class CotisationServiceAdminBranchesTest { when(organisationService.listerOrganisationsPourUtilisateur("admin@unionflow.com")) .thenReturn(null); - List result = cotisationService.getMesCotisations(0, 10); + List result = cotisationService.getMesCotisations(0, 10); assertThat(result).isNotNull().isEmpty(); } @@ -166,7 +166,7 @@ class CotisationServiceAdminBranchesTest { void getMesCotisations_emailNull_retourneListeVide() { when(securiteHelper.resolveEmail()).thenReturn(null); - List result = cotisationService.getMesCotisations(0, 10); + List result = cotisationService.getMesCotisations(0, 10); assertThat(result).isNotNull().isEmpty(); } @@ -176,7 +176,7 @@ class CotisationServiceAdminBranchesTest { void getMesCotisations_emailBlank_retourneListeVide() { when(securiteHelper.resolveEmail()).thenReturn(" "); - List result = cotisationService.getMesCotisations(0, 10); + List result = cotisationService.getMesCotisations(0, 10); assertThat(result).isNotNull().isEmpty(); } @@ -189,7 +189,7 @@ class CotisationServiceAdminBranchesTest { when(membreRepository.findByEmail("membre-inconnu@unionflow.com")) .thenReturn(Optional.empty()); - List result = cotisationService.getMesCotisations(0, 10); + List result = cotisationService.getMesCotisations(0, 10); assertThat(result).isNotNull().isEmpty(); } @@ -228,10 +228,10 @@ class CotisationServiceAdminBranchesTest { when(cotisationRepository.findEnAttenteByOrganisationIdIn(any())) .thenReturn(List.of(cotisation)); - List result = cotisationService.getMesCotisationsEnAttente(); + List result = cotisationService.getMesCotisationsEnAttente(); assertThat(result).isNotNull().hasSize(1); - assertThat(result.get(0).statut()).isEqualTo("EN_ATTENTE"); + assertThat(result.get(0).getStatut()).isEqualTo("EN_ATTENTE"); } @Test @@ -259,10 +259,10 @@ class CotisationServiceAdminBranchesTest { when(cotisationRepository.findEnAttenteByOrganisationIdIn(any())) .thenReturn(List.of(cotisation)); - List result = cotisationService.getMesCotisationsEnAttente(); + List result = cotisationService.getMesCotisationsEnAttente(); assertThat(result).isNotNull().hasSize(1); - assertThat(result.get(0).statut()).isEqualTo("EN_ATTENTE"); + assertThat(result.get(0).getStatut()).isEqualTo("EN_ATTENTE"); } @Test @@ -273,7 +273,7 @@ class CotisationServiceAdminBranchesTest { when(organisationService.listerOrganisationsPourUtilisateur("admin-vide@unionflow.com")) .thenReturn(Collections.emptyList()); - List result = cotisationService.getMesCotisationsEnAttente(); + List result = cotisationService.getMesCotisationsEnAttente(); assertThat(result).isNotNull().isEmpty(); } @@ -283,7 +283,7 @@ class CotisationServiceAdminBranchesTest { void getMesCotisationsEnAttente_emailNull_retourneListeVide() { when(securiteHelper.resolveEmail()).thenReturn(null); - List result = cotisationService.getMesCotisationsEnAttente(); + List result = cotisationService.getMesCotisationsEnAttente(); assertThat(result).isNotNull().isEmpty(); } @@ -295,7 +295,7 @@ class CotisationServiceAdminBranchesTest { when(securiteHelper.getRoles()).thenReturn(Set.of("MEMBRE")); when(membreRepository.findByEmail("inconnu@unionflow.com")).thenReturn(Optional.empty()); - List result = cotisationService.getMesCotisationsEnAttente(); + List result = cotisationService.getMesCotisationsEnAttente(); assertThat(result).isNotNull().isEmpty(); } @@ -308,7 +308,7 @@ class CotisationServiceAdminBranchesTest { when(securiteHelper.getRoles()).thenReturn(null); when(membreRepository.findByEmail("roles.null@unionflow.com")).thenReturn(Optional.empty()); - List result = cotisationService.getMesCotisationsEnAttente(); + List result = cotisationService.getMesCotisationsEnAttente(); assertThat(result).isNotNull().isEmpty(); } @@ -318,7 +318,7 @@ class CotisationServiceAdminBranchesTest { void getMesCotisationsEnAttente_emailBlank_retourneListeVide() { when(securiteHelper.resolveEmail()).thenReturn(" "); // blank → L736 true - List result = cotisationService.getMesCotisationsEnAttente(); + List result = cotisationService.getMesCotisationsEnAttente(); assertThat(result).isNotNull().isEmpty(); } @@ -499,7 +499,7 @@ class CotisationServiceAdminBranchesTest { when(organisationService.listerOrganisationsPourUtilisateur("admin-null-ea@unionflow.com")) .thenReturn(null); // null → orgs == null → true → return empty - List result = cotisationService.getMesCotisationsEnAttente(); + List result = cotisationService.getMesCotisationsEnAttente(); assertThat(result).isNotNull().isEmpty(); } @@ -570,7 +570,7 @@ class CotisationServiceAdminBranchesTest { org.mockito.ArgumentMatchers.any(Sort.class))) .thenReturn(java.util.Collections.emptyList()); - List result = cotisationService.getMesCotisations(0, 10); + List result = cotisationService.getMesCotisations(0, 10); assertThat(result).isNotNull(); } @@ -595,7 +595,7 @@ class CotisationServiceAdminBranchesTest { when(q.setParameter(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(q); when(q.getResultList()).thenReturn(java.util.Collections.emptyList()); - List result = cotisationService.getMesCotisationsEnAttente(); + List result = cotisationService.getMesCotisationsEnAttente(); assertThat(result).isNotNull(); } diff --git a/src/test/java/dev/lions/unionflow/server/service/CotisationServiceFinalBranchesTest.java b/src/test/java/dev/lions/unionflow/server/service/CotisationServiceFinalBranchesTest.java index de9ebcf..f797c49 100644 --- a/src/test/java/dev/lions/unionflow/server/service/CotisationServiceFinalBranchesTest.java +++ b/src/test/java/dev/lions/unionflow/server/service/CotisationServiceFinalBranchesTest.java @@ -6,7 +6,6 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import dev.lions.unionflow.server.api.dto.cotisation.response.CotisationResponse; -import dev.lions.unionflow.server.api.dto.cotisation.response.CotisationSummaryResponse; import dev.lions.unionflow.server.entity.Cotisation; import dev.lions.unionflow.server.entity.Membre; import dev.lions.unionflow.server.entity.Organisation; @@ -288,7 +287,7 @@ class CotisationServiceFinalBranchesTest { .thenReturn(cotQuery); when(cotQuery.getResultList()).thenReturn(Collections.emptyList()); - List result = cotisationService.getMesCotisationsEnAttente(); + List result = cotisationService.getMesCotisationsEnAttente(); assertThat(result).isNotNull().isEmpty(); } @@ -329,7 +328,7 @@ class CotisationServiceFinalBranchesTest { cot.setMembre(membre); when(cotQuery.getResultList()).thenReturn(List.of(cot)); - List result = cotisationService.getMesCotisationsEnAttente(); + List result = cotisationService.getMesCotisationsEnAttente(); assertThat(result).isNotNull().hasSize(1); } diff --git a/src/test/java/dev/lions/unionflow/server/service/CotisationServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/CotisationServiceTest.java index 06a3cde..e20a584 100644 --- a/src/test/java/dev/lions/unionflow/server/service/CotisationServiceTest.java +++ b/src/test/java/dev/lions/unionflow/server/service/CotisationServiceTest.java @@ -4,7 +4,7 @@ import static org.assertj.core.api.Assertions.*; import dev.lions.unionflow.server.api.dto.cotisation.request.CreateCotisationRequest; import dev.lions.unionflow.server.api.dto.cotisation.request.UpdateCotisationRequest; -import dev.lions.unionflow.server.api.dto.cotisation.response.CotisationSummaryResponse; +import dev.lions.unionflow.server.api.dto.cotisation.response.CotisationResponse; import dev.lions.unionflow.server.entity.Cotisation; import dev.lions.unionflow.server.entity.Membre; import dev.lions.unionflow.server.entity.Organisation; @@ -311,7 +311,7 @@ class CotisationServiceTest { void getCotisationsByMembre_existant_returnsList() { var list = cotisationService.getCotisationsByMembre(membre.getId(), 0, 10); assertThat(list).isNotEmpty(); - assertThat(list.get(0).id()).isEqualTo(cotisation.getId()); + assertThat(list.get(0).getId()).isEqualTo(cotisation.getId()); } @Test @@ -352,12 +352,12 @@ class CotisationServiceTest { @TestSecurity(user = TEST_USER_EMAIL, roles = {"MEMBRE"}) @DisplayName("getMesCotisationsEnAttente → retourne seulement les cotisations EN_ATTENTE du membre connecté") void getMesCotisationsEnAttente_returnsOnlyMemberCotisations() { - List results = cotisationService.getMesCotisationsEnAttente(); + List results = cotisationService.getMesCotisationsEnAttente(); assertThat(results).isNotNull(); assertThat(results).isNotEmpty(); - assertThat(results).allMatch(c -> c.statut().equals("EN_ATTENTE")); - assertThat(results.get(0).id()).isEqualTo(cotisation.getId()); + assertThat(results).allMatch(c -> c.getStatut().equals("EN_ATTENTE")); + assertThat(results.get(0).getId()).isEqualTo(cotisation.getId()); } @Test @@ -382,12 +382,12 @@ class CotisationServiceTest { cotisationNextYear.setNumeroReference("COT-TEST-NY-" + java.util.UUID.randomUUID().toString().substring(0, 8)); cotisationRepository.persist(cotisationNextYear); - List results = cotisationService.getMesCotisationsEnAttente(); + List results = cotisationService.getMesCotisationsEnAttente(); // Ne doit retourner que la cotisation de l'année en cours assertThat(results).isNotNull(); assertThat(results).allMatch(c -> - c.dateEcheance().getYear() == LocalDate.now().getYear() + c.getDateEcheance().getYear() == LocalDate.now().getYear() ); // Cleanup @@ -709,9 +709,9 @@ class CotisationServiceTest { @DisplayName("getMesCotisations comme MEMBRE → retourne les cotisations du membre connecté") void getMesCotisations_commeMembreConnecte_retourneSesCotsisations() { // Couvre la branche MEMBRE : membreConnecte != null → getCotisationsByMembre(...) - List result = cotisationService.getMesCotisations(0, 10); + List result = cotisationService.getMesCotisations(0, 10); assertThat(result).isNotNull().isNotEmpty(); - assertThat(result.stream().anyMatch(c -> c.id().equals(cotisation.getId()))).isTrue(); + assertThat(result.stream().anyMatch(c -> c.getId().equals(cotisation.getId()))).isTrue(); } @Test diff --git a/src/test/java/dev/lions/unionflow/server/service/MemberLifecycleServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/MemberLifecycleServiceTest.java new file mode 100644 index 0000000..5f1e6ab --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/service/MemberLifecycleServiceTest.java @@ -0,0 +1,453 @@ +package dev.lions.unionflow.server.service; + +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.entity.Organisation; +import dev.lions.unionflow.server.repository.MembreOrganisationRepository; +import io.quarkus.hibernate.orm.panache.PanacheQuery; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Tests unitaires pour {@link MemberLifecycleService}. + * + *

Couvre les transitions de statut manuel (activer, suspendre, radier, archiver, inviter) + * ainsi que les traitements automatiques (expiration des invitations, rappels). + */ +@DisplayName("MemberLifecycleService — transitions de statut") +class MemberLifecycleServiceTest { + + @InjectMocks + MemberLifecycleService service; + + @Mock + MembreOrganisationRepository membreOrgRepository; + + @Mock + NotificationService notificationService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + // ─── Helpers ───────────────────────────────────────────────────────────── + + private MembreOrganisation buildLien(StatutMembre statut) { + Membre membre = new Membre(); + membre.setId(UUID.randomUUID()); + membre.setNom("Dupont"); + membre.setPrenom("Marie"); + + Organisation org = new Organisation(); + org.setId(UUID.randomUUID()); + org.setNom("Association Test"); + + MembreOrganisation lien = MembreOrganisation.builder() + .membre(membre) + .organisation(org) + .statutMembre(statut) + .build(); + lien.setId(UUID.randomUUID()); + return lien; + } + + // ═════════════════════════════════════════════════════════════════════════ + // activerMembre + // ═════════════════════════════════════════════════════════════════════════ + + @Test + @DisplayName("activerMembre: EN_ATTENTE_VALIDATION → ACTIF") + void activerMembre_enAttenteValidation_becomesActif() { + var lien = buildLien(StatutMembre.EN_ATTENTE_VALIDATION); + when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien)); + doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class)); + + var result = service.activerMembre(lien.getId(), UUID.randomUUID(), "validation admin"); + + assertThat(result.getStatutMembre()).isEqualTo(StatutMembre.ACTIF); + assertThat(result.getMotifStatut()).isEqualTo("validation admin"); + verify(membreOrgRepository).persist(lien); + } + + @Test + @DisplayName("activerMembre: INVITE → ACTIF (validation directe)") + void activerMembre_invite_becomesActif() { + var lien = buildLien(StatutMembre.INVITE); + when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien)); + doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class)); + + var result = service.activerMembre(lien.getId(), UUID.randomUUID(), null); + + assertThat(result.getStatutMembre()).isEqualTo(StatutMembre.ACTIF); + assertThat(result.getMotifStatut()).isEqualTo("Validation par l'administrateur"); + } + + @Test + @DisplayName("activerMembre: SUSPENDU → ACTIF (réactivation)") + void activerMembre_suspendu_becomesActif() { + var lien = buildLien(StatutMembre.SUSPENDU); + when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien)); + doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class)); + + var result = service.activerMembre(lien.getId(), UUID.randomUUID(), "levée suspension"); + + assertThat(result.getStatutMembre()).isEqualTo(StatutMembre.ACTIF); + } + + @Test + @DisplayName("activerMembre: RADIE → IllegalStateException (transition non autorisée)") + void activerMembre_radie_throwsIllegalState() { + var lien = buildLien(StatutMembre.RADIE); + when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien)); + + assertThatThrownBy(() -> service.activerMembre(lien.getId(), UUID.randomUUID(), null)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Transition non autorisée"); + + verify(membreOrgRepository, never()).persist(any(MembreOrganisation.class)); + } + + @Test + @DisplayName("activerMembre: ARCHIVE → IllegalStateException") + void activerMembre_archive_throwsIllegalState() { + var lien = buildLien(StatutMembre.ARCHIVE); + when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien)); + + assertThatThrownBy(() -> service.activerMembre(lien.getId(), UUID.randomUUID(), null)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("activerMembre: ID inexistant → IllegalArgumentException") + void activerMembre_unknownId_throwsIllegalArgument() { + UUID unknown = UUID.randomUUID(); + when(membreOrgRepository.findByIdOptional(unknown)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.activerMembre(unknown, UUID.randomUUID(), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("introuvable"); + } + + // ═════════════════════════════════════════════════════════════════════════ + // suspendreMembre + // ═════════════════════════════════════════════════════════════════════════ + + @Test + @DisplayName("suspendreMembre: ACTIF → SUSPENDU avec motif") + void suspendreMembre_actif_becomesSuspenduWithMotif() { + var lien = buildLien(StatutMembre.ACTIF); + when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien)); + doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class)); + + var result = service.suspendreMembre(lien.getId(), UUID.randomUUID(), "comportement inapproprié"); + + assertThat(result.getStatutMembre()).isEqualTo(StatutMembre.SUSPENDU); + assertThat(result.getMotifStatut()).isEqualTo("comportement inapproprié"); + } + + @Test + @DisplayName("suspendreMembre: motif null → motif par défaut") + void suspendreMembre_nullMotif_usesDefaultMotif() { + var lien = buildLien(StatutMembre.ACTIF); + when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien)); + doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class)); + + var result = service.suspendreMembre(lien.getId(), UUID.randomUUID(), null); + + assertThat(result.getMotifStatut()).isEqualTo("Suspension par l'administrateur"); + } + + @Test + @DisplayName("suspendreMembre: INVITE → IllegalStateException") + void suspendreMembre_invite_throwsIllegalState() { + var lien = buildLien(StatutMembre.INVITE); + when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien)); + + assertThatThrownBy(() -> service.suspendreMembre(lien.getId(), UUID.randomUUID(), null)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("suspendreMembre: EN_ATTENTE_VALIDATION → IllegalStateException") + void suspendreMembre_enAttente_throwsIllegalState() { + var lien = buildLien(StatutMembre.EN_ATTENTE_VALIDATION); + when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien)); + + assertThatThrownBy(() -> service.suspendreMembre(lien.getId(), UUID.randomUUID(), null)) + .isInstanceOf(IllegalStateException.class); + } + + // ═════════════════════════════════════════════════════════════════════════ + // radierMembre + // ═════════════════════════════════════════════════════════════════════════ + + @Test + @DisplayName("radierMembre: ACTIF → RADIE") + void radierMembre_actif_becomesRadie() { + var lien = buildLien(StatutMembre.ACTIF); + when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien)); + doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class)); + + var result = service.radierMembre(lien.getId(), UUID.randomUUID(), "grave faute"); + + assertThat(result.getStatutMembre()).isEqualTo(StatutMembre.RADIE); + assertThat(result.getMotifStatut()).isEqualTo("grave faute"); + } + + @Test + @DisplayName("radierMembre: SUSPENDU → RADIE (radiation depuis suspension)") + void radierMembre_suspendu_becomesRadie() { + var lien = buildLien(StatutMembre.SUSPENDU); + when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien)); + doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class)); + + var result = service.radierMembre(lien.getId(), UUID.randomUUID(), null); + + assertThat(result.getStatutMembre()).isEqualTo(StatutMembre.RADIE); + assertThat(result.getMotifStatut()).isEqualTo("Radiation par l'administrateur"); + } + + // ═════════════════════════════════════════════════════════════════════════ + // archiverMembre + // ═════════════════════════════════════════════════════════════════════════ + + @Test + @DisplayName("archiverMembre: RADIE → ARCHIVE") + void archiverMembre_radie_becomesArchive() { + var lien = buildLien(StatutMembre.RADIE); + when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien)); + doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class)); + + var result = service.archiverMembre(lien.getId(), "nettoyage annuel"); + + assertThat(result.getStatutMembre()).isEqualTo(StatutMembre.ARCHIVE); + assertThat(result.getMotifArchivage()).isEqualTo("nettoyage annuel"); + } + + @Test + @DisplayName("archiverMembre: ACTIF → ARCHIVE (archivage direct)") + void archiverMembre_actif_becomesArchive() { + var lien = buildLien(StatutMembre.ACTIF); + when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien)); + doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class)); + + var result = service.archiverMembre(lien.getId(), null); + + assertThat(result.getStatutMembre()).isEqualTo(StatutMembre.ARCHIVE); + } + + // ═════════════════════════════════════════════════════════════════════════ + // inviterMembre + // ═════════════════════════════════════════════════════════════════════════ + + @Test + @DisplayName("inviterMembre: crée un lien INVITE avec token 32 chars et expiration 7j") + void inviterMembre_createsInviteLienWithToken() { + Membre membre = new Membre(); + membre.setId(UUID.randomUUID()); + membre.setNom("Sow"); + membre.setPrenom("Amadou"); + + Organisation org = new Organisation(); + org.setId(UUID.randomUUID()); + org.setNom("Coopérative Agricole"); + + when(membreOrgRepository.findByMembreIdAndOrganisationId(membre.getId(), org.getId())) + .thenReturn(Optional.empty()); + doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class)); + + var result = service.inviterMembre(membre, org, UUID.randomUUID(), "TRESORIER"); + + assertThat(result.getStatutMembre()).isEqualTo(StatutMembre.INVITE); + assertThat(result.getTokenInvitation()).isNotNull().hasSize(32); + assertThat(result.getRoleOrg()).isEqualTo("TRESORIER"); + assertThat(result.getDateExpirationInvitation()) + .isAfter(LocalDateTime.now().plusDays(6)); + } + + @Test + @DisplayName("inviterMembre: roleOrg null accepté") + void inviterMembre_nullRoleOrg_accepted() { + Membre membre = new Membre(); + membre.setId(UUID.randomUUID()); + membre.setNom("Diallo"); + membre.setPrenom("Fatou"); + Organisation org = new Organisation(); + org.setId(UUID.randomUUID()); + org.setNom("Tontine"); + + when(membreOrgRepository.findByMembreIdAndOrganisationId(any(), any())) + .thenReturn(Optional.empty()); + doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class)); + + var result = service.inviterMembre(membre, org, UUID.randomUUID(), null); + + assertThat(result.getStatutMembre()).isEqualTo(StatutMembre.INVITE); + assertThat(result.getRoleOrg()).isNull(); + } + + @Test + @DisplayName("inviterMembre: membre déjà lié → IllegalStateException") + void inviterMembre_alreadyLinked_throwsIllegalState() { + Membre membre = new Membre(); + membre.setId(UUID.randomUUID()); + membre.setNom("Test"); + membre.setPrenom("Test"); + Organisation org = new Organisation(); + org.setId(UUID.randomUUID()); + org.setNom("Org"); + + var existingLien = buildLien(StatutMembre.ACTIF); + when(membreOrgRepository.findByMembreIdAndOrganisationId(membre.getId(), org.getId())) + .thenReturn(Optional.of(existingLien)); + + assertThatThrownBy(() -> service.inviterMembre(membre, org, UUID.randomUUID(), null)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("déjà lié"); + } + + // ═════════════════════════════════════════════════════════════════════════ + // accepterInvitation + // ═════════════════════════════════════════════════════════════════════════ + + @Test + @DisplayName("accepterInvitation: token valide → EN_ATTENTE_VALIDATION, token invalidé") + @SuppressWarnings("unchecked") + void accepterInvitation_validToken_becomesEnAttenteValidation() { + String token = "abc123validtokenabcde1234567890a"; + var lien = buildLien(StatutMembre.INVITE); + lien.setTokenInvitation(token); + lien.setDateExpirationInvitation(LocalDateTime.now().plusDays(7)); + + PanacheQuery mockQuery = mock(PanacheQuery.class); + when(membreOrgRepository.find(anyString(), eq(token), eq(StatutMembre.INVITE))) + .thenReturn(mockQuery); + when(mockQuery.firstResultOptional()).thenReturn(Optional.of(lien)); + doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class)); + + var result = service.accepterInvitation(token); + + assertThat(result.getStatutMembre()).isEqualTo(StatutMembre.EN_ATTENTE_VALIDATION); + assertThat(result.getTokenInvitation()).isNull(); // Token invalidé + } + + @Test + @DisplayName("accepterInvitation: invitation expirée → IllegalStateException") + @SuppressWarnings("unchecked") + void accepterInvitation_expiredToken_throwsIllegalState() { + String token = "expiredtokenabcde1234567890abc12"; + var lien = buildLien(StatutMembre.INVITE); + lien.setTokenInvitation(token); + lien.setDateExpirationInvitation(LocalDateTime.now().minusDays(1)); // Expiré hier + + PanacheQuery mockQuery = mock(PanacheQuery.class); + when(membreOrgRepository.find(anyString(), eq(token), eq(StatutMembre.INVITE))) + .thenReturn(mockQuery); + when(mockQuery.firstResultOptional()).thenReturn(Optional.of(lien)); + + assertThatThrownBy(() -> service.accepterInvitation(token)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("expir"); + } + + @Test + @DisplayName("accepterInvitation: token inexistant → IllegalArgumentException") + @SuppressWarnings("unchecked") + void accepterInvitation_unknownToken_throwsIllegalArgument() { + String token = "unknowntoken0000000000000000000"; + + PanacheQuery mockQuery = mock(PanacheQuery.class); + when(membreOrgRepository.find(anyString(), eq(token), eq(StatutMembre.INVITE))) + .thenReturn(mockQuery); + when(mockQuery.firstResultOptional()).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.accepterInvitation(token)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invitation introuvable"); + } + + // ═════════════════════════════════════════════════════════════════════════ + // expirerInvitations (automatique) + // ═════════════════════════════════════════════════════════════════════════ + + @Test + @DisplayName("expirerInvitations: expire 3 invitations périmées → retourne 3") + void expirerInvitations_expiresAll_returnsCount() { + var lien1 = buildLien(StatutMembre.INVITE); + var lien2 = buildLien(StatutMembre.INVITE); + var lien3 = buildLien(StatutMembre.INVITE); + + when(membreOrgRepository.findInvitationsExpirees(any(LocalDateTime.class))) + .thenReturn(List.of(lien1, lien2, lien3)); + doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class)); + + int count = service.expirerInvitations(); + + assertThat(count).isEqualTo(3); + assertThat(lien1.getStatutMembre()).isEqualTo(StatutMembre.RADIE); + assertThat(lien2.getStatutMembre()).isEqualTo(StatutMembre.RADIE); + assertThat(lien3.getStatutMembre()).isEqualTo(StatutMembre.RADIE); + assertThat(lien1.getMotifStatut()).isEqualTo("Invitation expirée sans réponse"); + } + + @Test + @DisplayName("expirerInvitations: aucune invitation expirée → retourne 0") + void expirerInvitations_noneExpired_returnsZero() { + when(membreOrgRepository.findInvitationsExpirees(any(LocalDateTime.class))) + .thenReturn(List.of()); + + int count = service.expirerInvitations(); + + assertThat(count).isZero(); + verify(membreOrgRepository, never()).persist(any(MembreOrganisation.class)); + } + + // ═════════════════════════════════════════════════════════════════════════ + // envoyerRappelsInvitation (automatique) + // ═════════════════════════════════════════════════════════════════════════ + + @Test + @DisplayName("envoyerRappelsInvitation: 2 invitations expirant bientôt → retourne 2") + void envoyerRappelsInvitation_twoExpiringSoon_sendsTwo() { + var lien1 = buildLien(StatutMembre.INVITE); + var lien2 = buildLien(StatutMembre.INVITE); + + when(membreOrgRepository.findInvitationsExpirantBientot( + any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(List.of(lien1, lien2)); + + int count = service.envoyerRappelsInvitation(); + + assertThat(count).isEqualTo(2); + } + + @Test + @DisplayName("envoyerRappelsInvitation: aucune invitation → retourne 0") + void envoyerRappelsInvitation_none_returnsZero() { + when(membreOrgRepository.findInvitationsExpirantBientot( + any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(List.of()); + + int count = service.envoyerRappelsInvitation(); + + assertThat(count).isZero(); + } +}