fix(security): audit RBAC complet v3.0 — rôles normalisés, lifecycle, changement mdp mobile

RBAC:
- HealthResource: @PermitAll
- RoleResource: @RolesAllowed ADMIN/SUPER_ADMIN/ADMIN_ORGANISATION class-level
- PropositionAideResource: @RolesAllowed MEMBRE/USER class-level
- AuthCallbackResource: @PermitAll
- EvenementResource: @PermitAll /publics et /test, count restreint
- BackupResource/LogsMonitoringResource/SystemResource: MODERATOR → MODERATEUR
- AnalyticsResource: MANAGER/MEMBER → ADMIN_ORGANISATION/MEMBRE
- RoleConstant.java: constantes de rôles centralisées

Cycle de vie membres:
- MemberLifecycleService: ajouterMembre()/retirerMembre() sur activation/radiation/archivage
- MembreResource: endpoint GET /numero/{numeroMembre}
- MembreService: méthode trouverParNumeroMembre()

Changement mot de passe:
- CompteAdherentResource: endpoint POST /auth/change-password (mobile)
- MembreKeycloakSyncService: changerMotDePasseDirectKeycloak() via API Admin Keycloak directe
- Fallback automatique si lions-user-manager indisponible

Workflow:
- Flyway V17-V23: rôles, types org, formules Option C, lifecycle columns, bareme cotisation
- Nouvelles classes: MemberLifecycleService, OrganisationModuleService, scheduler
- Security: OrganisationContextFilter, OrganisationContextHolder, ModuleAccessFilter
This commit is contained in:
dahoud
2026-04-07 20:52:26 +00:00
parent c74ae25ad6
commit a2dfae9a0b
78 changed files with 5637 additions and 271 deletions

View File

@@ -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);

View File

@@ -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.
*
* <p>Permet de définir des montants différenciés selon le rôle du membre
* (PRESIDENT, TRESORIER, MEMBRE_ORDINAIRE, etc.).
*
* <p>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}.
*
* <p>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;
}

View File

@@ -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 ────────────────────────────────────────────────────────
/**

View File

@@ -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<BaremeCotisationRole, UUID> {
public Optional<BaremeCotisationRole> findByOrganisationIdAndRoleOrg(UUID organisationId, String roleOrg) {
return find("organisation.id = ?1 AND roleOrg = ?2", organisationId, roleOrg).firstResultOptional();
}
public List<BaremeCotisationRole> findByOrganisationId(UUID organisationId) {
return find("organisation.id = ?1", organisationId).list();
}
}

View File

@@ -601,4 +601,22 @@ public class CotisationRepository extends BaseRepository<Cotisation> {
}
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;
}
}

View File

@@ -66,6 +66,16 @@ public class DocumentRepository implements PanacheRepositoryBase<Document, UUID>
public List<Document> 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<Document> findByCreePar(String email) {
return find("creePar = ?1 AND actif = true ORDER BY dateCreation DESC", email).list();
}
}

View File

@@ -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<ParametresCotisationOrganisation, UUID> {
public Optional<ParametresCotisationOrganisation> findByOrganisationId(UUID organisationId) {
return find("organisation.id = ?1", organisationId).firstResultOptional();
}
/** Retourne toutes les organisations ayant activé la génération automatique. */
public List<ParametresCotisationOrganisation> findAvecGenerationAutomatiqueActivee() {
return find("generationAutomatiqueActivee = true AND actif = true").list();
}
}

View File

@@ -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")

View File

@@ -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) {}
}

View File

@@ -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<BackupResponse> 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");

View File

@@ -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) {}
}

View File

@@ -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;
}
}
}

View File

@@ -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.
*
* <p>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<String, String> 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<Membre> membreOpt = membreRepository.findByEmail(email.trim())
.or(() -> membreRepository.findByEmail(email.trim().toLowerCase()));
if (membreOpt.isEmpty()) {
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("message", "Aucun membre trouvé pour ce compte."))
.build();
}
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();
}
}
}

View File

