feat(sprint-10 backend 2026-04-25): Resources REST UBO + audit trail + délégation + enrich update org
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 4m30s

Expose les features Sprints 1-2 via API REST. Architecture stricte respectée :
Resource ↔ Service ↔ Repository ↔ Entity ↔ DTO. Mapping centralisé en Service.

Bump dépendance api 1.0.7 → 1.0.8 (DTOs nouveaux)

UBO (Instr. BCEAO 003-03-2025)
- BeneficiaireEffectifService : creer (validation cible obligatoire), mettreAJour (PATCH), desactiver (soft-delete), validation seuilPourcentage (somme cumul ≤ 100, exclusion en mode update), audit trail systématique
- BeneficiaireEffectifResource : CRUD /api/kyc/beneficiaires-effectifs, filtres queryParam (kycDossierId | organisationCibleId | pep), @RolesAllowed COMPLIANCE_OFFICER + ADMIN_ORG + SUPER_ADMIN

Audit trail (CQRS read-side)
- AuditTrailQueryService NEW : 5 méthodes lecture seule (parUtilisateur, historiqueEntite, parOrganisation, violationsSod, operationsFinancieres) + mapping Entity→DTO
- AuditTrailOperationResource : /api/audit-trail/{by-user|by-entity|by-organisation|sod-violations|financial}, parsing dates ISO + fallbacks
- Distinct du AuditTrailService (write-side dans security/) : Single Responsibility appliqué

Délégation rôles (Sprint 2 service réutilisé)
- RoleDelegationService enrichi : creerDepuisRequest (DTO→entité→creer existant), revoquerEtRetourner, listerParOrganisation, toResponse (mapping centralisé)
- RoleDelegationRepository : findByOrganisation
- RoleDelegationResource : POST/DELETE/GET /api/role-delegations

Enrichissement Organisation update (DRY)
- OrganisationService.convertFromUpdateRequest : parseReferentielComptable (fallback ReferentielComptable.defaultFor) + complianceOfficerId
- OrganisationService.mettreAJourOrganisation : propagation conditionnelle des nouveaux champs

Tests (en attente publication api 1.0.8 — install local nécessaire)
- BeneficiaireEffectifServiceTest : 8 tests verifierSeuilPourcentage (OK / limite / dépassement / excludeId / null / inactifs ignorés) + creer + toResponse mapping
- AuditTrailQueryServiceTest : 4 tests parUtilisateur, sodViolations, toResponse mapping, historiqueEntite

ACTION USER : `mvn install` côté unionflow-server-api pour rendre 1.0.8 dispo en m2 local + publier via script/publish-api.sh pour Gitea.
This commit is contained in:
dahoud
2026-04-25 12:32:41 +00:00
parent 4d400dc48d
commit 241533efa6
11 changed files with 901 additions and 1 deletions

View File

@@ -0,0 +1,89 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.api.dto.audit.response.AuditTrailOperationResponse;
import dev.lions.unionflow.server.service.audit.AuditTrailQueryService;
import io.quarkus.security.Authenticated;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
/**
* Endpoints REST de lecture de l'audit trail enrichi (Sprint 1, exposé Sprint 10).
*
* <p>Cible : compliance officer / contrôleur interne / SUPER_ADMIN. Seule la lecture
* est exposée — les écritures sont produites automatiquement par les services métier
* via {@code AuditTrailService}.
*
* @since 2026-04-25 (Sprint 10)
*/
@Path("/api/audit-trail")
@Produces(MediaType.APPLICATION_JSON)
@Authenticated
public class AuditTrailOperationResource {
@Inject AuditTrailQueryService queryService;
@GET
@Path("/by-user/{userId}")
@RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "SUPER_ADMIN"})
public List<AuditTrailOperationResponse> parUtilisateur(
@PathParam("userId") UUID userId,
@QueryParam("from") String from,
@QueryParam("to") String to) {
LocalDateTime fromDt = parseDateTime(from, LocalDateTime.now().minusDays(30));
LocalDateTime toDt = parseDateTime(to, LocalDateTime.now());
return queryService.rechercherParUtilisateur(userId, fromDt, toDt);
}
@GET
@Path("/by-entity/{type}/{id}")
@RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "SUPER_ADMIN"})
public List<AuditTrailOperationResponse> historique(
@PathParam("type") String entityType,
@PathParam("id") UUID entityId) {
return queryService.historiqueEntite(entityType, entityId);
}
@GET
@Path("/by-organisation/{orgId}")
@RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "ADMIN_ORGANISATION", "SUPER_ADMIN"})
public List<AuditTrailOperationResponse> parOrganisation(@PathParam("orgId") UUID orgId) {
return queryService.rechercherParOrganisation(orgId);
}
@GET
@Path("/sod-violations")
@RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "SUPER_ADMIN"})
public List<AuditTrailOperationResponse> violationsSod() {
return queryService.violationsSod();
}
@GET
@Path("/financial/{orgId}")
@RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "TRESORIER", "SUPER_ADMIN"})
public List<AuditTrailOperationResponse> operationsFinancieres(
@PathParam("orgId") UUID orgId,
@QueryParam("from") String from,
@QueryParam("to") String to) {
LocalDateTime fromDt = parseDateTime(from, LocalDateTime.now().minusDays(90));
LocalDateTime toDt = parseDateTime(to, LocalDateTime.now());
return queryService.operationsFinancieres(orgId, fromDt, toDt);
}
private LocalDateTime parseDateTime(String input, LocalDateTime fallback) {
if (input == null || input.isBlank()) return fallback;
try {
return LocalDateTime.parse(input);
} catch (Exception e) {
return fallback;
}
}
}

