From e4d7c8e4b7f6d61c5056f883498edd21febb1e75 Mon Sep 17 00:00:00 2001 From: dahoud <41957584+DahoudG@users.noreply.github.com> Date: Sat, 25 Apr 2026 16:08:33 +0000 Subject: [PATCH] =?UTF-8?q?feat(sprint-15=20backend=202026-04-25):=20Live?= =?UTF-8?q?=20Activity=20Feed=20=E2=80=94=20endpoint=20/api/audit-trail/re?= =?UTF-8?q?cent=20+=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transparency opérationnelle UnionFlow — opérations sensibles consultables en temps réel selon scope. Repository - findRecent(limit) : N opérations les plus récentes, toutes orgs - findRecentByOrganisation(orgId, limit) : pour scope ORG - findRecentByUser(userId, limit) : pour scope SELF - Tous clamps limit à [1, 500] (sécurité DoS) Service AuditTrailQueryService - listerRecentes(scope, orgId, userId, limit) : dispatcher SELF/ORG/ALL avec fallback liste vide si UUID requis manquant ; insensible à la casse Resource REST AuditTrailOperationResource - GET /api/audit-trail/recent?scope=SELF&orgId=...&userId=...&limit=50 - Default scope=SELF, limit=50 - @RolesAllowed étendu (MEMBRE inclus pour scope SELF) — un membre peut voir SES propres opérations Tests (7 nouveaux, 11/11 cumulé service) - recentes_All / Null defaults to ALL / Org / OrgWithoutId / Self / SelfWithoutUser / CaseInsensitive --- .../AuditTrailOperationRepository.java | 17 ++++++ .../resource/AuditTrailOperationResource.java | 24 ++++++++ .../service/audit/AuditTrailQueryService.java | 26 ++++++++ .../audit/AuditTrailQueryServiceTest.java | 61 +++++++++++++++++++ 4 files changed, 128 insertions(+) diff --git a/src/main/java/dev/lions/unionflow/server/repository/AuditTrailOperationRepository.java b/src/main/java/dev/lions/unionflow/server/repository/AuditTrailOperationRepository.java index 52ce672..58cbe60 100644 --- a/src/main/java/dev/lions/unionflow/server/repository/AuditTrailOperationRepository.java +++ b/src/main/java/dev/lions/unionflow/server/repository/AuditTrailOperationRepository.java @@ -47,4 +47,21 @@ public class AuditTrailOperationRepository + "ORDER BY operationAt DESC", organisationId, from, to); } + + /** N opérations les plus récentes — toutes organisations confondues (Live Feed). */ + public List findRecent(int limit) { + return find("ORDER BY operationAt DESC").page(0, Math.max(1, Math.min(limit, 500))).list(); + } + + /** N opérations les plus récentes pour une organisation. */ + public List findRecentByOrganisation(UUID organisationId, int limit) { + return find("organisationActiveId = ?1 ORDER BY operationAt DESC", organisationId) + .page(0, Math.max(1, Math.min(limit, 500))).list(); + } + + /** N opérations les plus récentes d'un utilisateur. */ + public List findRecentByUser(UUID userId, int limit) { + return find("userId = ?1 ORDER BY operationAt DESC", userId) + .page(0, Math.max(1, Math.min(limit, 500))).list(); + } } diff --git a/src/main/java/dev/lions/unionflow/server/resource/AuditTrailOperationResource.java b/src/main/java/dev/lions/unionflow/server/resource/AuditTrailOperationResource.java index 81a9a5e..62484d5 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/AuditTrailOperationResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/AuditTrailOperationResource.java @@ -78,6 +78,30 @@ public class AuditTrailOperationResource { return queryService.operationsFinancieres(orgId, fromDt, toDt); } + /** + * Live Activity Feed (Sprint 15) — N opérations les plus récentes (transparency). + * + *

Scopes : + *

    + *
  • ALL — toutes orgs (restreint compliance/contrôleurs/super-admin)
  • + *
  • ORG — organisation indiquée (admin org / président / officers)
  • + *
  • SELF (défaut) — opérations de l'utilisateur indiqué (n'importe quel rôle peut voir les siennes)
  • + *
+ * + *

Limit clampé à [1, 500] côté repository (sécurité contre DoS). + */ + @GET + @Path("/recent") + @RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "ADMIN_ORGANISATION", + "PRESIDENT", "TRESORIER", "MEMBRE", "SUPER_ADMIN"}) + public List recent( + @QueryParam("scope") @jakarta.ws.rs.DefaultValue("SELF") String scope, + @QueryParam("orgId") UUID orgId, + @QueryParam("userId") UUID userId, + @QueryParam("limit") @jakarta.ws.rs.DefaultValue("50") int limit) { + return queryService.listerRecentes(scope, orgId, userId, limit); + } + private LocalDateTime parseDateTime(String input, LocalDateTime fallback) { if (input == null || input.isBlank()) return fallback; try { diff --git a/src/main/java/dev/lions/unionflow/server/service/audit/AuditTrailQueryService.java b/src/main/java/dev/lions/unionflow/server/service/audit/AuditTrailQueryService.java index 1e4d1f6..7c6b722 100644 --- a/src/main/java/dev/lions/unionflow/server/service/audit/AuditTrailQueryService.java +++ b/src/main/java/dev/lions/unionflow/server/service/audit/AuditTrailQueryService.java @@ -52,6 +52,32 @@ public class AuditTrailQueryService { .map(this::toResponse).toList(); } + /** + * Live Feed (Sprint 15) — N opérations les plus récentes selon le scope demandé. + * + * @param scope ALL (toutes orgs — restreint COMPLIANCE_OFFICER/CONTROLEUR_INTERNE/SUPER_ADMIN), + * ORG (organisation passée), SELF (utilisateur passé) + * @param organisationId requis pour scope=ORG + * @param userId requis pour scope=SELF + * @param limit clampé à [1, 500] + */ + public List listerRecentes( + String scope, UUID organisationId, UUID userId, int limit) { + String s = scope == null ? "ALL" : scope.toUpperCase(); + return switch (s) { + case "ORG" -> organisationId == null + ? java.util.List.of() + : repository.findRecentByOrganisation(organisationId, limit) + .stream().map(this::toResponse).toList(); + case "SELF" -> userId == null + ? java.util.List.of() + : repository.findRecentByUser(userId, limit) + .stream().map(this::toResponse).toList(); + default -> repository.findRecent(limit) + .stream().map(this::toResponse).toList(); + }; + } + // ──────────────────────────────────────────────────────────── // Mapping Entity ↔ DTO // ──────────────────────────────────────────────────────────── diff --git a/src/test/java/dev/lions/unionflow/server/service/audit/AuditTrailQueryServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/audit/AuditTrailQueryServiceTest.java index 1eec9e5..3e42fcf 100644 --- a/src/test/java/dev/lions/unionflow/server/service/audit/AuditTrailQueryServiceTest.java +++ b/src/test/java/dev/lions/unionflow/server/service/audit/AuditTrailQueryServiceTest.java @@ -97,4 +97,65 @@ class AuditTrailQueryServiceTest { assertThat(result).hasSize(1); assertThat(result.get(0).entityType()).isEqualTo("Membre"); } + + // ── Sprint 15 : Live Activity Feed (listerRecentes) ───────────────────────── + + @Test + @DisplayName("listerRecentes scope=ALL appelle findRecent") + void recentes_All() { + when(repository.findRecent(50)) + .thenReturn(List.of(op("Membre", "CREATE", true), op("Cotisation", "UPDATE", true))); + var result = service.listerRecentes("ALL", null, null, 50); + assertThat(result).hasSize(2); + } + + @Test + @DisplayName("listerRecentes scope=null → ALL par défaut") + void recentes_NullScopeDefaultsToAll() { + when(repository.findRecent(20)).thenReturn(List.of(op("X", "CREATE", true))); + var result = service.listerRecentes(null, null, null, 20); + assertThat(result).hasSize(1); + } + + @Test + @DisplayName("listerRecentes scope=ORG appelle findRecentByOrganisation") + void recentes_Org() { + UUID orgId = UUID.randomUUID(); + when(repository.findRecentByOrganisation(orgId, 30)) + .thenReturn(List.of(op("Cotisation", "VALIDATE", true))); + var result = service.listerRecentes("ORG", orgId, null, 30); + assertThat(result).hasSize(1); + } + + @Test + @DisplayName("listerRecentes scope=ORG sans orgId → liste vide") + void recentes_OrgWithoutId() { + var result = service.listerRecentes("ORG", null, null, 30); + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("listerRecentes scope=SELF appelle findRecentByUser") + void recentes_Self() { + UUID userId = UUID.randomUUID(); + when(repository.findRecentByUser(userId, 25)) + .thenReturn(List.of(op("Profil", "UPDATE", true))); + var result = service.listerRecentes("SELF", null, userId, 25); + assertThat(result).hasSize(1); + } + + @Test + @DisplayName("listerRecentes scope=SELF sans userId → liste vide") + void recentes_SelfWithoutUser() { + var result = service.listerRecentes("SELF", null, null, 25); + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("listerRecentes scope insensible à la casse") + void recentes_CaseInsensitive() { + when(repository.findRecent(10)).thenReturn(List.of(op("X", "CREATE", true))); + var result = service.listerRecentes("all", null, null, 10); + assertThat(result).hasSize(1); + } }