Déclarer et appliquer les durées de conservation

La table RetentionPolicy existait et cinq durées y étaient semées depuis la
matrice ; rien ne les appliquait. Une durée déclarée que personne n'exécute est
une conformité de papier.

Aucune durée par défaut n'est appliquée, et c'est le point central : la matrice
interdit explicitement d'aligner tout sur cinq ans. Un objet sans politique
déclarée se conserve, et l'écran le signale plutôt que de le taire. Symétrie
inverse, tout aussi importante : effacer faute de règle serait aussi fautif que
garder indéfiniment.

La justification est obligatoire au niveau du serveur. Une durée sans motif est
une durée qu'on ne saura pas défendre le jour d'un contrôle.

Les politiques sont effectif-datées comme le reste de l'application : une pièce
déposée en mars relève de la règle en vigueur en mars. Sans cela, un
durcissement rétroactif purgerait ce que la règle du moment autorisait à garder.
La résolution va du précis au général — Document:SICK_NOTE avant Document — car
un arrêt de travail et un contrat n'ont aucune raison de se conserver aussi
longtemps.

Trois refus distincts plutôt qu'un seul : absence de politique, conservation
suspendue à titre probatoire, échéance non atteinte. Les confondre sous « rien
à purger » empêcherait de vérifier que la conservation est réellement tenue. Un
quatrième existe : employee_departure est déclaré mais non calculable, PlanFlow
ne modélisant pas de date de départ — purger sur une date inventée serait pire
que ne pas purger, et l'écran l'affiche comme tel.

La purge s'exécute en ligne de commande pour une tâche planifiée, la matrice
demandant des purges automatiques ; un bouton qu'il faut penser à presser n'en
est pas une. Elle passe par le client scopé et la RLS, compte par compte. Les
tables append-only en sont exclues par construction : le journal d'audit doit
survivre aux données qu'il décrit, sans quoi on ne pourrait plus démontrer que
la purge a eu lieu.

L'échéance affichée est dérivée de la politique, jamais stockée — même
discipline que la péremption d'un export.

Deux pièges rencontrés : un objet de composants exporté depuis un module client
ne survit pas au passage par un composant serveur, React n'en recevant qu'un
undefined ; et le minLength du navigateur masquait le contrôle serveur de la
justification, que des espaces suffisent à contourner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
Claude committed 2026-08-09 07:43:30 +00:00
1 parent 4a58d01575
commit 6f3b630ced
13 files changed
+1371 -6

No files matched your search

+187
View File
@@ -0,0 +1,187 @@
'use client';
import { useActionState } from 'react';
import { Button } from '@/components/ui/Button';
import {
START_POINT_LABELS,
START_POINTS,
} from '@/domain/retention/policy';
import {
runPurgeAction,
saveRetentionPolicyAction,
toggleLegalHoldAction,
type RetentionState,
} from '@/server/retention/actions';
const empty: RetentionState = {};
export function RetentionPolicyForm() {
const [state, action, pending] = useActionState(
saveRetentionPolicyAction,
empty,
);
return (
<form action={action} className="grid gap-3 sm:grid-cols-2">
<Field
label="Objet"
name="objectType"
placeholder="Document:SICK_NOTE"
required
hint="Un objet précis l’emporte sur le général : « Document:SICK_NOTE » avant « Document »."
/>
<Field
label="Durée en mois"
name="durationMonths"
type="number"
min={1}
required
hint="De date à date. 60 pour cinq ans."
/>
<label className="flex flex-col gap-1">
<span className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
Point de départ
</span>
<select
name="startPoint"
defaultValue="creation"
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
>
{START_POINTS.map((point) => (
<option key={point} value={point}>
{START_POINT_LABELS[point]}
</option>
))}
</select>
</label>
<Field
label="En vigueur à partir du"
name="effectiveFrom"
type="date"
required
hint="Effectif-daté : une pièce relève de la politique en vigueur au jour de son dépôt."
/>
<label className="flex flex-col gap-1 sm:col-span-2">
<span className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
Justification
</span>
<textarea
name="justification"
rows={2}
required
minLength={10}
className="rounded-2 border border-line-2 bg-surface px-2 py-1 text-sm text-ink-1"
/>
<span className="text-micro text-ink-3">
Obligatoire. Une durée sans motif est une durée qu’on ne saura pas
défendre le jour d’un contrôle.
</span>
</label>
<div className="flex items-center gap-3 sm:col-span-2">
<Button type="submit" variant="primary" disabled={pending}>
Enregistrer
</Button>
<Messages state={state} />
</div>
</form>
);
}
export function LegalHoldForm({
policyId,
held,
}: {
policyId: string;
held: boolean;
}) {
const [state, action, pending] = useActionState(toggleLegalHoldAction, empty);
return (
<form action={action} className="flex items-center gap-2">
<input type="hidden" name="policyId" value={policyId} />
<Button type="submit" disabled={pending}>
{held ? 'Lever la suspension' : 'Suspendre'}
</Button>
{held ? (
<span className="text-micro text-info-soft-ink">Suspendue</span>
) : null}
{state.error ? (
<span role="alert" className="text-micro text-danger">
{state.error}
</span>
) : null}
</form>
);
}
export function PurgeForm({ due }: { due: number }) {
const [state, action, pending] = useActionState(runPurgeAction, empty);
return (
<form action={action} className="flex flex-wrap items-center gap-3 border-t border-line-1 pt-3">
<Button type="submit" variant="primary" disabled={pending || due === 0}>
Purger les pièces échues
</Button>
<span className="text-micro text-ink-3">
{/* Le bouton est un dépannage : la matrice demande des purges
automatiques, et `pnpm retention:purge` sert à les planifier. */}
Aussi disponible en ligne de commande, pour une exécution périodique.
</span>
<Messages state={state} />
</form>
);
}
function Messages({ state }: { state: RetentionState }) {
return (
<>
{state.error ? (
<span role="alert" className="text-xs text-danger">
{state.error}
</span>
) : null}
{state.message ? (
<span className="text-xs text-ok-soft-ink">{state.message}</span>
) : null}
</>
);
}
function Field({
label,
name,
hint,
...rest
}: {
label: string;
name: string;
hint?: string;
} & React.InputHTMLAttributes<HTMLInputElement>) {
const id = `retention-${name}`;
return (
<div className="flex flex-col gap-1">
<label
htmlFor={id}
className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase"
>
{label}
</label>
<input
id={id}
name={name}
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
{...rest}
/>
{hint ? <p className="text-micro text-ink-3">{hint}</p> : null}
</div>
);
}
// Exports nommés et non un objet de composants : une référence client passée
// depuis un composant serveur ne survit pas à l'indirection d'un namespace —
// React ne reçoit alors qu'un `undefined` et rend une page d'erreur.
+5
View File
@@ -85,6 +85,11 @@ export const NAVIGATION: NavSection[] = [
{ id: 'registre', label: 'Registre de paramétrage', href: '/reglages/registre' },
{ id: 'email', label: 'Envoi de courrier', href: '/reglages/email' },
{ id: 'securite', label: 'Sécurité', href: '/reglages/securite' },
{
id: 'conservation',
label: 'Durées de conservation',
href: '/reglages/conservation',
},
{ id: 'convention', label: 'Convention collective' },
{ id: 'postes', label: 'Postes et étiquettes' },
{ id: 'roles', label: 'Rôles et permissions' },