View File

@@ -0,0 +1,83 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.api.dto.kyc.request.CreateBeneficiaireEffectifRequest;
import dev.lions.unionflow.server.api.dto.kyc.request.UpdateBeneficiaireEffectifRequest;
import dev.lions.unionflow.server.api.dto.kyc.response.BeneficiaireEffectifResponse;
import dev.lions.unionflow.server.service.kyc.BeneficiaireEffectifService;
import io.quarkus.security.Authenticated;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.UUID;
/**
* Endpoints REST des Bénéficiaires Effectifs (UBO) — Instr. BCEAO 003-03-2025.
*
* <p>Cette Resource ne fait QUE le mapping HTTP ↔ Service. Toute la logique
* métier (validation pourcentages, audit trail, persistence) est dans
* {@link BeneficiaireEffectifService}.
*
* @since 2026-04-25 (Sprint 10)
*/
@Path("/api/kyc/beneficiaires-effectifs")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Authenticated
public class BeneficiaireEffectifResource {
@Inject BeneficiaireEffectifService service;
@GET
@RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "ADMIN_ORGANISATION", "SUPER_ADMIN"})
public List<BeneficiaireEffectifResponse> lister(
@QueryParam("kycDossierId") UUID kycDossierId,
@QueryParam("organisationCibleId") UUID organisationCibleId,
@QueryParam("pep") Boolean pep) {
if (Boolean.TRUE.equals(pep)) return service.listerPep();
if (kycDossierId != null) return service.listerParKycDossier(kycDossierId);
if (organisationCibleId != null) return service.listerParOrganisationCible(organisationCibleId);
return List.of();
}
@GET
@Path("/{id}")
@RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "ADMIN_ORGANISATION", "SUPER_ADMIN"})
public BeneficiaireEffectifResponse trouverParId(@PathParam("id") UUID id) {
return service.trouverParId(id);
}
@POST
@RolesAllowed({"COMPLIANCE_OFFICER", "ADMIN_ORGANISATION", "SUPER_ADMIN"})
public Response creer(@Valid CreateBeneficiaireEffectifRequest request) {
BeneficiaireEffectifResponse created = service.creer(request);
return Response.status(Response.Status.CREATED).entity(created).build();
}
@PUT
@Path("/{id}")
@RolesAllowed({"COMPLIANCE_OFFICER", "ADMIN_ORGANISATION", "SUPER_ADMIN"})
public BeneficiaireEffectifResponse mettreAJour(
@PathParam("id") UUID id, @Valid UpdateBeneficiaireEffectifRequest request) {
return service.mettreAJour(id, request);
}
@DELETE
@Path("/{id}")
@RolesAllowed({"COMPLIANCE_OFFICER", "ADMIN_ORGANISATION", "SUPER_ADMIN"})
public Response desactiver(@PathParam("id") UUID id) {
service.desactiver(id);
return Response.noContent().build();
}
}

View File

@@ -0,0 +1,73 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.api.dto.delegation.request.CreateRoleDelegationRequest;
import dev.lions.unionflow.server.api.dto.delegation.response.RoleDelegationResponse;
import dev.lions.unionflow.server.service.delegation.RoleDelegationService;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.Authenticated;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.Set;
import java.util.UUID;
/**
* Endpoints REST des délégations de rôle (Sprint 10 — service Sprint 2).
*
* <p>La Resource ne fait que mapper HTTP ↔ {@link RoleDelegationService}. La
* logique SoD, validation de dates, audit trail est dans le Service.
*
* <p>Le set des rôles du délégataire est passé via {@code SecurityIdentity} pour
* vérification SoD : le client web/mobile fournit cette info via header (à terme).
*
* @since 2026-04-25 (Sprint 10)
*/
@Path("/api/role-delegations")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Authenticated
public class RoleDelegationResource {
@Inject RoleDelegationService service;
@Inject SecurityIdentity securityIdentity;
@GET
@Path("/organisation/{orgId}")
@RolesAllowed({"ADMIN_ORGANISATION", "PRESIDENT", "SUPER_ADMIN", "COMPLIANCE_OFFICER"})
public List<RoleDelegationResponse> listerParOrganisation(@PathParam("orgId") UUID orgId) {
return service.listerParOrganisation(orgId);
}
@POST
@RolesAllowed({"ADMIN_ORGANISATION", "PRESIDENT", "SUPER_ADMIN"})
public Response creer(
@Valid CreateRoleDelegationRequest request,
@QueryParam("rolesDelegataire") String rolesDelegataireCsv) {
Set<String> rolesDelegataire = parseRoles(rolesDelegataireCsv);
RoleDelegationResponse created = service.creerDepuisRequest(request, rolesDelegataire);
return Response.status(Response.Status.CREATED).entity(created).build();
}
@DELETE
@Path("/{id}")
@RolesAllowed({"ADMIN_ORGANISATION", "PRESIDENT", "SUPER_ADMIN"})
public RoleDelegationResponse revoquer(@PathParam("id") UUID id) {
return service.revoquerEtRetourner(id);
}
private Set<String> parseRoles(String csv) {
if (csv == null || csv.isBlank()) return Set.of();
return Set.of(csv.split(","));
}
}