From fcaac36a147f976455f9c251c31d8b5e40ebadd8 Mon Sep 17 00:00:00 2001 From: dahoud <41957584+DahoudG@users.noreply.github.com> Date: Sat, 25 Apr 2026 16:10:41 +0000 Subject: [PATCH] feat(sprint-15 web 2026-04-25): Live Activity Feed page (transparency operations) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Page consommant /api/audit-trail/recent avec auto-refresh PrimeFaces toutes les 10s. Transparency opérationnelle UnionFlow — chaque utilisateur voit selon son scope. REST client AuditTrailRestClient - Nouvelle méthode recent(scope, orgId, userId, limit) Bean LiveFeedBean (@ViewScoped) - Polling pilote externe via p:poll interval=10 - 3 scopes : SELF (défaut, n'importe quel rôle), ORG (admin/officer), ALL (compliance/contrôleur) - Helpers : couleurAction (8 mappings), couleurSod (3 cas), tempsRelatif (s/m/h/j/futur) - Limit clamp [1, 500] - Compteur de refresh visible dans l'UI (debug) Page /pages/secure/conformite/live-feed.xhtml - Panel scope avec selectOneMenu + p:ajax change → rafraîchir - Conditional inputs : orgId si scope=ORG, userId si scope=SELF - p:poll interval=10 listener=rafraichir autoStart=true - DataTable opérations : index, "il y a Xs", acteur+rôle, action coloré, entité, description, SoD tag - Tag refresh counter (visible feedback) Centralisation - ViewPaths.CONFORMITE_LIVE_FEED + getter ViewPathsBean - menu.xhtml : entrée Live Feed sous sous-menu Conformité (icon pi-bolt) Tests (13/13 verts) - couleurAction × 4 (danger/success/info/autres) - couleurSod - tempsRelatif × 6 (null, secondes, minutes, heures, jours, futur) - setLimit clamp × 4 - defaults --- .../unionflow/client/constants/ViewPaths.java | 1 + .../client/service/AuditTrailRestClient.java | 9 ++ .../unionflow/client/view/LiveFeedBean.java | 104 ++++++++++++++ .../unionflow/client/view/ViewPathsBean.java | 1 + .../pages/secure/conformite/live-feed.xhtml | 117 ++++++++++++++++ .../templates/components/layout/menu.xhtml | 1 + .../client/view/LiveFeedBeanTest.java | 130 ++++++++++++++++++ 7 files changed, 363 insertions(+) create mode 100644 src/main/java/dev/lions/unionflow/client/view/LiveFeedBean.java create mode 100644 src/main/resources/META-INF/resources/pages/secure/conformite/live-feed.xhtml create mode 100644 src/test/java/dev/lions/unionflow/client/view/LiveFeedBeanTest.java diff --git a/src/main/java/dev/lions/unionflow/client/constants/ViewPaths.java b/src/main/java/dev/lions/unionflow/client/constants/ViewPaths.java index ebf272a..2b3a97a 100644 --- a/src/main/java/dev/lions/unionflow/client/constants/ViewPaths.java +++ b/src/main/java/dev/lions/unionflow/client/constants/ViewPaths.java @@ -115,6 +115,7 @@ public final class ViewPaths { public static final String CONFORMITE_RAPPORTS_TRIMESTRIELS = "/pages/secure/conformite/rapports-trimestriels"; public static final String CONFORMITE_BENEFICIAIRES_EFFECTIFS = "/pages/secure/conformite/beneficiaires-effectifs"; public static final String CONFORMITE_AUDIT_TRAIL = "/pages/secure/conformite/audit-trail"; + public static final String CONFORMITE_LIVE_FEED = "/pages/secure/conformite/live-feed"; // ─── Admin technique ───────────────────────────────────────────────────── public static final String ADMIN_PISPI_READINESS = "/pages/secure/admin/pispi-readiness"; diff --git a/src/main/java/dev/lions/unionflow/client/service/AuditTrailRestClient.java b/src/main/java/dev/lions/unionflow/client/service/AuditTrailRestClient.java index 10adaaa..bd1ed75 100644 --- a/src/main/java/dev/lions/unionflow/client/service/AuditTrailRestClient.java +++ b/src/main/java/dev/lions/unionflow/client/service/AuditTrailRestClient.java @@ -50,4 +50,13 @@ public interface AuditTrailRestClient { @PathParam("orgId") UUID orgId, @QueryParam("from") String from, @QueryParam("to") String to); + + /** Live Activity Feed (Sprint 15) — N opérations les plus récentes selon scope. */ + @GET + @Path("/recent") + List recent( + @QueryParam("scope") String scope, + @QueryParam("orgId") UUID orgId, + @QueryParam("userId") UUID userId, + @QueryParam("limit") int limit); } diff --git a/src/main/java/dev/lions/unionflow/client/view/LiveFeedBean.java b/src/main/java/dev/lions/unionflow/client/view/LiveFeedBean.java new file mode 100644 index 0000000..18179eb --- /dev/null +++ b/src/main/java/dev/lions/unionflow/client/view/LiveFeedBean.java @@ -0,0 +1,104 @@ +package dev.lions.unionflow.client.view; + +import dev.lions.unionflow.client.service.AuditTrailRestClient; +import dev.lions.unionflow.server.api.dto.audit.response.AuditTrailOperationResponse; +import jakarta.faces.application.FacesMessage; +import jakarta.faces.context.FacesContext; +import jakarta.faces.view.ViewScoped; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import java.io.Serializable; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.jboss.logging.Logger; + +/** + * Bean Live Activity Feed (Sprint 15 — transparency opérationnelle). + * + *

Polling 10s sur {@code /api/audit-trail/recent} avec choix du scope : + *

+ * + *

L'auto-refresh est piloté côté UI via {@code <p:poll>} qui appelle {@link #rafraichir()}. + */ +@Named +@ViewScoped +public class LiveFeedBean implements Serializable { + + private static final long serialVersionUID = 1L; + private static final Logger LOG = Logger.getLogger(LiveFeedBean.class); + + @Inject @RestClient AuditTrailRestClient client; + + /** SELF (défaut, n'importe quel rôle), ORG (admin/officer), ALL (compliance/contrôleur). */ + private String scope = "SELF"; + private UUID orgId; + private UUID userId; + private int limit = 50; + + private List operations = Collections.emptyList(); + private long compteur; + private String erreur; + + public void rafraichir() { + erreur = null; + try { + operations = client.recent(scope, orgId, userId, limit); + compteur++; + LOG.debugf("LiveFeed (%s) refresh #%d : %d ops", scope, compteur, + operations == null ? 0 : operations.size()); + } catch (Exception e) { + LOG.warnf("LiveFeed refresh échoué : %s", e.getMessage()); + erreur = "Échec rafraîchissement : " + e.getMessage(); + FacesContext.getCurrentInstance().addMessage(null, + new FacesMessage(FacesMessage.SEVERITY_WARN, "Erreur LiveFeed", erreur)); + operations = Collections.emptyList(); + } + } + + public String getCouleurAction(String actionType) { + if (actionType == null) return "secondary"; + return switch (actionType) { + case "DELETE", "PAYMENT_FAILED" -> "danger"; + case "VALIDATE", "PAYMENT_CONFIRMED", "AID_REQUEST_APPROVED" -> "success"; + case "UPDATE", "PAYMENT_INITIATED", "BUDGET_APPROVED" -> "info"; + case "CREATE" -> "primary"; + case "EXPORT" -> "warning"; + default -> "secondary"; + }; + } + + public String getCouleurSod(Boolean sodCheckPassed) { + if (sodCheckPassed == null) return "secondary"; + return sodCheckPassed ? "success" : "danger"; + } + + /** Pour l'affichage relatif "il y a Xs". */ + public String tempsRelatif(java.time.LocalDateTime dt) { + if (dt == null) return "—"; + long secondes = java.time.Duration.between(dt, java.time.LocalDateTime.now()).getSeconds(); + if (secondes < 0) return "à l'instant"; + if (secondes < 60) return "il y a " + secondes + "s"; + if (secondes < 3600) return "il y a " + (secondes / 60) + "m"; + if (secondes < 86400) return "il y a " + (secondes / 3600) + "h"; + return "il y a " + (secondes / 86400) + "j"; + } + + // Getters / setters + public String getScope() { return scope; } + public void setScope(String scope) { this.scope = scope; } + public UUID getOrgId() { return orgId; } + public void setOrgId(UUID orgId) { this.orgId = orgId; } + public UUID getUserId() { return userId; } + public void setUserId(UUID userId) { this.userId = userId; } + public int getLimit() { return limit; } + public void setLimit(int limit) { this.limit = Math.max(1, Math.min(limit, 500)); } + public List getOperations() { return operations; } + public long getCompteur() { return compteur; } + public String getErreur() { return erreur; } +} diff --git a/src/main/java/dev/lions/unionflow/client/view/ViewPathsBean.java b/src/main/java/dev/lions/unionflow/client/view/ViewPathsBean.java index 5b49a3f..4ecb525 100644 --- a/src/main/java/dev/lions/unionflow/client/view/ViewPathsBean.java +++ b/src/main/java/dev/lions/unionflow/client/view/ViewPathsBean.java @@ -110,6 +110,7 @@ public class ViewPathsBean implements Serializable { public String getConformiteRapportsTrimestriels() { return ViewPaths.CONFORMITE_RAPPORTS_TRIMESTRIELS; } public String getConformiteBeneficiairesEffectifs() { return ViewPaths.CONFORMITE_BENEFICIAIRES_EFFECTIFS; } public String getConformiteAuditTrail() { return ViewPaths.CONFORMITE_AUDIT_TRAIL; } + public String getConformiteLiveFeed() { return ViewPaths.CONFORMITE_LIVE_FEED; } // ─── Admin technique ───────────────────────────────────────────────────── public String getAdminPispiReadiness() { return ViewPaths.ADMIN_PISPI_READINESS; } diff --git a/src/main/resources/META-INF/resources/pages/secure/conformite/live-feed.xhtml b/src/main/resources/META-INF/resources/pages/secure/conformite/live-feed.xhtml new file mode 100644 index 0000000..d17c0c1 --- /dev/null +++ b/src/main/resources/META-INF/resources/pages/secure/conformite/live-feed.xhtml @@ -0,0 +1,117 @@ + + + + UnionFlow - Live Activity Feed + + +

+
+
+
+
+

+ + Live Activity Feed +

+

+ Transparency opérationnelle — les opérations sensibles défilent en temps réel. + Refresh auto toutes les 10 secondes. +

+
+ +
+ + + + + +
+
+ + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + + + + + + + + + +
+ #{o.operationAt} +
+ + +
+ + #{o.roleActif} + +
+ + + + + + + + + + + + + +
+
+
+
+
+ + diff --git a/src/main/resources/META-INF/resources/templates/components/layout/menu.xhtml b/src/main/resources/META-INF/resources/templates/components/layout/menu.xhtml index 8776c83..4e6187b 100644 --- a/src/main/resources/META-INF/resources/templates/components/layout/menu.xhtml +++ b/src/main/resources/META-INF/resources/templates/components/layout/menu.xhtml @@ -115,6 +115,7 @@ + diff --git a/src/test/java/dev/lions/unionflow/client/view/LiveFeedBeanTest.java b/src/test/java/dev/lions/unionflow/client/view/LiveFeedBeanTest.java new file mode 100644 index 0000000..44096da --- /dev/null +++ b/src/test/java/dev/lions/unionflow/client/view/LiveFeedBeanTest.java @@ -0,0 +1,130 @@ +package dev.lions.unionflow.client.view; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class LiveFeedBeanTest { + + private LiveFeedBean bean; + + @BeforeEach + void setUp() { + bean = new LiveFeedBean(); + } + + // ── couleurAction (mêmes règles que AuditTrailViewerBean) ──────────── + + @Test + @DisplayName("couleurAction — DELETE/PAYMENT_FAILED → danger") + void couleurDanger() { + assertEquals("danger", bean.getCouleurAction("DELETE")); + assertEquals("danger", bean.getCouleurAction("PAYMENT_FAILED")); + } + + @Test + @DisplayName("couleurAction — VALIDATE/PAYMENT_CONFIRMED/AID_REQUEST_APPROVED → success") + void couleurSuccess() { + assertEquals("success", bean.getCouleurAction("VALIDATE")); + assertEquals("success", bean.getCouleurAction("PAYMENT_CONFIRMED")); + assertEquals("success", bean.getCouleurAction("AID_REQUEST_APPROVED")); + } + + @Test + @DisplayName("couleurAction — UPDATE/PAYMENT_INITIATED/BUDGET_APPROVED → info") + void couleurInfo() { + assertEquals("info", bean.getCouleurAction("UPDATE")); + assertEquals("info", bean.getCouleurAction("PAYMENT_INITIATED")); + assertEquals("info", bean.getCouleurAction("BUDGET_APPROVED")); + } + + @Test + @DisplayName("couleurAction — CREATE → primary, EXPORT → warning, autres → secondary") + void couleurAutres() { + assertEquals("primary", bean.getCouleurAction("CREATE")); + assertEquals("warning", bean.getCouleurAction("EXPORT")); + assertEquals("secondary", bean.getCouleurAction(null)); + assertEquals("secondary", bean.getCouleurAction("INCONNU")); + } + + @Test + @DisplayName("couleurSod — true→success, false→danger, null→secondary") + void couleurSod() { + assertEquals("success", bean.getCouleurSod(true)); + assertEquals("danger", bean.getCouleurSod(false)); + assertEquals("secondary", bean.getCouleurSod(null)); + } + + // ── tempsRelatif ───────────────────────────────────────────────────── + + @Test + @DisplayName("tempsRelatif — null → '—'") + void tempsRelatifNull() { + assertEquals("—", bean.tempsRelatif(null)); + } + + @Test + @DisplayName("tempsRelatif — 30s ago → 'il y a 30s'") + void tempsRelatifSecondes() { + LocalDateTime past = LocalDateTime.now().minusSeconds(30); + assertTrue(bean.tempsRelatif(past).startsWith("il y a ")); + assertTrue(bean.tempsRelatif(past).endsWith("s")); + } + + @Test + @DisplayName("tempsRelatif — 5min ago → 'il y a 5m'") + void tempsRelatifMinutes() { + LocalDateTime past = LocalDateTime.now().minusMinutes(5); + assertEquals("il y a 5m", bean.tempsRelatif(past)); + } + + @Test + @DisplayName("tempsRelatif — 2h ago → 'il y a 2h'") + void tempsRelatifHeures() { + LocalDateTime past = LocalDateTime.now().minusHours(2); + assertEquals("il y a 2h", bean.tempsRelatif(past)); + } + + @Test + @DisplayName("tempsRelatif — 3 jours ago → 'il y a 3j'") + void tempsRelatifJours() { + LocalDateTime past = LocalDateTime.now().minusDays(3); + assertEquals("il y a 3j", bean.tempsRelatif(past)); + } + + @Test + @DisplayName("tempsRelatif — futur → 'à l'instant'") + void tempsRelatifFutur() { + LocalDateTime futur = LocalDateTime.now().plusMinutes(1); + assertEquals("à l'instant", bean.tempsRelatif(futur)); + } + + // ── Limit clamping ─────────────────────────────────────────────────── + + @Test + @DisplayName("setLimit clamp [1, 500]") + void setLimitClamp() { + bean.setLimit(0); + assertEquals(1, bean.getLimit()); + bean.setLimit(1000); + assertEquals(500, bean.getLimit()); + bean.setLimit(50); + assertEquals(50, bean.getLimit()); + bean.setLimit(-100); + assertEquals(1, bean.getLimit()); + } + + // ── Defaults ───────────────────────────────────────────────────────── + + @Test + @DisplayName("Defaults — scope=SELF, limit=50, compteur=0") + void defaults() { + assertEquals("SELF", bean.getScope()); + assertEquals(50, bean.getLimit()); + assertEquals(0, bean.getCompteur()); + } +}