@@ -60,19 +60,19 @@ public class CotisationResource {
try {
log.info("GET /api/cotisations/public - page: {}, size: {}", page, size);
List<CotisationSummaryResponse> cotisations = cotisationService.getAllCotisations(page, size);
List<CotisationResponse> cotisations = cotisationService.getAllCotisations(page, size);
List<Map<String, Object>> content = cotisations.stream()
.map(c -> {
Map<String, Object> 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<CotisationSummaryResponse> cotisations = cotisationService.getAllCotisations(page, size);
List<CotisationResponse> 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<CotisationSummaryResponse> results = cotisationService.getCotisationsByMembre(membreId, page, size);
List<CotisationResponse> 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<CotisationSummaryResponse> results = cotisationService.getCotisationsByStatut(statut, page, size);
List<CotisationResponse> 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<CotisationSummaryResponse> results = cotisationService.getCotisationsEnRetard(page, size);
List<CotisationResponse> 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<CotisationSummaryResponse> results = cotisationService.rechercherCotisations(
List<CotisationResponse> 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<CotisationSummaryResponse> results = cotisationService.getMesCotisations(page, size);
List<CotisationResponse> 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<CotisationSummaryResponse> results = cotisationService.getMesCotisationsEnAttente();
List<CotisationResponse> results = cotisationService.getMesCotisationsEnAttente();
return Response.ok(results).build();
} catch (Exception e) {
log.error("Erreur récupération mes cotisations en attente", e);

View File

@@ -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<DocumentResponse> 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;
}
}
}

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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<SystemLogResponse> 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<SystemAlertResponse> 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");

View File

@@ -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<Membre> membres = membreService.listerMembresActifs();
List<MembreResponse> 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<dev.lions.unionflow.server.entity.MembreOrganisation> liens =
membreOrgRepository.findMembresActifsParOrganisation(organisationId);
List<MembreResponse> 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 {

View File

@@ -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<UUID> membreIds;

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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<SouscriptionStatutResponse> 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).
*/

View File

@@ -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.)"

View File

@@ -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 = """
<!DOCTYPE html>
<html lang="fr">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Paiement confirmé</title>
<style>
body{font-family:sans-serif;display:flex;flex-direction:column;align-items:center;
justify-content:center;min-height:100vh;margin:0;background:#f0fdf4;}
.card{background:#fff;border-radius:16px;padding:2rem 2.5rem;text-align:center;
box-shadow:0 4px 24px #0001;max-width:360px;}
.icon{font-size:3.5rem;margin-bottom:1rem;}
h2{color:#16a34a;margin:.5rem 0;}
p{color:#555;margin:.5rem 0;}
</style>
</head>
<body>
<div class="card">
<div class="icon">✅</div>
<h2>Paiement confirmé !</h2>
<p>Votre cotisation a été enregistrée avec succès.</p>
<p style="font-size:.85rem;color:#888;margin-top:1rem;">
Vous pouvez fermer cette page et revenir sur UnionFlow.
</p>
</div>
</body>
</html>
""";
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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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.
*
* <p>Tâches exécutées périodiquement :
* <ul>
* <li>Toutes les heures : rappels d'invitation expirant dans 24h</li>
* <li>Tous les jours à 02:00 : expiration des invitations périmées</li>
* </ul>
*
* <p>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");
}
}
}

View File

@@ -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.
*
* <p>S'exécute après l'authentification (AUTHORIZATION + 10) pour laisser
* Quarkus OIDC et {@code @RolesAllowed} s'exécuter en premier.
*
* <p>Le filtre :
* <ol>
* <li>Détecte l'annotation {@link RequiresModule} sur la méthode ou la classe.</li>
* <li>Extrait l'organisation active depuis le header {@code X-Active-Organisation-Id}.</li>
* <li>Vérifie via {@link OrganisationModuleService} que le module est activé.</li>
* <li>Retourne HTTP 403 avec un message explicite si le module est absent.</li>
* </ol>
*
* <p>Header attendu : {@code X-Active-Organisation-Id: <UUID>}
*/
@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();
}
}

View File

@@ -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.
*
* <p>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.
*
* <p>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.
*
* <p>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<Organisation> 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<Membre> membreOpt = membreRepository.findByEmail(email);
if (membreOpt.isPresent()) {
Optional<MembreOrganisation> 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);
}
}

View File

