From 1519b6bf9d2efd7440447964107d87d66d5ef979 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 11 Aug 2026 12:32:03 +0200 Subject: [PATCH] =?UTF-8?q?Ouvrir=20le=20journal=20d'activit=C3=A9=20?= =?UTF-8?q?=C3=A0=20la=20lecture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La piste d'audit était écrite à chaque mutation depuis WP-01, et lisible nulle part. Une piste que personne ne peut consulter ne prouve rien le jour où on le lui demande. L'écran ne porte aucune action : le journal est append-only, imposé par un trigger, et cette page ne fait que le lire. La capacité `audit.view` est distincte de l'administration — on confie la lecture de la piste à un contrôleur sans lui donner le droit de modifier ce qu'elle enregistre. Seuls les noms des champs modifiés s'affichent, pas leurs valeurs. Le journal enregistre bien l'avant et l'après en clair, c'est ce qui le rend opposable ; mais les étaler dans une liste exposerait un salaire ou un motif médical à quiconque ouvre l'écran. La liste dit ce qui a changé, la fiche dit quoi, sous ses propres autorisations. Les listes de filtres se déduisent du journal lui-même plutôt que d'une énumération tenue à la main : une action ajoutée par un lot futur y apparaît seule, et une énumération figée finirait par masquer ce qu'elle omet. Co-Authored-By: Claude Opus 5 --- src/app/(app)/rapports/activite/page.tsx | 197 +++++++++++++++++++++++ src/components/shell/navigation.ts | 6 +- src/server/reports/activity.ts | 147 +++++++++++++++++ 3 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 src/app/(app)/rapports/activite/page.tsx create mode 100644 src/server/reports/activity.ts diff --git a/src/app/(app)/rapports/activite/page.tsx b/src/app/(app)/rapports/activite/page.tsx new file mode 100644 index 0000000..3bd3575 --- /dev/null +++ b/src/app/(app)/rapports/activite/page.tsx @@ -0,0 +1,197 @@ +import Link from 'next/link'; + +import { PageBody, PageHeader } from '@/components/shell/PageHeader'; +import { Badge } from '@/components/ui/Badge'; +import { Card, CardHeader, EmptyState } from '@/components/ui/Card'; +import { getActivity } from '@/server/reports/activity'; + +export const metadata = { title: 'Journal d’activité · PlanFlow' }; +export const dynamic = 'force-dynamic'; + +interface PageProps { + searchParams: Promise<{ + domaine?: string; + objet?: string; + du?: string; + au?: string; + }>; +} + +const stamp = new Intl.DateTimeFormat('fr-FR', { + dateStyle: 'short', + timeStyle: 'short', +}); + +export default async function ActivitePage({ searchParams }: PageProps) { + const params = await searchParams; + // `exactOptionalPropertyTypes` : un filtre absent ne se transmet pas comme + // « undefined », il ne se transmet pas du tout. + const page = await getActivity({ + ...(params.domaine ? { domain: params.domaine } : {}), + ...(params.objet ? { entityType: params.objet } : {}), + ...(params.du ? { from: params.du } : {}), + ...(params.au ? { to: params.au } : {}), + }); + + const withParam = (key: string, value: string | undefined) => { + const next = new URLSearchParams(); + for (const [name, current] of Object.entries(params)) { + if (current) next.set(name, current); + } + if (value) next.set(key, value); + else next.delete(key); + const qs = next.toString(); + return qs ? `/rapports/activite?${qs}` : '/rapports/activite'; + }; + + const chip = (href: string, label: string, active: boolean) => ( + + {label} + + ); + + return ( + + 1 ? 's' : ''} sur la période`} + /> + + + 200 dernières affichées + ) : null + } + /> + +
+
+ {chip(withParam('domaine', undefined), 'Tous les domaines', !params.domaine)} + {page.domains.map((domain) => + chip( + withParam('domaine', domain), + domain, + params.domaine === domain, + ), + )} +
+ +
+ {chip(withParam('objet', undefined), 'Tous les objets', !params.objet)} + {page.entityTypes.map((type) => + chip(withParam('objet', type), type, params.objet === type), + )} +
+ +
+ {params.domaine ? ( + + ) : null} + {params.objet ? ( + + ) : null} + + + +
+
+
+ + + + + {page.entries.length === 0 ? ( + + ) : ( +
+ + + + {['Horodatage', 'Auteur', 'Action', 'Objet', 'Champs', 'Justification'].map( + (heading) => ( + + ), + )} + + + + {page.entries.map((entry) => ( + + + + + + + + + ))} + +
+ {heading} +
+ {stamp.format(entry.occurredAt)} + {entry.actorName}{entry.action} + {entry.entityType} + + {entry.entityId} + + + {entry.changedFields.length === 0 + ? '—' + : entry.changedFields.join(', ')} + + {entry.reason ?? '—'} +
+
+ )} +
+ +

+ Le journal enregistre l’avant et l’après de chaque mutation — c’est ce + qui le rend opposable. Cet écran n’affiche que le nom des champs + modifiés : la valeur se lit sur la fiche concernée, sous ses propres + autorisations. Un salaire ou un motif médical n’a pas à être visible de + quiconque ouvre la piste d’audit. +

+
+ ); +} diff --git a/src/components/shell/navigation.ts b/src/components/shell/navigation.ts index d81b970..0fadb59 100644 --- a/src/components/shell/navigation.ts +++ b/src/components/shell/navigation.ts @@ -91,7 +91,11 @@ export const NAVIGATION: NavSection[] = [ { id: 'paie', label: 'Préparation de paie', href: '/paie' }, { id: 'periodes', label: 'Périodes de paie', href: '/paie/periodes' }, { id: 'silae', label: 'Codes Silae', href: '/paie/silae' }, - { id: 'activite', label: "Journal d'activité" }, + { + id: 'activite', + label: "Journal d'activité", + href: '/rapports/activite', + }, ], }, { diff --git a/src/server/reports/activity.ts b/src/server/reports/activity.ts new file mode 100644 index 0000000..05fc811 --- /dev/null +++ b/src/server/reports/activity.ts @@ -0,0 +1,147 @@ +import { query } from '@/server/context'; + +/** + * Journal d'activité — PLAN.md §9, `/rapports/activite`. + * + * Le journal d'audit est append-only, imposé par un trigger. Cet écran ne fait + * que le lire : aucune action ne s'y trouve, et c'est le point. Une piste que + * l'on peut trier, filtrer et exporter, mais jamais corriger, est la seule qui + * prouve quelque chose. + * + * L'accès passe par `audit.view`, capacité distincte de l'administration : on + * confie la lecture de la piste à un contrôleur sans lui donner le droit de + * modifier ce qu'elle enregistre. + */ + +export interface ActivityEntry { + id: string; + occurredAt: Date; + action: string; + entityType: string; + entityId: string; + actorName: string; + reason: string | null; + ip: string | null; + /** Champs dont la valeur a changé, sans leur contenu. */ + changedFields: string[]; +} + +export interface ActivityFilters { + /** Préfixe d'action, par exemple `planning` ou `payroll.period`. */ + domain?: string; + entityType?: string; + from?: string; + to?: string; +} + +export interface ActivityPage { + entries: ActivityEntry[]; + /** Domaines présents dans le journal, pour le filtre. */ + domains: string[]; + entityTypes: string[]; + total: number; + truncated: boolean; +} + +const PAGE_SIZE = 200; + +/** + * Noms des champs modifiés, sans leur valeur. + * + * La piste enregistre l'avant et l'après en clair — c'est ce qui la rend + * opposable. Les afficher dans une liste, en revanche, exposerait un salaire ou + * une donnée de santé à quiconque ouvre l'écran. Le journal dit **ce qui** a + * changé ; le détail se lit sur la fiche, sous ses propres autorisations. + */ +function changedFields(before: unknown, after: unknown): string[] { + const keys = new Set(); + for (const value of [before, after]) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + for (const key of Object.keys(value)) keys.add(key); + } + } + return [...keys].sort(); +} + +export async function getActivity( + filters: ActivityFilters = {}, +): Promise { + return query('audit.view', async (db) => { + const where = { + ...(filters.domain ? { action: { startsWith: filters.domain } } : {}), + ...(filters.entityType ? { entityType: filters.entityType } : {}), + ...(filters.from || filters.to + ? { + occurredAt: { + ...(filters.from + ? { gte: new Date(`${filters.from}T00:00:00Z`) } + : {}), + // Borne haute inclusive : filtrer « jusqu'au 31 » doit contenir + // le 31, pas s'arrêter à son premier instant. + ...(filters.to + ? { lte: new Date(`${filters.to}T23:59:59.999Z`) } + : {}), + }, + } + : {}), + }; + + const [rows, total] = await Promise.all([ + db.auditLog.findMany({ + where, + orderBy: { occurredAt: 'desc' }, + take: PAGE_SIZE, + include: { + actor: { + include: { + profile: { select: { firstName: true, lastName: true } }, + }, + }, + }, + }), + db.auditLog.count({ where }), + ]); + + // Les listes de filtres viennent du journal lui-même : une action ajoutée + // par un lot futur y apparaît sans qu'on ait à tenir une énumération à + // jour, et une énumération figée finirait par masquer ce qu'elle omet. + const [actions, types] = await Promise.all([ + db.auditLog.findMany({ + distinct: ['action'], + select: { action: true }, + orderBy: { action: 'asc' }, + take: 500, + }), + db.auditLog.findMany({ + distinct: ['entityType'], + select: { entityType: true }, + orderBy: { entityType: 'asc' }, + take: 200, + }), + ]); + + return { + entries: rows.map((row) => ({ + id: row.id, + occurredAt: row.occurredAt, + action: row.action, + entityType: row.entityType, + entityId: row.entityId, + actorName: row.actor + ? `${row.actor.profile?.firstName ?? ''} ${row.actor.profile?.lastName ?? row.actor.employeeNumber}`.trim() + : 'Système', + reason: row.reason, + ip: row.ip, + changedFields: changedFields(row.before, row.after), + })), + domains: [ + ...new Set( + actions.map((row) => row.action.split('.')[0] ?? row.action), + ), + ].sort(), + entityTypes: types.map((row) => row.entityType), + total, + truncated: total > rows.length, + }; + }); +}