Compare commits
5 Commits
6bd3f6bc18
...
aebf333421
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aebf333421 | ||
|
|
aa4350ffbb | ||
|
|
4816d1ac50 | ||
|
|
78d8fd7cd8 | ||
|
|
e81c75b828 |
@@ -43,6 +43,9 @@ public class KafkaEventProducer {
|
|||||||
@Channel("contributions-events-out")
|
@Channel("contributions-events-out")
|
||||||
Emitter<Record<String, String>> contributionsEventsEmitter;
|
Emitter<Record<String, String>> contributionsEventsEmitter;
|
||||||
|
|
||||||
|
@Channel("chat-messages-out")
|
||||||
|
Emitter<Record<String, String>> chatMessagesEmitter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publie un event d'approbation en attente.
|
* Publie un event d'approbation en attente.
|
||||||
*/
|
*/
|
||||||
@@ -116,6 +119,28 @@ public class KafkaEventProducer {
|
|||||||
publishToChannel(membersEventsEmitter, memberId.toString(), event, "members-events");
|
publishToChannel(membersEventsEmitter, memberId.toString(), event, "members-events");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publie un event de désactivation de membre (soft delete).
|
||||||
|
* Les consommateurs peuvent réagir : bloquer comptes épargne, annuler inscriptions,
|
||||||
|
* reassigner approvals pending, nettoyer notifications, etc.
|
||||||
|
*/
|
||||||
|
public void publishMemberDeactivated(dev.lions.unionflow.server.entity.Membre membre) {
|
||||||
|
if (membre == null || membre.getId() == null) return;
|
||||||
|
Map<String, Object> data = new java.util.HashMap<>();
|
||||||
|
data.put("membreId", membre.getId().toString());
|
||||||
|
data.put("email", membre.getEmail());
|
||||||
|
data.put("nomComplet", membre.getNomComplet());
|
||||||
|
data.put("numeroMembre", membre.getNumeroMembre());
|
||||||
|
// organisationId principal (si présent) pour routage par org
|
||||||
|
String orgId = membre.getMembresOrganisations() != null
|
||||||
|
&& !membre.getMembresOrganisations().isEmpty()
|
||||||
|
&& membre.getMembresOrganisations().get(0).getOrganisation() != null
|
||||||
|
? membre.getMembresOrganisations().get(0).getOrganisation().getId().toString()
|
||||||
|
: "";
|
||||||
|
var event = buildEvent("MEMBER_DEACTIVATED", orgId, data);
|
||||||
|
publishToChannel(membersEventsEmitter, membre.getId().toString(), event, "members-events");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publie un event de cotisation payée.
|
* Publie un event de cotisation payée.
|
||||||
*/
|
*/
|
||||||
@@ -124,6 +149,15 @@ public class KafkaEventProducer {
|
|||||||
publishToChannel(contributionsEventsEmitter, contributionId.toString(), event, "contributions-events");
|
publishToChannel(contributionsEventsEmitter, contributionId.toString(), event, "contributions-events");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publie un event de nouveau message de chat.
|
||||||
|
* Les clients WebSocket de l'organisation sont notifiés pour rafraîchir leurs messages.
|
||||||
|
*/
|
||||||
|
public void publishNouveauMessage(UUID conversationId, String organizationId, Map<String, Object> messageData) {
|
||||||
|
var event = buildEvent("NOUVEAU_MESSAGE", organizationId, messageData);
|
||||||
|
publishToChannel(chatMessagesEmitter, conversationId.toString(), event, "chat-messages");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construit un event avec structure standardisée.
|
* Construit un event avec structure standardisée.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ public class MembreRepository extends BaseRepository<Membre> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Trouve les membres appartenant à au moins une des organisations données (pour admin d'organisation).
|
* Trouve les membres appartenant à au moins une des organisations données (pour admin d'organisation).
|
||||||
|
* Filtre les membres désactivés (actif=false) pour ne pas polluer les listes UI.
|
||||||
*/
|
*/
|
||||||
public List<Membre> findDistinctByOrganisationIdIn(Set<UUID> organisationIds, Page page, Sort sort) {
|
public List<Membre> findDistinctByOrganisationIdIn(Set<UUID> organisationIds, Page page, Sort sort) {
|
||||||
if (organisationIds == null || organisationIds.isEmpty()) {
|
if (organisationIds == null || organisationIds.isEmpty()) {
|
||||||
@@ -94,7 +95,9 @@ public class MembreRepository extends BaseRepository<Membre> {
|
|||||||
}
|
}
|
||||||
String orderBy = sort != null ? " ORDER BY " + buildOrderBy(sort) : "";
|
String orderBy = sort != null ? " ORDER BY " + buildOrderBy(sort) : "";
|
||||||
TypedQuery<Membre> query = entityManager.createQuery(
|
TypedQuery<Membre> query = entityManager.createQuery(
|
||||||
"SELECT DISTINCT m FROM Membre m JOIN m.membresOrganisations mo WHERE mo.organisation.id IN :organisationIds"
|
"SELECT DISTINCT m FROM Membre m JOIN m.membresOrganisations mo "
|
||||||
|
+ "WHERE mo.organisation.id IN :organisationIds "
|
||||||
|
+ "AND (m.actif = true OR m.actif IS NULL)"
|
||||||
+ orderBy,
|
+ orderBy,
|
||||||
Membre.class);
|
Membre.class);
|
||||||
query.setParameter("organisationIds", organisationIds);
|
query.setParameter("organisationIds", organisationIds);
|
||||||
@@ -103,13 +106,15 @@ public class MembreRepository extends BaseRepository<Membre> {
|
|||||||
return query.getResultList();
|
return query.getResultList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compte les membres distincts appartenant à au moins une des organisations données. */
|
/** Compte les membres distincts appartenant à au moins une des organisations données (filtre actif=true). */
|
||||||
public long countDistinctByOrganisationIdIn(Set<UUID> organisationIds) {
|
public long countDistinctByOrganisationIdIn(Set<UUID> organisationIds) {
|
||||||
if (organisationIds == null || organisationIds.isEmpty()) {
|
if (organisationIds == null || organisationIds.isEmpty()) {
|
||||||
return 0L;
|
return 0L;
|
||||||
}
|
}
|
||||||
TypedQuery<Long> query = entityManager.createQuery(
|
TypedQuery<Long> query = entityManager.createQuery(
|
||||||
"SELECT COUNT(DISTINCT m) FROM Membre m JOIN m.membresOrganisations mo WHERE mo.organisation.id IN :organisationIds",
|
"SELECT COUNT(DISTINCT m) FROM Membre m JOIN m.membresOrganisations mo "
|
||||||
|
+ "WHERE mo.organisation.id IN :organisationIds "
|
||||||
|
+ "AND (m.actif = true OR m.actif IS NULL)",
|
||||||
Long.class);
|
Long.class);
|
||||||
query.setParameter("organisationIds", organisationIds);
|
query.setParameter("organisationIds", organisationIds);
|
||||||
return query.getSingleResult();
|
return query.getSingleResult();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import java.time.LocalDate;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repository pour l'entité MembreRole
|
* Repository pour l'entité MembreRole
|
||||||
@@ -18,6 +19,8 @@ import java.util.UUID;
|
|||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
public class MembreRoleRepository implements PanacheRepository<MembreRole> {
|
public class MembreRoleRepository implements PanacheRepository<MembreRole> {
|
||||||
|
|
||||||
|
private static final Logger LOG = Logger.getLogger(MembreRoleRepository.class);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trouve une attribution membre-role par son UUID
|
* Trouve une attribution membre-role par son UUID
|
||||||
*
|
*
|
||||||
@@ -76,5 +79,70 @@ public class MembreRoleRepository implements PanacheRepository<MembreRole> {
|
|||||||
return find("membreOrganisation.membre.id = ?1 AND role.id = ?2", membreId, roleId)
|
return find("membreOrganisation.membre.id = ?1 AND role.id = ?2", membreId, roleId)
|
||||||
.firstResult();
|
.firstResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compte les administrateurs actifs d'une organisation.
|
||||||
|
*
|
||||||
|
* <p>Le code DB du rôle admin d'organisation est {@code ORGADMIN}
|
||||||
|
* (cf. seed V13__Seed_Standard_Roles.sql). Ne pas confondre avec le rôle
|
||||||
|
* Keycloak {@code ADMIN_ORGANISATION} utilisé dans {@code @RolesAllowed} —
|
||||||
|
* les deux représentent le même concept mais avec un code différent par
|
||||||
|
* source (Keycloak vs table roles DB).
|
||||||
|
*
|
||||||
|
* <p>L'unique constraint {@code uk_mr_membre_org_role} garantit qu'un même
|
||||||
|
* membre n'est comptabilisé qu'une fois même s'il se voit attribuer
|
||||||
|
* plusieurs fois le rôle.
|
||||||
|
*
|
||||||
|
* @param organisationId ID de l'organisation
|
||||||
|
* @return nombre d'admins actifs de cette organisation
|
||||||
|
*/
|
||||||
|
public long countAdminsByOrganisationId(UUID organisationId) {
|
||||||
|
final LocalDate today = LocalDate.now();
|
||||||
|
|
||||||
|
// Diagnostic : inventaire complet des MembreRole liés à cette organisation
|
||||||
|
final long totalForOrg = count("organisation.id = ?1", organisationId);
|
||||||
|
final List<MembreRole> allForOrg = list("organisation.id = ?1", organisationId);
|
||||||
|
final String codesFound = allForOrg.stream()
|
||||||
|
.map(mr -> String.format(
|
||||||
|
"%s[actif=%s,dateDebut=%s,dateFin=%s]",
|
||||||
|
mr.getRole() != null ? mr.getRole().getCode() : "null",
|
||||||
|
mr.getActif(),
|
||||||
|
mr.getDateDebut(),
|
||||||
|
mr.getDateFin()))
|
||||||
|
.reduce((a, b) -> a + ", " + b)
|
||||||
|
.orElse("(aucun)");
|
||||||
|
|
||||||
|
final long strictCount = count(
|
||||||
|
"organisation.id = ?1 AND role.code = ?2 AND actif = true "
|
||||||
|
+ "AND (dateDebut IS NULL OR dateDebut <= ?3) "
|
||||||
|
+ "AND (dateFin IS NULL OR dateFin >= ?3)",
|
||||||
|
organisationId,
|
||||||
|
"ORGADMIN",
|
||||||
|
today);
|
||||||
|
|
||||||
|
LOG.infof(
|
||||||
|
"countAdminsByOrganisationId(org=%s) → strict=%d, total_membres_roles_pour_cette_org=%d, detail=[%s]",
|
||||||
|
organisationId, strictCount, totalForOrg, codesFound);
|
||||||
|
|
||||||
|
// Fallback : si aucun match strict mais qu'il existe des entrées actives
|
||||||
|
// avec un code admin alternatif (ex. ADMIN_ORGANISATION résiduel), on les
|
||||||
|
// compte quand même pour éviter un faux zéro.
|
||||||
|
if (strictCount == 0 && totalForOrg > 0) {
|
||||||
|
final long fallbackCount = count(
|
||||||
|
"organisation.id = ?1 AND role.code IN (?2) AND actif = true "
|
||||||
|
+ "AND (dateDebut IS NULL OR dateDebut <= ?3) "
|
||||||
|
+ "AND (dateFin IS NULL OR dateFin >= ?3)",
|
||||||
|
organisationId,
|
||||||
|
List.of("ORGADMIN", "ADMIN_ORGANISATION", "ADMIN"),
|
||||||
|
today);
|
||||||
|
if (fallbackCount > 0) {
|
||||||
|
LOG.warnf(
|
||||||
|
"countAdminsByOrganisationId(org=%s) strict=0 mais fallback (codes alternatifs)=%d — le seed V13 utilise 'ORGADMIN', vérifier les assignations",
|
||||||
|
organisationId, fallbackCount);
|
||||||
|
return fallbackCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strictCount;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import dev.lions.unionflow.server.entity.Membre;
|
|||||||
import dev.lions.unionflow.server.entity.Organisation;
|
import dev.lions.unionflow.server.entity.Organisation;
|
||||||
import dev.lions.unionflow.server.entity.MembreOrganisation;
|
import dev.lions.unionflow.server.entity.MembreOrganisation;
|
||||||
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
|
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
|
||||||
|
import dev.lions.unionflow.server.repository.MembreRoleRepository;
|
||||||
import dev.lions.unionflow.server.service.MemberLifecycleService;
|
import dev.lions.unionflow.server.service.MemberLifecycleService;
|
||||||
import dev.lions.unionflow.server.service.MembreKeycloakSyncService;
|
import dev.lions.unionflow.server.service.MembreKeycloakSyncService;
|
||||||
import dev.lions.unionflow.server.service.MembreService;
|
import dev.lions.unionflow.server.service.MembreService;
|
||||||
@@ -77,6 +78,9 @@ public class MembreResource {
|
|||||||
@Inject
|
@Inject
|
||||||
MembreOrganisationRepository membreOrgRepository;
|
MembreOrganisationRepository membreOrgRepository;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
MembreRoleRepository membreRoleRepository;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
io.quarkus.security.identity.SecurityIdentity securityIdentity;
|
io.quarkus.security.identity.SecurityIdentity securityIdentity;
|
||||||
|
|
||||||
@@ -1119,6 +1123,10 @@ public class MembreResource {
|
|||||||
@Parameter(description = "UUID du lien membre-organisation") @PathParam("membreOrgId") UUID membreOrgId,
|
@Parameter(description = "UUID du lien membre-organisation") @PathParam("membreOrgId") UUID membreOrgId,
|
||||||
Map<String, String> body) {
|
Map<String, String> body) {
|
||||||
|
|
||||||
|
MembreOrganisation lienCheck = membreOrgRepository.findByIdOptional(membreOrgId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Lien membre-organisation introuvable: " + membreOrgId));
|
||||||
|
verifierOwnershipEtProtectionAdmin(lienCheck);
|
||||||
|
|
||||||
String motif = body != null ? body.get("motif") : null;
|
String motif = body != null ? body.get("motif") : null;
|
||||||
UUID adminId = resolveCurrentAdminId();
|
UUID adminId = resolveCurrentAdminId();
|
||||||
try {
|
try {
|
||||||
@@ -1148,6 +1156,10 @@ public class MembreResource {
|
|||||||
@Parameter(description = "UUID du lien membre-organisation") @PathParam("membreOrgId") UUID membreOrgId,
|
@Parameter(description = "UUID du lien membre-organisation") @PathParam("membreOrgId") UUID membreOrgId,
|
||||||
Map<String, String> body) {
|
Map<String, String> body) {
|
||||||
|
|
||||||
|
MembreOrganisation lienCheck = membreOrgRepository.findByIdOptional(membreOrgId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Lien membre-organisation introuvable: " + membreOrgId));
|
||||||
|
verifierOwnershipEtProtectionAdmin(lienCheck);
|
||||||
|
|
||||||
String motif = body != null ? body.get("motif") : null;
|
String motif = body != null ? body.get("motif") : null;
|
||||||
try {
|
try {
|
||||||
var lien = memberLifecycleService.archiverMembre(membreOrgId, motif);
|
var lien = memberLifecycleService.archiverMembre(membreOrgId, motif);
|
||||||
@@ -1223,6 +1235,7 @@ public class MembreResource {
|
|||||||
return Response.status(Response.Status.NOT_FOUND)
|
return Response.status(Response.Status.NOT_FOUND)
|
||||||
.entity(Map.of("error", "Aucune adhésion trouvée")).build();
|
.entity(Map.of("error", "Aucune adhésion trouvée")).build();
|
||||||
}
|
}
|
||||||
|
verifierOwnershipEtProtectionAdmin(lien);
|
||||||
String motif = body != null ? body.get("motif") : null;
|
String motif = body != null ? body.get("motif") : null;
|
||||||
UUID adminId = resolveCurrentAdminId();
|
UUID adminId = resolveCurrentAdminId();
|
||||||
try {
|
try {
|
||||||
@@ -1258,6 +1271,7 @@ public class MembreResource {
|
|||||||
return Response.status(Response.Status.NOT_FOUND)
|
return Response.status(Response.Status.NOT_FOUND)
|
||||||
.entity(Map.of("error", "Aucune adhésion trouvée")).build();
|
.entity(Map.of("error", "Aucune adhésion trouvée")).build();
|
||||||
}
|
}
|
||||||
|
verifierOwnershipEtProtectionAdmin(lien);
|
||||||
String motif = body != null ? body.get("motif") : null;
|
String motif = body != null ? body.get("motif") : null;
|
||||||
UUID adminId = resolveCurrentAdminId();
|
UUID adminId = resolveCurrentAdminId();
|
||||||
try {
|
try {
|
||||||
@@ -1292,6 +1306,7 @@ public class MembreResource {
|
|||||||
return Response.status(Response.Status.NOT_FOUND)
|
return Response.status(Response.Status.NOT_FOUND)
|
||||||
.entity(Map.of("error", "Aucune adhésion trouvée")).build();
|
.entity(Map.of("error", "Aucune adhésion trouvée")).build();
|
||||||
}
|
}
|
||||||
|
verifierOwnershipEtProtectionAdmin(lien);
|
||||||
String motif = body != null ? body.get("motif") : null;
|
String motif = body != null ? body.get("motif") : null;
|
||||||
var updated = memberLifecycleService.radierMembre(lien.getId(), resolveCurrentAdminId(), motif);
|
var updated = memberLifecycleService.radierMembre(lien.getId(), resolveCurrentAdminId(), motif);
|
||||||
return Response.ok(Map.of("statut", updated.getStatutMembre())).build();
|
return Response.ok(Map.of("statut", updated.getStatutMembre())).build();
|
||||||
@@ -1314,6 +1329,55 @@ public class MembreResource {
|
|||||||
return Response.ok(membreService.convertToResponse(membre)).build();
|
return Response.ok(membreService.convertToResponse(membre)).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie qu'un ADMIN_ORGANISATION :
|
||||||
|
* 1. agit bien sur une organisation dont il est responsable (ownership),
|
||||||
|
* 2. ne tente pas d'agir sur un autre admin (ORGADMIN) ou super-admin (SUPERADMIN).
|
||||||
|
*
|
||||||
|
* Sans effet si l'appelant est SUPER_ADMIN ou ADMIN (ils ont accès total).
|
||||||
|
*
|
||||||
|
* @throws ForbiddenException si l'une des règles est violée
|
||||||
|
*/
|
||||||
|
private void verifierOwnershipEtProtectionAdmin(MembreOrganisation lien) {
|
||||||
|
java.util.Set<String> roles = securityIdentity.getRoles();
|
||||||
|
boolean isOrgAdminOnly = roles != null
|
||||||
|
&& roles.contains("ADMIN_ORGANISATION")
|
||||||
|
&& !roles.contains("ADMIN")
|
||||||
|
&& !roles.contains("SUPER_ADMIN");
|
||||||
|
|
||||||
|
if (!isOrgAdminOnly) {
|
||||||
|
return; // SUPER_ADMIN / ADMIN : accès total, pas de vérification supplémentaire
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. Ownership : l'org cible doit appartenir à l'admin connecté ──────
|
||||||
|
String email = securityIdentity.getPrincipal().getName();
|
||||||
|
java.util.List<UUID> orgIds = organisationService
|
||||||
|
.listerOrganisationsPourUtilisateur(email)
|
||||||
|
.stream()
|
||||||
|
.map(Organisation::getId)
|
||||||
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
|
||||||
|
if (lien.getOrganisation() == null || !orgIds.contains(lien.getOrganisation().getId())) {
|
||||||
|
LOG.warnf("ADMIN_ORGANISATION %s tente d'agir sur org %s qui n'est pas la sienne",
|
||||||
|
email, lien.getOrganisation() != null ? lien.getOrganisation().getId() : "null");
|
||||||
|
throw new ForbiddenException("Vous n'êtes pas autorisé à gérer les membres de cette organisation.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Protection : interdire d'agir sur un admin ou super-admin ────────
|
||||||
|
Membre cible = lien.getMembre();
|
||||||
|
if (cible != null && cible.getId() != null) {
|
||||||
|
long adminCount = membreRoleRepository.count(
|
||||||
|
"membreOrganisation.membre.id = ?1 AND role.code IN (?2) AND actif = true",
|
||||||
|
cible.getId(), java.util.List.of("ORGADMIN", "SUPERADMIN"));
|
||||||
|
if (adminCount > 0) {
|
||||||
|
LOG.warnf("ADMIN_ORGANISATION %s tente d'agir sur l'admin/superadmin membre=%s",
|
||||||
|
email, cible.getId());
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"Vous ne pouvez pas modifier le statut d'un administrateur ou super-administrateur.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Résout l'UUID de l'admin connecté depuis le JWT subject. */
|
/** Résout l'UUID de l'admin connecté depuis le JWT subject. */
|
||||||
private UUID resolveCurrentAdminId() {
|
private UUID resolveCurrentAdminId() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import dev.lions.unionflow.server.entity.Organisation;
|
|||||||
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
|
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
|
||||||
import dev.lions.unionflow.server.repository.MembreRepository;
|
import dev.lions.unionflow.server.repository.MembreRepository;
|
||||||
import dev.lions.unionflow.server.repository.OrganisationRepository;
|
import dev.lions.unionflow.server.repository.OrganisationRepository;
|
||||||
|
import dev.lions.unionflow.server.service.MembreRoleSyncService;
|
||||||
import io.quarkus.security.identity.SecurityIdentity;
|
import io.quarkus.security.identity.SecurityIdentity;
|
||||||
import jakarta.annotation.Priority;
|
import jakarta.annotation.Priority;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
@@ -54,6 +55,9 @@ public class OrganisationContextFilter implements ContainerRequestFilter {
|
|||||||
@Inject
|
@Inject
|
||||||
OrganisationRepository organisationRepository;
|
OrganisationRepository organisationRepository;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
MembreRoleSyncService membreRoleSyncService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void filter(ContainerRequestContext requestContext) throws IOException {
|
public void filter(ContainerRequestContext requestContext) throws IOException {
|
||||||
String orgIdHeader = requestContext.getHeaderString(HEADER_ACTIVE_ORG);
|
String orgIdHeader = requestContext.getHeaderString(HEADER_ACTIVE_ORG);
|
||||||
@@ -128,6 +132,18 @@ public class OrganisationContextFilter implements ContainerRequestFilter {
|
|||||||
.build());
|
.build());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync rôle DB ↔ Keycloak : si l'utilisateur est admin dans Keycloak
|
||||||
|
// mais n'a pas encore de MembreRole ORGADMIN en base, le créer.
|
||||||
|
if (securityIdentity.getRoles().contains("ADMIN_ORGANISATION")) {
|
||||||
|
try {
|
||||||
|
membreRoleSyncService.ensureOrgAdminRole(membreOrg);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Non bloquant — on log et on continue
|
||||||
|
LOG.warnf("ensureOrgAdminRole: erreur non bloquante pour %s : %s",
|
||||||
|
email, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,33 @@ public class AuditService {
|
|||||||
@Inject
|
@Inject
|
||||||
OrganisationRepository organisationRepository;
|
OrganisationRepository organisationRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enregistre un audit de désactivation de membre (soft delete).
|
||||||
|
* Portée GLOBALE (trace centrale de toute action admin sur un compte).
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void logMembreDesactive(UUID membreId, String membreEmail, String adminOperateur,
|
||||||
|
int nbAdhesionsSuspendues, int nbRolesDesactives) {
|
||||||
|
AuditLog auditLog = new AuditLog();
|
||||||
|
auditLog.setTypeAction("MEMBRE_DESACTIVE");
|
||||||
|
auditLog.setSeverite("WARN");
|
||||||
|
auditLog.setUtilisateur(adminOperateur != null ? adminOperateur : "system");
|
||||||
|
auditLog.setModule("MEMBRES");
|
||||||
|
auditLog.setDescription("Désactivation (soft delete) d'un compte membre");
|
||||||
|
auditLog.setDetails(String.format(
|
||||||
|
"membreId=%s, email=%s, adhesionsSuspendues=%d, rolesDesactives=%d",
|
||||||
|
membreId, membreEmail != null ? membreEmail : "", nbAdhesionsSuspendues, nbRolesDesactives));
|
||||||
|
auditLog.setEntiteType("Membre");
|
||||||
|
auditLog.setEntiteId(membreId != null ? membreId.toString() : null);
|
||||||
|
auditLog.setDateHeure(LocalDateTime.now());
|
||||||
|
auditLog.setPortee(PorteeAudit.GLOBAL);
|
||||||
|
try {
|
||||||
|
auditLogRepository.persist(auditLog);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Persist audit MEMBRE_DESACTIVE échoué pour membreId={} : {}", membreId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enregistre un log d'audit LCB-FT lorsqu'une transaction épargne dépasse le seuil.
|
* Enregistre un log d'audit LCB-FT lorsqu'une transaction épargne dépasse le seuil.
|
||||||
* Portée ORGANISATION pour traçabilité anti-blanchiment.
|
* Portée ORGANISATION pour traçabilité anti-blanchiment.
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package dev.lions.unionflow.server.service;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.entity.MembreOrganisation;
|
||||||
|
import dev.lions.unionflow.server.entity.MembreRole;
|
||||||
|
import dev.lions.unionflow.server.repository.MembreRoleRepository;
|
||||||
|
import dev.lions.unionflow.server.repository.RoleRepository;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import jakarta.persistence.PersistenceContext;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronise le rôle DB (MembreRole) avec le rôle Keycloak au premier accès.
|
||||||
|
*
|
||||||
|
* <p>Lorsqu'un utilisateur est admin dans Keycloak (rôle {@code ADMIN_ORGANISATION})
|
||||||
|
* mais n'a pas encore de ligne {@code MembreRole ORGADMIN} en base, ce service la
|
||||||
|
* crée à la volée. Cela couvre deux cas :
|
||||||
|
* <ol>
|
||||||
|
* <li>Comptes créés directement dans Keycloak sans passer par
|
||||||
|
* {@code MembreService#promouvoirAdminOrganisation}.</li>
|
||||||
|
* <li>Bases de données en dev qui ont démarré avant la migration V30.</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class MembreRoleSyncService {
|
||||||
|
|
||||||
|
private static final Logger LOG = Logger.getLogger(MembreRoleSyncService.class);
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
MembreRoleRepository membreRoleRepository;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
RoleRepository roleRepository;
|
||||||
|
|
||||||
|
@PersistenceContext
|
||||||
|
EntityManager entityManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assure qu'un MembreRole ORGADMIN existe pour ce MembreOrganisation.
|
||||||
|
* Idempotent : sans effet si l'entrée existe déjà.
|
||||||
|
*
|
||||||
|
* @param mo MembreOrganisation de l'admin (non null)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void ensureOrgAdminRole(MembreOrganisation mo) {
|
||||||
|
if (mo == null || mo.getId() == null || mo.getOrganisation() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier si un MembreRole ORGADMIN actif existe déjà pour ce membre dans cette org
|
||||||
|
long existing = membreRoleRepository.count(
|
||||||
|
"membreOrganisation.id = ?1 AND role.code = ?2 AND actif = true",
|
||||||
|
mo.getId(), "ORGADMIN");
|
||||||
|
|
||||||
|
if (existing > 0) {
|
||||||
|
return; // Déjà en place — rien à faire
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chercher le rôle ORGADMIN dans la table roles
|
||||||
|
roleRepository.findByCode("ORGADMIN").ifPresentOrElse(
|
||||||
|
role -> {
|
||||||
|
MembreRole mr = new MembreRole();
|
||||||
|
mr.setMembreOrganisation(mo);
|
||||||
|
mr.setOrganisation(mo.getOrganisation());
|
||||||
|
mr.setRole(role);
|
||||||
|
mr.setActif(true);
|
||||||
|
mr.setDateDebut(LocalDate.now());
|
||||||
|
entityManager.persist(mr);
|
||||||
|
LOG.infof(
|
||||||
|
"MembreRole ORGADMIN auto-sync créé pour membre %s dans org %s",
|
||||||
|
mo.getMembre() != null ? mo.getMembre().getId() : "?",
|
||||||
|
mo.getOrganisation().getId());
|
||||||
|
},
|
||||||
|
() -> LOG.warnf(
|
||||||
|
"Rôle ORGADMIN introuvable dans la table roles — "
|
||||||
|
+ "vérifier que V13__Seed_Standard_Roles.sql a été appliqué")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,15 @@ public class MembreService {
|
|||||||
@Inject
|
@Inject
|
||||||
dev.lions.unionflow.server.messaging.KafkaEventProducer kafkaEventProducer;
|
dev.lions.unionflow.server.messaging.KafkaEventProducer kafkaEventProducer;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
MembreKeycloakSyncService keycloakSyncService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
AuditService auditService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
dev.lions.unionflow.server.repository.NotificationRepository notificationRepository;
|
||||||
|
|
||||||
/** Crée un nouveau membre en attente de validation admin */
|
/** Crée un nouveau membre en attente de validation admin */
|
||||||
@Transactional
|
@Transactional
|
||||||
public Membre creerMembre(Membre membre) {
|
public Membre creerMembre(Membre membre) {
|
||||||
@@ -293,7 +302,25 @@ public class MembreService {
|
|||||||
return membreRepository.findByNomOrPrenom(recherche);
|
return membreRepository.findByNomOrPrenom(recherche);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Désactive un membre */
|
/**
|
||||||
|
* Désactive un membre avec propagation complète des cascades métier.
|
||||||
|
*
|
||||||
|
* <p>Garde-fous et effets :
|
||||||
|
* <ol>
|
||||||
|
* <li><b>Check mono-admin</b> : si le membre est le seul ORGADMIN d'une org,
|
||||||
|
* lève {@link jakarta.ws.rs.WebApplicationException} 409 Conflict — l'appelant
|
||||||
|
* doit d'abord assigner un autre admin pour éviter l'orphelinage.</li>
|
||||||
|
* <li>DB : {@code actif=false}, {@code statutCompte='DESACTIVE'}</li>
|
||||||
|
* <li>Toutes les adhésions actives → {@code SUSPENDU}, {@code nombreMembres} décrémenté</li>
|
||||||
|
* <li>Tous les {@link dev.lions.unionflow.server.entity.MembreRole} → {@code actif=false}
|
||||||
|
* (perte immédiate des droits fonctionnels)</li>
|
||||||
|
* <li>Keycloak (lions-user-manager) : {@code user.enabled=false} → login impossible</li>
|
||||||
|
* <li>Kafka : événement {@code member.deactivated} émis pour les consommateurs externes</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>Non couvert (laissé à des services spécialisés) : comptes épargne, cotisations,
|
||||||
|
* inscriptions événements, approbations en attente — à traiter via workflow dédié.
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void desactiverMembre(UUID id) {
|
public void desactiverMembre(UUID id) {
|
||||||
LOG.infof("Désactivation du membre ID: %s", id);
|
LOG.infof("Désactivation du membre ID: %s", id);
|
||||||
@@ -303,18 +330,107 @@ public class MembreService {
|
|||||||
throw new IllegalArgumentException("Membre non trouvé avec l'ID: " + id);
|
throw new IllegalArgumentException("Membre non trouvé avec l'ID: " + id);
|
||||||
}
|
}
|
||||||
|
|
||||||
membre.setActif(false);
|
// ── 1. GARDE-FOU mono-admin : refuser si l'orphelinage créerait une org sans admin ──
|
||||||
|
List<String> orgsOrphelines = verifierOrgsOrphelinees(id);
|
||||||
|
if (!orgsOrphelines.isEmpty()) {
|
||||||
|
final String msg = "Suppression impossible : ce membre est le seul administrateur de "
|
||||||
|
+ orgsOrphelines.size() + " organisation(s) ("
|
||||||
|
+ String.join(", ", orgsOrphelines)
|
||||||
|
+ "). Veuillez d'abord désigner un autre administrateur avant de supprimer ce compte.";
|
||||||
|
LOG.warnf("Refus désactivation %s (mono-admin de %s)", id, orgsOrphelines);
|
||||||
|
throw new jakarta.ws.rs.WebApplicationException(msg, jakarta.ws.rs.core.Response.Status.CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
// Décrémenter le compteur nombreMembres pour chaque organisation active du membre
|
// ── 2. DB : flags principaux du membre ───────────────────────────────────────────
|
||||||
// (fix DATA-01 : le compteur restait figé lors d'une désactivation directe)
|
membre.setActif(false);
|
||||||
membreOrganisationRepository.findOrganisationsActivesParMembre(id).forEach(mo -> {
|
membre.setStatutCompte("DESACTIVE");
|
||||||
|
|
||||||
|
// ── 3. Adhésions actives → SUSPENDU + décrément compteur org ─────────────────────
|
||||||
|
final var adhesionsActives = membreOrganisationRepository.findOrganisationsActivesParMembre(id);
|
||||||
|
for (var mo : adhesionsActives) {
|
||||||
mo.getOrganisation().retirerMembre();
|
mo.getOrganisation().retirerMembre();
|
||||||
mo.setStatutMembre(dev.lions.unionflow.server.api.enums.membre.StatutMembre.SUSPENDU);
|
mo.setStatutMembre(dev.lions.unionflow.server.api.enums.membre.StatutMembre.SUSPENDU);
|
||||||
LOG.infof("Compteur membres décrémenté pour organisation %s (membre désactivé)",
|
}
|
||||||
mo.getOrganisation().getId());
|
final int nbAdhesionsSuspendues = adhesionsActives.size();
|
||||||
});
|
|
||||||
|
|
||||||
LOG.infof("Membre désactivé: %s", membre.getNomComplet());
|
// ── 4. Désactivation des rôles fonctionnels (ORGADMIN, TRESORIER, etc.) ─────────
|
||||||
|
final int rolesDesactives = (int) membreRoleRepository.update(
|
||||||
|
"actif = false, dateFin = ?1, modifiePar = ?2 "
|
||||||
|
+ "WHERE membreOrganisation.membre.id = ?3 AND actif = true",
|
||||||
|
LocalDate.now(), "system", id);
|
||||||
|
LOG.infof("%d MembreRole désactivés pour le membre %s", rolesDesactives, id);
|
||||||
|
|
||||||
|
// ── 5. Annulation des notifications pending pour ce membre ──────────────────────
|
||||||
|
try {
|
||||||
|
final long notifsAnnulees = notificationRepository.update(
|
||||||
|
"statut = ?1, dateModification = ?2 "
|
||||||
|
+ "WHERE membre.id = ?3 AND statut IN (?4, ?5) AND actif = true",
|
||||||
|
"ANNULEE", java.time.LocalDateTime.now(), id,
|
||||||
|
"EN_ATTENTE", "ECHEC_TEMPORAIRE");
|
||||||
|
if (notifsAnnulees > 0) {
|
||||||
|
LOG.infof("%d notifications pending annulées pour membre %s", notifsAnnulees, id);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warnf("Annulation notifications pending échouée pour %s : %s", id, e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. Propagation Keycloak (non bloquant) ───────────────────────────────────────
|
||||||
|
try {
|
||||||
|
keycloakSyncService.syncMembreToKeycloak(id);
|
||||||
|
LOG.infof("Compte Keycloak désactivé pour membre %s", id);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warnf("Sync Keycloak échouée pour membre %s : %s (DB reste cohérente)",
|
||||||
|
id, e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 7. Événement Kafka pour les autres modules/services ─────────────────────────
|
||||||
|
try {
|
||||||
|
kafkaEventProducer.publishMemberDeactivated(membre);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warnf("Publication Kafka member.deactivated échouée pour %s : %s", id, e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 8. Audit log (traçabilité RGPD/compliance) ──────────────────────────────────
|
||||||
|
try {
|
||||||
|
String operateur = securityIdentity != null && !securityIdentity.isAnonymous()
|
||||||
|
? securityIdentity.getPrincipal().getName()
|
||||||
|
: "system";
|
||||||
|
auditService.logMembreDesactive(id, membre.getEmail(), operateur,
|
||||||
|
nbAdhesionsSuspendues, rolesDesactives);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warnf("Audit log MEMBRE_DESACTIVE échoué pour %s : %s", id, e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG.infof("Membre désactivé avec cascade complète : %s (adhésions=%d, rôles=%d)",
|
||||||
|
membre.getNomComplet(), nbAdhesionsSuspendues, rolesDesactives);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si la désactivation d'un membre entraînerait l'orphelinage d'organisations
|
||||||
|
* (i.e. le membre est le seul ORGADMIN actif d'au moins une org).
|
||||||
|
*
|
||||||
|
* @return liste des noms d'organisations qui deviendraient orphelines (vide si OK)
|
||||||
|
*/
|
||||||
|
private List<String> verifierOrgsOrphelinees(UUID membreId) {
|
||||||
|
List<String> orphelines = new ArrayList<>();
|
||||||
|
// Toutes les orgs où ce membre est ORGADMIN actif
|
||||||
|
final LocalDate today = LocalDate.now();
|
||||||
|
List<dev.lions.unionflow.server.entity.MembreRole> rolesAdmin = membreRoleRepository.list(
|
||||||
|
"membreOrganisation.membre.id = ?1 AND role.code = ?2 AND actif = true "
|
||||||
|
+ "AND (dateDebut IS NULL OR dateDebut <= ?3) "
|
||||||
|
+ "AND (dateFin IS NULL OR dateFin >= ?3)",
|
||||||
|
membreId, "ORGADMIN", today);
|
||||||
|
|
||||||
|
for (var role : rolesAdmin) {
|
||||||
|
if (role.getOrganisation() == null) continue;
|
||||||
|
UUID orgId = role.getOrganisation().getId();
|
||||||
|
long totalAdmins = membreRoleRepository.countAdminsByOrganisationId(orgId);
|
||||||
|
// Si ce membre est le seul admin (total=1) et qu'on le désactive → org orpheline
|
||||||
|
if (totalAdmins <= 1) {
|
||||||
|
orphelines.add(role.getOrganisation().getNom());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return orphelines;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Génère un numéro de membre unique */
|
/** Génère un numéro de membre unique */
|
||||||
@@ -342,10 +458,11 @@ public class MembreService {
|
|||||||
if (ids.isEmpty()) return List.of();
|
if (ids.isEmpty()) return List.of();
|
||||||
return membreRepository.findDistinctByOrganisationIdIn(ids, page, sort);
|
return membreRepository.findDistinctByOrganisationIdIn(ids, page, sort);
|
||||||
}
|
}
|
||||||
return membreRepository.findAll(page, sort);
|
// SuperAdmin : filtre les désactivés par défaut (ne pas polluer les listes UI)
|
||||||
|
return membreRepository.findAllActifs(page, sort);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compte les membres. Pour ADMIN_ORGANISATION, compte uniquement les membres de ses organisations. */
|
/** Compte les membres actifs. Pour ADMIN_ORGANISATION, compte uniquement les membres de ses organisations. */
|
||||||
public long compterMembres() {
|
public long compterMembres() {
|
||||||
Optional<Set<UUID>> orgIds = getOrganisationIdsForCurrentUserIfAdminOrg();
|
Optional<Set<UUID>> orgIds = getOrganisationIdsForCurrentUserIfAdminOrg();
|
||||||
if (orgIds.isPresent()) {
|
if (orgIds.isPresent()) {
|
||||||
@@ -353,7 +470,7 @@ public class MembreService {
|
|||||||
if (ids.isEmpty()) return 0L;
|
if (ids.isEmpty()) return 0L;
|
||||||
return membreRepository.countDistinctByOrganisationIdIn(ids);
|
return membreRepository.countDistinctByOrganisationIdIn(ids);
|
||||||
}
|
}
|
||||||
return membreRepository.count();
|
return membreRepository.countActifs();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Recherche des membres avec pagination. Pour ADMIN_ORGANISATION, limite aux membres de ses organisations. */
|
/** Recherche des membres avec pagination. Pour ADMIN_ORGANISATION, limite aux membres de ses organisations. */
|
||||||
@@ -394,14 +511,18 @@ public class MembreService {
|
|||||||
long membresActifs = membreRepository.countActifs();
|
long membresActifs = membreRepository.countActifs();
|
||||||
long membresInactifs = totalMembres - membresActifs;
|
long membresInactifs = totalMembres - membresActifs;
|
||||||
long nouveauxMembres30Jours = membreRepository.countNouveauxMembres(LocalDate.now().minusDays(30));
|
long nouveauxMembres30Jours = membreRepository.countNouveauxMembres(LocalDate.now().minusDays(30));
|
||||||
|
long totalOrganisations = organisationService.rechercherOrganisationsCount("");
|
||||||
|
|
||||||
return Map.of(
|
Map<String, Object> stats = new java.util.HashMap<>();
|
||||||
"totalMembres", totalMembres,
|
stats.put("totalMembres", totalMembres);
|
||||||
"membresActifs", membresActifs,
|
stats.put("total", totalMembres); // alias pour compatibilité mobile
|
||||||
"membresInactifs", membresInactifs,
|
stats.put("membresActifs", membresActifs);
|
||||||
"nouveauxMembres30Jours", nouveauxMembres30Jours,
|
stats.put("membresInactifs", membresInactifs);
|
||||||
"tauxActivite", totalMembres > 0 ? (membresActifs * 100.0 / totalMembres) : 0.0,
|
stats.put("nouveauxMembres30Jours", nouveauxMembres30Jours);
|
||||||
"timestamp", LocalDateTime.now());
|
stats.put("tauxActivite", totalMembres > 0 ? (membresActifs * 100.0 / totalMembres) : 0.0);
|
||||||
|
stats.put("totalOrganisations", totalOrganisations);
|
||||||
|
stats.put("timestamp", LocalDateTime.now());
|
||||||
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import dev.lions.unionflow.server.repository.AdresseRepository;
|
|||||||
import dev.lions.unionflow.server.repository.EvenementRepository;
|
import dev.lions.unionflow.server.repository.EvenementRepository;
|
||||||
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
|
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
|
||||||
import dev.lions.unionflow.server.repository.MembreRepository;
|
import dev.lions.unionflow.server.repository.MembreRepository;
|
||||||
|
import dev.lions.unionflow.server.repository.MembreRoleRepository;
|
||||||
import dev.lions.unionflow.server.repository.TypeReferenceRepository;
|
import dev.lions.unionflow.server.repository.TypeReferenceRepository;
|
||||||
import dev.lions.unionflow.server.entity.Organisation;
|
import dev.lions.unionflow.server.entity.Organisation;
|
||||||
import dev.lions.unionflow.server.repository.OrganisationRepository;
|
import dev.lions.unionflow.server.repository.OrganisationRepository;
|
||||||
@@ -63,6 +64,9 @@ public class OrganisationService {
|
|||||||
@Inject
|
@Inject
|
||||||
EvenementRepository evenementRepository;
|
EvenementRepository evenementRepository;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
MembreRoleRepository membreRoleRepository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Crée une nouvelle organisation
|
* Crée une nouvelle organisation
|
||||||
*
|
*
|
||||||
@@ -565,11 +569,16 @@ public class OrganisationService {
|
|||||||
dto.setObjectifs(organisation.getObjectifs());
|
dto.setObjectifs(organisation.getObjectifs());
|
||||||
dto.setActivitesPrincipales(organisation.getActivitesPrincipales());
|
dto.setActivitesPrincipales(organisation.getActivitesPrincipales());
|
||||||
dto.setNombreMembres(organisation.getNombreMembres());
|
dto.setNombreMembres(organisation.getNombreMembres());
|
||||||
dto.setNombreAdministrateurs(organisation.getNombreAdministrateurs());
|
|
||||||
if (organisation.getId() != null) {
|
if (organisation.getId() != null) {
|
||||||
|
// Compte dynamique des administrateurs (rôle ADMIN_ORGANISATION actif)
|
||||||
|
// — le champ Organisation.nombreAdministrateurs n'est pas tenu à jour.
|
||||||
|
long countAdmins = membreRoleRepository.countAdminsByOrganisationId(organisation.getId());
|
||||||
|
dto.setNombreAdministrateurs((int) countAdmins);
|
||||||
|
|
||||||
long countEvenements = evenementRepository.countActifsByOrganisationId(organisation.getId());
|
long countEvenements = evenementRepository.countActifsByOrganisationId(organisation.getId());
|
||||||
dto.setNombreEvenements((int) countEvenements);
|
dto.setNombreEvenements((int) countEvenements);
|
||||||
} else {
|
} else {
|
||||||
|
dto.setNombreAdministrateurs(0);
|
||||||
dto.setNombreEvenements(0);
|
dto.setNombreEvenements(0);
|
||||||
}
|
}
|
||||||
dto.setBudgetAnnuel(organisation.getBudgetAnnuel());
|
dto.setBudgetAnnuel(organisation.getBudgetAnnuel());
|
||||||
|
|||||||
@@ -24,17 +24,17 @@ quarkus.http.cors.origins=*
|
|||||||
|
|
||||||
# Keycloak / OIDC local
|
# Keycloak / OIDC local
|
||||||
quarkus.oidc.tenant-enabled=true
|
quarkus.oidc.tenant-enabled=true
|
||||||
quarkus.oidc.auth-server-url=http://localhost:8180/realms/unionflow
|
quarkus.oidc.auth-server-url=http://192.168.1.145:8180/realms/unionflow
|
||||||
quarkus.oidc.client-id=unionflow-server
|
quarkus.oidc.client-id=unionflow-server
|
||||||
# Audience mapper configuré sur unionflow-client et unionflow-mobile dans Keycloak
|
# Audience mapper configuré sur unionflow-client et unionflow-mobile dans Keycloak
|
||||||
# → les tokens contiennent désormais "unionflow-server" dans le claim aud
|
# → les tokens contiennent désormais "unionflow-server" dans le claim aud
|
||||||
quarkus.oidc.token.audience=unionflow-server
|
quarkus.oidc.token.audience=unionflow-server
|
||||||
quarkus.oidc.credentials.secret=unionflow-secret-2025
|
quarkus.oidc.credentials.secret=Esj0DzyRt7wSPtcePDae1dQQdqmQxlJm
|
||||||
quarkus.oidc.tls.verification=none
|
quarkus.oidc.tls.verification=none
|
||||||
# Issuer dynamique — Keycloak émet iss=http://<IP_LAN>:8180/... quand le mobile
|
# En dev : accepter les tokens dont l'issuer est l'IP locale (mobile) ou localhost (web)
|
||||||
# accède via le réseau local. DEV_HOST dans .env doit correspondre à l'IP de la machine.
|
# issuer=any est requis car le mobile obtient ses tokens via l'IP LAN (192.168.x.x)
|
||||||
# Garder en sync avec android/local.properties → dev.host
|
# alors que le backend peut tourner sur localhost — les deux issuers seraient différents.
|
||||||
quarkus.oidc.token.issuer=http://${DEV_HOST:localhost}:8180/realms/unionflow
|
quarkus.oidc.token.issuer=any
|
||||||
|
|
||||||
# OpenAPI — serveur dev
|
# OpenAPI — serveur dev
|
||||||
quarkus.smallrye-openapi.servers=http://localhost:8085
|
quarkus.smallrye-openapi.servers=http://localhost:8085
|
||||||
@@ -63,3 +63,9 @@ quarkus.oidc-client.admin-service.credentials.secret=Esj0DzyRt7wSPtcePDae1dQQdqm
|
|||||||
quarkus.oidc-client.admin-service.grant.type=client
|
quarkus.oidc-client.admin-service.grant.type=client
|
||||||
quarkus.oidc-client.admin-service.tls.verification=none
|
quarkus.oidc-client.admin-service.tls.verification=none
|
||||||
quarkus.oidc-client.admin-service.early-tokens-acquisition=true
|
quarkus.oidc-client.admin-service.early-tokens-acquisition=true
|
||||||
|
|
||||||
|
# Keycloak Admin API (dev)
|
||||||
|
keycloak.admin.url=http://192.168.1.145:8180
|
||||||
|
keycloak.admin.username=${KEYCLOAK_ADMIN_USERNAME:admin}
|
||||||
|
keycloak.admin.password=${KEYCLOAK_ADMIN_PASSWORD:admin}
|
||||||
|
keycloak.admin.realm=unionflow
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- V30 — Alignement membres_roles avec l'entité Java MembreRole
|
||||||
|
--
|
||||||
|
-- Contexte : Hibernate update mode a pu créer membres_roles avant V1 (depuis
|
||||||
|
-- l'entité Java), OU V1 l'a créé avec membre_id. Les deux cas sont gérés.
|
||||||
|
-- Toutes les opérations sont défensives (IF EXISTS / DO blocks).
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- 1. Rendre membre_id nullable si la colonne existe encore (cas V1-first)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'membres_roles' AND column_name = 'membre_id'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE membres_roles ALTER COLUMN membre_id DROP NOT NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 2. Ajouter membre_organisation_id si absente
|
||||||
|
ALTER TABLE membres_roles ADD COLUMN IF NOT EXISTS membre_organisation_id UUID
|
||||||
|
REFERENCES membres_organisations(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- 3. Ajouter organisation_id si absente
|
||||||
|
ALTER TABLE membres_roles ADD COLUMN IF NOT EXISTS organisation_id UUID
|
||||||
|
REFERENCES organisations(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- 4. Ajouter date_debut / date_fin / commentaire si absents
|
||||||
|
ALTER TABLE membres_roles ADD COLUMN IF NOT EXISTS date_debut DATE;
|
||||||
|
ALTER TABLE membres_roles ADD COLUMN IF NOT EXISTS date_fin DATE;
|
||||||
|
ALTER TABLE membres_roles ADD COLUMN IF NOT EXISTS commentaire VARCHAR(500);
|
||||||
|
|
||||||
|
-- 5. Ajouter les colonnes BaseEntity manquantes
|
||||||
|
ALTER TABLE membres_roles ADD COLUMN IF NOT EXISTS date_modification TIMESTAMP;
|
||||||
|
ALTER TABLE membres_roles ADD COLUMN IF NOT EXISTS version BIGINT DEFAULT 0;
|
||||||
|
ALTER TABLE membres_roles ADD COLUMN IF NOT EXISTS cree_par VARCHAR(255);
|
||||||
|
ALTER TABLE membres_roles ADD COLUMN IF NOT EXISTS modifie_par VARCHAR(255);
|
||||||
|
|
||||||
|
-- 6. Supprimer l'ancienne contrainte unique V1 (membre_id, role_id) si présente
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'uk_membre_role' AND conrelid = 'membres_roles'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE membres_roles DROP CONSTRAINT uk_membre_role;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 7. Ajouter la contrainte unique (membre_organisation_id, role_id) si absente
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'uk_mr_membre_org_role' AND conrelid = 'membres_roles'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE membres_roles
|
||||||
|
ADD CONSTRAINT uk_mr_membre_org_role
|
||||||
|
UNIQUE (membre_organisation_id, role_id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 8. Index de performance (IF NOT EXISTS — idempotent)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mr_membre_org ON membres_roles (membre_organisation_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mr_organisation ON membres_roles (organisation_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mr_role ON membres_roles (role_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mr_actif ON membres_roles (actif);
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- V31 — Corrige les colonnes legacy NOT NULL de la table notifications
|
||||||
|
--
|
||||||
|
-- V1 a créé les colonnes suivantes avec NOT NULL :
|
||||||
|
-- - destinataire_id UUID
|
||||||
|
-- - titre VARCHAR(255)
|
||||||
|
--
|
||||||
|
-- L'entité Java Notification utilise désormais membre_id et sujet/corps, mais
|
||||||
|
-- les anciennes colonnes sont restées NOT NULL → les INSERTs depuis l'entité
|
||||||
|
-- échouent avec 23502 (violation NOT NULL).
|
||||||
|
--
|
||||||
|
-- Fix : rendre ces colonnes nullables (on ne les utilise plus depuis l'entité).
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- 1. destinataire_id : rendre nullable
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'notifications'
|
||||||
|
AND column_name = 'destinataire_id'
|
||||||
|
AND is_nullable = 'NO'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE notifications ALTER COLUMN destinataire_id DROP NOT NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 2. titre : rendre nullable (remplacé par sujet)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'notifications'
|
||||||
|
AND column_name = 'titre'
|
||||||
|
AND is_nullable = 'NO'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE notifications ALTER COLUMN titre DROP NOT NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 3. nombre_tentatives : rendre nullable (défaut 0 déjà présent, mais NOT NULL bloque si l'entité ne le met pas)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'notifications'
|
||||||
|
AND column_name = 'nombre_tentatives'
|
||||||
|
AND is_nullable = 'NO'
|
||||||
|
) THEN
|
||||||
|
-- Garder le default mais enlever NOT NULL pour éviter les crashes si l'entité ne définit pas la valeur
|
||||||
|
ALTER TABLE notifications ALTER COLUMN nombre_tentatives DROP NOT NULL;
|
||||||
|
ALTER TABLE notifications ALTER COLUMN nombre_tentatives SET DEFAULT 0;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
Reference in New Issue
Block a user