feat(sprint-15 backend 2026-04-25): Live Activity Feed — endpoint /api/audit-trail/recent + tests
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m50s

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
This commit is contained in:
dahoud
2026-04-25 16:08:33 +00:00
parent 7c3352ed48
commit e4d7c8e4b7
4 changed files with 128 additions and 0 deletions

View File

@@ -47,4 +47,21 @@ public class AuditTrailOperationRepository
+ "ORDER BY operationAt DESC", + "ORDER BY operationAt DESC",
organisationId, from, to); organisationId, from, to);
} }
/** N opérations les plus récentes — toutes organisations confondues (Live Feed). */
public List<AuditTrailOperation> 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<AuditTrailOperation> 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<AuditTrailOperation> findRecentByUser(UUID userId, int limit) {
return find("userId = ?1 ORDER BY operationAt DESC", userId)
.page(0, Math.max(1, Math.min(limit, 500))).list();
}
} }

View File

@@ -78,6 +78,30 @@ public class AuditTrailOperationResource {
return queryService.operationsFinancieres(orgId, fromDt, toDt); return queryService.operationsFinancieres(orgId, fromDt, toDt);
} }
/**
* Live Activity Feed (Sprint 15) — N opérations les plus récentes (transparency).
*
* <p>Scopes :
* <ul>
* <li>ALL — toutes orgs (restreint compliance/contrôleurs/super-admin)</li>
* <li>ORG — organisation indiquée (admin org / président / officers)</li>
* <li>SELF (défaut) — opérations de l'utilisateur indiqué (n'importe quel rôle peut voir les siennes)</li>
* </ul>
*
* <p>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<AuditTrailOperationResponse> 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) { private LocalDateTime parseDateTime(String input, LocalDateTime fallback) {
if (input == null || input.isBlank()) return fallback; if (input == null || input.isBlank()) return fallback;
try { try {

View File

@@ -52,6 +52,32 @@ public class AuditTrailQueryService {
.map(this::toResponse).toList(); .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<AuditTrailOperationResponse> 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 // Mapping Entity ↔ DTO
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────

View File

@@ -97,4 +97,65 @@ class AuditTrailQueryServiceTest {
assertThat(result).hasSize(1); assertThat(result).hasSize(1);
assertThat(result.get(0).entityType()).isEqualTo("Membre"); 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);
}
} }