@@ -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.
*
* <p>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.
*
* <p>Exemple d'utilisation dans un service :
* <pre>{@code
* @Inject OrganisationContextHolder orgContext;
*
* public List<Tontine> listTontines() {
* UUID orgId = orgContext.getOrganisationId();
* return tontineRepository.findByOrganisationId(orgId);
* }
* }</pre>
*/
@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;
}
}

View File

@@ -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.
*
* <p>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é.
*
* <p>Le module est déterminé par le type d'organisation (Option C) et non
* par le plan tarifaire.
*
* <p>Exemple d'utilisation :
* <pre>{@code
* @GET
* @Path("/cycles")
* @RequiresModule("TONTINE")
* @RolesAllowed({"ADMIN", "TONTINE_MANAGER"})
* public Response getCycles() { ... }
* }</pre>
*/
@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 "";
}

View File

@@ -0,0 +1,42 @@
package dev.lions.unionflow.server.security;
/**
* Constantes centralisées pour les rôles UnionFlow.
*
* <p>Utiliser ces constantes dans {@code @RolesAllowed} pour éviter
* les fautes de frappe et garantir la cohérence entre ressources.</p>
*/
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() {}
}

View File

@@ -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.
*
* <p>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é.
*
* <p>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<ParametresCotisationOrganisation> 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<MembreOrganisation> 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<BaremeCotisationRole> bareme =
baremeRepository.findByOrganisationIdAndRoleOrg(orgId, roleOrg);
if (bareme.isPresent() && bareme.get().getMontantMensuel() != null) {
return bareme.get().getMontantMensuel();
}
}
return params.getMontantCotisationMensuelle();
}
}

View File

@@ -70,17 +70,17 @@ public class CotisationService {
* @param size taille de la page
* @return liste des cotisations converties en Summary Response
*/
public List<CotisationSummaryResponse> getAllCotisations(int page, int size) {
public List<CotisationResponse> getAllCotisations(int page, int size) {
log.debug("Récupération des cotisations - page: {}, size: {}", page, size);
jakarta.persistence.TypedQuery<Cotisation> 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<Cotisation> 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<CotisationSummaryResponse> getCotisationsByMembre(@NotNull UUID membreId, int page, int size) {
public List<CotisationResponse> 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<Cotisation> 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<CotisationSummaryResponse> getCotisationsByStatut(@NotNull String statut, int page, int size) {
public List<CotisationResponse> getCotisationsByStatut(@NotNull String statut, int page, int size) {
log.debug("Récupération des cotisations avec statut: {}", statut);
List<Cotisation> 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<CotisationSummaryResponse> getCotisationsEnRetard(int page, int size) {
public List<CotisationResponse> getCotisationsEnRetard(int page, int size) {
log.debug("Récupération des cotisations en retard");
List<Cotisation> 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<CotisationSummaryResponse> rechercherCotisations(
public List<CotisationResponse> rechercherCotisations(
UUID membreId,
String statut,
String typeCotisation,
@@ -334,7 +334,7 @@ public class CotisationService {
List<Cotisation> 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<CotisationSummaryResponse> getMesCotisations(int page, int size) {
public List<CotisationResponse> 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<UUID> orgIds = orgs.stream().map(Organisation::getId).collect(Collectors.toCollection(LinkedHashSet::new));
List<Cotisation> 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<CotisationSummaryResponse> getMesCotisationsEnAttente() {
public List<CotisationResponse> getMesCotisationsEnAttente() {
String email = securiteHelper.resolveEmail();
if (email == null || email.isBlank()) {
return Collections.emptyList();
@@ -745,7 +745,7 @@ public class CotisationService {
Set<UUID> orgIds = orgs.stream().map(Organisation::getId).collect(Collectors.toCollection(LinkedHashSet::new));
List<Cotisation> 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<Cotisation> 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());
}

View File

@@ -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<DemandeAideResponse> chargerToutesLesDemandesDepuisBDD() {
List<DemandeAide> entities = demandeAideRepository.listAll();
int limite = 1000;
List<DemandeAide> 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());

View File

@@ -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<DocumentResponse> 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
*

View File

@@ -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.
*
* <p>Gère les transitions de statut automatiques et manuelles :
* <ul>
* <li>Invitation → EN_ATTENTE_VALIDATION (après acceptation)</li>
* <li>EN_ATTENTE_VALIDATION → ACTIF (après validation admin)</li>
* <li>ACTIF → SUSPENDU (suspension manuelle ou automatique)</li>
* <li>INVITE → expiré (si la date d'expiration est dépassée)</li>
* <li>ACTIF → ARCHIVE (archivage)</li>
* </ul>
*
* <p>Les tâches automatiques sont déclenchées par {@link MemberLifecycleScheduler}.
*/
@ApplicationScoped
public class MemberLifecycleService {
private static final Logger LOG = Logger.getLogger(MemberLifecycleService.class);
/** Durée de validité par défaut d'une invitation (7 jours). */
public static final int INVITATION_EXPIRY_DAYS = 7;
/** Délai de rappel avant expiration (24h avant). */
public static final int INVITATION_REMINDER_HOURS = 24;
@Inject
MembreOrganisationRepository membreOrgRepository;
@Inject
NotificationService notificationService;
// =========================================================================
// Transitions manuelles
// =========================================================================
/**
* Active un membre dans une organisation (transition → ACTIF).
*
* @param membreOrgId UUID du lien membre-organisation
* @param adminId UUID de l'administrateur qui valide
* @param motif Motif optionnel de l'activation
*/
@Transactional
public MembreOrganisation activerMembre(UUID membreOrgId, UUID adminId, String motif) {
MembreOrganisation lien = charger(membreOrgId);
verifierTransitionAutorisee(lien.getStatutMembre(),
StatutMembre.EN_ATTENTE_VALIDATION, StatutMembre.INVITE, StatutMembre.SUSPENDU);
lien.setStatutMembre(StatutMembre.ACTIF);
lien.setDateChangementStatut(LocalDate.now());
lien.setMotifStatut(motif != null ? motif : "Validation par l'administrateur");
membreOrgRepository.persist(lien);
// Mettre à jour le compteur de membres de l'organisation
lien.getOrganisation().ajouterMembre();
LOG.infof("Membre %s activé dans organisation %s par admin %s",
lien.getMembre().getId(), lien.getOrganisation().getId(), adminId);
// Notifier le membre
envoyerNotification(lien.getMembre(), "Adhésion activée",
"Votre adhésion à " + lien.getOrganisation().getNom() + " est maintenant active.");
return lien;
}
/**
* Suspend un membre dans une organisation (ACTIF → SUSPENDU).
*/
@Transactional
public MembreOrganisation suspendreMembre(UUID membreOrgId, UUID adminId, String motif) {
MembreOrganisation lien = charger(membreOrgId);
verifierTransitionAutorisee(lien.getStatutMembre(), StatutMembre.ACTIF);
lien.setStatutMembre(StatutMembre.SUSPENDU);
lien.setDateChangementStatut(LocalDate.now());
lien.setMotifStatut(motif != null ? motif : "Suspension par l'administrateur");
membreOrgRepository.persist(lien);
LOG.infof("Membre %s suspendu dans organisation %s", lien.getMembre().getId(),
lien.getOrganisation().getId());
envoyerNotification(lien.getMembre(), "Adhésion suspendue",
"Votre adhésion à " + lien.getOrganisation().getNom() + " a été suspendue. Motif : " + motif);
return lien;
}
/**
* Radie un membre d'une organisation (→ RADIE).
*/
@Transactional
public MembreOrganisation radierMembre(UUID membreOrgId, UUID adminId, String motif) {
MembreOrganisation lien = charger(membreOrgId);
lien.setStatutMembre(StatutMembre.RADIE);
lien.setDateChangementStatut(LocalDate.now());
lien.setMotifStatut(motif != null ? motif : "Radiation par l'administrateur");
membreOrgRepository.persist(lien);
// Mettre à jour le compteur de membres de l'organisation
lien.getOrganisation().retirerMembre();
LOG.infof("Membre %s radié de l'organisation %s. Motif: %s",
lien.getMembre().getId(), lien.getOrganisation().getId(), motif);
envoyerNotification(lien.getMembre(), "Adhésion radiée",
"Votre adhésion à " + lien.getOrganisation().getNom() + " a été radiée.");
return lien;
}
/**
* Archive un membre (→ ARCHIVE) sans supprimer l'historique.
*/
@Transactional
public MembreOrganisation archiverMembre(UUID membreOrgId, String motif) {
MembreOrganisation lien = charger(membreOrgId);
lien.setStatutMembre(StatutMembre.ARCHIVE);
lien.setDateChangementStatut(LocalDate.now());
lien.setMotifArchivage(motif);
membreOrgRepository.persist(lien);
// Mettre à jour le compteur de membres de l'organisation
lien.getOrganisation().retirerMembre();
LOG.infof("Membre %s archivé dans l'organisation %s", lien.getMembre().getId(),
lien.getOrganisation().getId());
return lien;
}
/**
* Crée une invitation (statut INVITE) pour un nouveau membre.
*
* @param membre Le membre à inviter (doit exister en base)
* @param organisation L'organisation cible
* @param adminId L'administrateur qui invite
* @param roleOrg Rôle proposé dans l'organisation (optionnel)
* @return le lien MembreOrganisation créé avec le token d'invitation
*/
@Transactional
public MembreOrganisation inviterMembre(
dev.lions.unionflow.server.entity.Membre membre,
dev.lions.unionflow.server.entity.Organisation organisation,
UUID adminId,
String roleOrg) {
// Vérifier s'il n'existe pas déjà un lien actif
membreOrgRepository.findByMembreIdAndOrganisationId(membre.getId(), organisation.getId())
.ifPresent(existing -> {
throw new IllegalStateException("Le membre est déjà lié à cette organisation.");
});
String token = UUID.randomUUID().toString().replace("-", "");
LocalDateTime now = LocalDateTime.now();
MembreOrganisation lien = MembreOrganisation.builder()
.membre(membre)
.organisation(organisation)
.statutMembre(StatutMembre.INVITE)
.dateInvitation(now)
.dateExpirationInvitation(now.plusDays(INVITATION_EXPIRY_DAYS))
.tokenInvitation(token)
.invitePar(adminId)
.roleOrg(roleOrg)
.build();
membreOrgRepository.persist(lien);
LOG.infof("Invitation créée pour membre %s dans organisation %s (expire: %s)",
membre.getId(), organisation.getId(), lien.getDateExpirationInvitation());
// Notifier le membre
envoyerNotification(membre, "Invitation à rejoindre " + organisation.getNom(),
"Vous avez été invité à rejoindre " + organisation.getNom() +
". Votre invitation expire dans " + INVITATION_EXPIRY_DAYS + " jours.");
return lien;
}
/**
* Accepte une invitation via son token (INVITE → EN_ATTENTE_VALIDATION).
*/
@Transactional
public MembreOrganisation accepterInvitation(String token) {
MembreOrganisation lien = membreOrgRepository
.find("tokenInvitation = ?1 and statutMembre = ?2", token, StatutMembre.INVITE)
.firstResultOptional()
.orElseThrow(() -> new IllegalArgumentException("Invitation introuvable ou déjà utilisée."));
if (lien.getDateExpirationInvitation() != null &&
lien.getDateExpirationInvitation().isBefore(LocalDateTime.now())) {
throw new IllegalStateException("Cette invitation a expiré.");
}
lien.setStatutMembre(StatutMembre.EN_ATTENTE_VALIDATION);
lien.setDateChangementStatut(LocalDate.now());
lien.setTokenInvitation(null); // Invalider le token après usage
membreOrgRepository.persist(lien);
LOG.infof("Invitation acceptée par membre %s dans organisation %s",
lien.getMembre().getId(), lien.getOrganisation().getId());
return lien;
}
// =========================================================================
// Traitements automatiques (appelés par MemberLifecycleScheduler)
// =========================================================================
/**
* Expire les invitations dont la date limite est dépassée.
* Les membres INVITE passent au statut RADIE.
*
* @return nombre d'invitations expirées
*/
@Transactional
public int expirerInvitations() {
LocalDateTime now = LocalDateTime.now();
List<MembreOrganisation> expiredInvitations = membreOrgRepository.findInvitationsExpirees(now);
int count = 0;
for (MembreOrganisation lien : expiredInvitations) {
lien.setStatutMembre(StatutMembre.RADIE);
lien.setDateChangementStatut(LocalDate.now());
lien.setMotifStatut("Invitation expirée sans réponse");
membreOrgRepository.persist(lien);
count++;
LOG.infof("Invitation expirée : membre %s dans org %s",
lien.getMembre().getId(), lien.getOrganisation().getId());
}
if (count > 0) {
LOG.infof("Expiration des invitations : %d invitation(s) expirée(s)", count);
}
return count;
}
/**
* Envoie des rappels pour les invitations qui expirent dans les prochaines 24h.
*
* @return nombre de rappels envoyés
*/
@Transactional
public int envoyerRappelsInvitation() {
LocalDateTime now = LocalDateTime.now();
LocalDateTime dans24h = now.plusHours(INVITATION_REMINDER_HOURS);
List<MembreOrganisation> aRappeler =
membreOrgRepository.findInvitationsExpirantBientot(now, dans24h);
int count = 0;
for (MembreOrganisation lien : aRappeler) {
envoyerNotification(lien.getMembre(),
"Rappel : votre invitation expire bientôt",
"Votre invitation à rejoindre " + lien.getOrganisation().getNom() +
" expire dans moins de 24h. Acceptez-la maintenant.");
count++;
}
if (count > 0) {
LOG.infof("Rappels d'invitation : %d rappel(s) envoyé(s)", count);
}
return count;
}
// =========================================================================
// Helpers privés
// =========================================================================
private MembreOrganisation charger(UUID membreOrgId) {
return membreOrgRepository.findByIdOptional(membreOrgId)
.orElseThrow(() -> new IllegalArgumentException(
"Lien membre-organisation introuvable : " + membreOrgId));
}
private void verifierTransitionAutorisee(StatutMembre current, StatutMembre... autorises) {
for (StatutMembre autorise : autorises) {
if (autorise.equals(current)) return;
}
throw new IllegalStateException(
"Transition non autorisée depuis le statut " + current);
}
private void envoyerNotification(Membre membre, String titre, String corps) {
try {
if (notificationService != null) {
CreateNotificationRequest req = CreateNotificationRequest.builder()
.typeNotification("SYSTEME")
.priorite("NORMALE")
.sujet(titre)
.corps(corps)
.membreId(membre.getId())
.build();
notificationService.creerNotification(req);
}
} catch (Exception e) {
LOG.warnf("Impossible d'envoyer la notification à %s : %s", membre.getId(), e.getMessage());
}
}
}

View File

@@ -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<String> 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é.
*

View File

@@ -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<Membre> trouverParNumeroMembre(String numeroMembre) {
return membreRepository.findByNumeroMembre(numeroMembre);
}
/** Liste tous les membres actifs */
public List<Membre> 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 */

View File

@@ -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.
*
* <p>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).
*
* <p>Les modules sont regroupés en deux catégories :
* <ul>
* <li>MODULES_COMMUNS — accessibles à tous les types d'org</li>
* <li>Modules métier — spécifiques au type d'org (TONTINE, CREDIT, etc.)</li>
* </ul>
*/
@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<String> 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<String> getModulesActifs(UUID organisationId) {
Optional<Organisation> 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<String> getModulesActifs(Organisation organisation) {
Set<String> 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<String> getModulesParType(String typeOrganisation) {
if (typeOrganisation == null) {
return Collections.emptySet();
}
Set<String> 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<Organisation> opt = organisationRepository.findByIdOptional(organisationId);
if (opt.isEmpty()) {
return new ModulesActifsResponse(organisationId, Collections.emptySet(), "UNKNOWN");
}
Organisation org = opt.get();
Set<String> 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<String> modules, String typeOrganisation) {}
}

View File

@@ -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;

View File

@@ -306,6 +306,40 @@ public class SouscriptionService {
// ── Validation SuperAdmin ──────────────────────────────────────────────────
/**
* Liste toutes les souscriptions (SuperAdmin), avec filtre optionnel par organisation.
*/
public List<SouscriptionStatutResponse> 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());

View File

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

View File

@@ -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<String> 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;

View File

@@ -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

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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$$;

View File

@@ -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);

View File

@@ -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;