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
+1368 -3

No files matched your search

+13
View File
@@ -90,6 +90,19 @@ pnpm mfa:reset adresse@example.fr
Le retrait révoque les sessions ouvertes et s'inscrit au journal d'audit. Il n'est délibérément pas exposé dans l'application : l'exécuter demande déjà un accès au serveur, c'est-à-dire davantage que ce que le second facteur protège.
### Durées de conservation
Aucune durée n'est appliquée par défaut : la matrice interdit d'aligner tout sur cinq ans, et un objet sans politique déclarée se conserve. Les durées se déclarent dans Réglages → Durées de conservation, chacune avec sa justification — elle devra être défendue lors d'un contrôle.
La purge doit tourner périodiquement, par exemple en `cron` :
```bash
pnpm retention:purge --dry # inventaire, n'efface rien
pnpm retention:purge # efface les pièces échues, compte par compte
```
Une conservation à titre probatoire (*legal hold*) suspend la purge des objets qu'elle vise, quelle que soit leur échéance. Les journaux d'audit y échappent par construction : ils doivent survivre aux données qu'ils décrivent, sans quoi il deviendrait impossible de démontrer que la purge a eu lieu.
## Vérifier
```bash
+1
View File
@@ -22,6 +22,7 @@
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"mfa:reset": "tsx scripts/mfa-reset.ts",
"retention:purge": "tsx scripts/retention-purge.ts",
"verify": "pnpm typecheck && pnpm lint && pnpm test"
},
"prisma": {
+58
View File
@@ -0,0 +1,58 @@
/**
* Purge périodique — PLAN.md §12.5, matrice n° 21.
*
* La matrice demande des purges **automatiques**. Un bouton qu'il faut penser à
* presser n'en est pas une : ce script est fait pour une tâche planifiée.
*
* pnpm retention:purge # tous les comptes
* pnpm retention:purge --dry # inventaire seulement
*
* Il passe par le client scopé et la RLS, compte par compte : une purge qui
* contournerait l'isolation serait le pire endroit où la perdre.
*/
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client';
import { inspectRetention, runRetentionPurge } from '../src/server/retention/purge';
import { withTenant } from '../src/server/tenant';
const dryRun = process.argv.includes('--dry');
async function main(): Promise<void> {
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
const accounts = await prisma.account.findMany({
select: { id: true, name: true },
});
for (const account of accounts) {
await withTenant(account.id, async (db) => {
if (dryRun) {
const candidates = await inspectRetention(db);
const due = candidates.filter((entry) => entry.verdict === 'DUE');
console.log(
`${account.name} — ${due.length} pièce(s) échue(s) sur ${candidates.length}`,
);
for (const entry of due) {
console.log(` ${entry.name} (${entry.category}) — ${entry.dueVia}`);
}
return;
}
// L'acteur est la purge elle-même : aucun humain ne décide pièce par
// pièce, et attribuer l'effacement à la dernière personne connectée
// fausserait le journal.
const report = await runRetentionPurge(db, 'retention');
console.log(`${account.name} — ${report.purged} pièce(s) effacée(s)`);
});
}
await prisma.$disconnect();
}
main().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,170 @@
import {
LegalHoldForm,
PurgeForm,
RetentionPolicyForm,
} from '@/components/settings/RetentionForms';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Card, CardHeader } from '@/components/ui/Card';
import {
isComputable,
START_POINT_LABELS,
VERDICT_LABELS,
type StartPoint,
} from '@/domain/retention/policy';
import { query } from '@/server/context';
import { inspectRetention } from '@/server/retention/purge';
export const metadata = { title: 'Conservation · PlanFlow' };
export const dynamic = 'force-dynamic';
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' });
export default async function RetentionPage() {
const { policies, candidates } = await query(
'settings.access',
async (db) => ({
policies: await db.retentionPolicy.findMany({
orderBy: [{ objectType: 'asc' }, { effectiveFrom: 'desc' }],
}),
candidates: await inspectRetention(db),
}),
);
const due = candidates.filter((entry) => entry.verdict === 'DUE');
const unpolicied = candidates.filter((entry) => entry.verdict === 'NO_POLICY');
return (
<PageBody>
<PageHeader
title="Durées de conservation"
subtitle="Chaque durée porte sa justification : elle devra être défendue lors d’un contrôle."
actions={
due.length > 0 ? (
<Badge tone="warn">{due.length} pièce(s) échue(s)</Badge>
) : (
<Badge tone="ok">Rien d’échu</Badge>
)
}
/>
<section className="rounded-3 border border-line-1 bg-surface-2 p-4 text-sm text-ink-2">
<p>
Les minima légaux ne sont ni universels ni une autorisation de tout
garder. PlanFlow n’applique <strong>aucune durée par défaut</strong> :
un objet sans politique déclarée se conserve, et cet écran le signale.
</p>
<p className="mt-2">
Les journaux d’audit échappent à la purge : ils doivent survivre aux
données qu’ils décrivent, sans quoi il deviendrait impossible de
démontrer que la purge a bien eu lieu.
</p>
</section>
<Card>
<CardHeader
title="Politiques déclarées"
badge={<Badge tone="neutral">{policies.length}</Badge>}
/>
<div className="overflow-x-auto">
<table className="w-full min-w-[880px] border-collapse text-sm">
<thead>
<tr className="border-b border-line-2 bg-surface-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
<th className="px-4 py-2.5">Objet</th>
<th className="px-4 py-2.5">Durée</th>
<th className="px-4 py-2.5">Point de départ</th>
<th className="px-4 py-2.5">Justification</th>
<th className="px-4 py-2.5">En vigueur le</th>
<th className="px-4 py-2.5">Conservation probatoire</th>
</tr>
</thead>
<tbody>
{policies.map((policy) => (
<tr
key={policy.id}
className="border-b border-line-1 last:border-b-0"
>
<td className="px-4 py-2.5 font-medium">{policy.objectType}</td>
<td className="tnum px-4 py-2.5">
{policy.durationMonths} mois
</td>
<td className="px-4 py-2.5 text-ink-2">
{START_POINT_LABELS[policy.startPoint as StartPoint] ??
policy.startPoint}
{!isComputable(policy.startPoint) ? (
// Le dire plutôt que de laisser croire que la politique
// s'applique : PlanFlow ne modèle pas encore cette date.
<Badge tone="warn">non calculable</Badge>
) : null}
</td>
<td className="px-4 py-2.5 text-ink-2">
{policy.justification}
</td>
<td className="tnum px-4 py-2.5 text-ink-2">
{dateFormat.format(policy.effectiveFrom)}
</td>
<td className="px-4 py-2.5">
<LegalHoldForm policyId={policy.id} held={policy.legalHold} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<Card>
<CardHeader title="Déclarer une durée" />
<div className="p-4">
<RetentionPolicyForm />
</div>
</Card>
<Card>
<CardHeader
title="Pièces du dossier salarié"
badge={<Badge tone="neutral">{candidates.length}</Badge>}
/>
<div className="flex flex-col gap-3 p-4">
{candidates.length === 0 ? (
<p className="text-sm text-ink-3">Aucune pièce déposée.</p>
) : (
<ul className="flex flex-col gap-1 text-sm">
{candidates.slice(0, 40).map((candidate) => (
<li key={candidate.id} className="flex flex-wrap items-center gap-2">
<Badge
tone={
candidate.verdict === 'DUE'
? 'warn'
: candidate.verdict === 'HELD'
? 'info'
: 'neutral'
}
>
{VERDICT_LABELS[candidate.verdict]}
</Badge>
<span className="text-ink-1">{candidate.name}</span>
<span className="text-micro text-ink-3">
{candidate.category}
</span>
<span className="tnum text-micro text-ink-3">
déposée le {dateFormat.format(candidate.uploadedAt)}
</span>
</li>
))}
</ul>
)}
{unpolicied.length > 0 ? (
<p className="text-micro text-ink-3">
{unpolicied.length} pièce(s) sans politique déclarée : elles ne
seront pas purgées tant qu’aucune durée ne les vise.
</p>
) : null}
<PurgeForm due={due.length} />
</div>
</Card>
</PageBody>
);
}
+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' },
+157
View File
@@ -0,0 +1,157 @@
/**
* Durées de conservation — PLAN.md §12.5, matrice n° 21.
*
* La matrice est explicite : les minima légaux ne sont ni universels ni une
* autorisation de tout garder. **Aucune durée par défaut n'est appliquée ici.**
* Un objet sans politique déclarée se conserve, et l'écran le dit — inventer
* « cinq ans partout » serait précisément la faute que le plan interdit.
*/
/**
* Points de départ du décompte.
*
* `employee_departure` est **déclaré mais pas calculable** : PlanFlow ne modèle
* pas encore de date de départ. Une politique qui s'y adosse est conservée et
* affichée, jamais appliquée — la déclarer applicable reviendrait à purger sur
* une date inventée.
*/
export const START_POINTS = [
'creation',
'contract_end',
'period_end',
'employee_departure',
] as const;
export type StartPoint = (typeof START_POINTS)[number];
export const START_POINT_LABELS: Record<StartPoint, string> = {
creation: 'Création de l’objet',
contract_end: 'Fin du contrat',
period_end: 'Fin de la période de paie',
employee_departure: 'Départ du salarié',
};
/** Points de départ que le code sait situer dans le temps aujourd'hui. */
export const COMPUTABLE_START_POINTS = new Set<StartPoint>([
'creation',
'contract_end',
'period_end',
]);
export function isComputable(startPoint: string): boolean {
return COMPUTABLE_START_POINTS.has(startPoint as StartPoint);
}
export interface RetentionPolicyLike {
objectType: string;
durationMonths: number;
startPoint: string;
legalHold: boolean;
effectiveFrom: Date;
}
/**
* Politique applicable à une date donnée.
*
* Effectif-daté comme le reste : une pièce déposée en mars relève de la
* politique en vigueur en mars, même si elle a changé depuis. Sans cela, un
* durcissement rétroactif purgerait des pièces que la règle du moment
* autorisait à garder.
*/
export function applicablePolicy<T extends RetentionPolicyLike>(
policies: readonly T[],
objectType: string,
at: Date,
): T | null {
const candidates = policies
.filter(
(policy) =>
policy.objectType === objectType && policy.effectiveFrom <= at,
)
.sort(
(a, b) => b.effectiveFrom.getTime() - a.effectiveFrom.getTime(),
);
return candidates[0] ?? null;
}
/**
* Politique applicable, en remontant du plus précis au plus général.
*
* `Document:SICK_NOTE` avant `Document` : un arrêt de travail et un contrat
* n'ont aucune raison de se conserver aussi longtemps, et la matrice demande
* justement de trancher par objet.
*/
export function resolvePolicy<T extends RetentionPolicyLike>(
policies: readonly T[],
objectTypes: readonly string[],
at: Date,
): T | null {
for (const objectType of objectTypes) {
const found = applicablePolicy(policies, objectType, at);
if (found) return found;
}
return null;
}
/**
* Échéance de conservation.
*
* Un décalage en mois, pas en jours : « cinq ans » se compte de date à date.
* `setUTCMonth` ramène le 31 mars + 1 mois au 31 avril, donc au 1er mai ; on
* borne au dernier jour du mois visé pour que l'échéance reste dans le mois
* attendu.
*/
export function dueAt(anchor: Date, durationMonths: number): Date {
const target = new Date(anchor.getTime());
const day = target.getUTCDate();
target.setUTCDate(1);
target.setUTCMonth(target.getUTCMonth() + durationMonths);
const lastDay = new Date(
Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0),
).getUTCDate();
target.setUTCDate(Math.min(day, lastDay));
return target;
}
export type PurgeVerdict =
| 'NO_POLICY'
| 'NOT_DUE'
| 'HELD'
| 'NOT_COMPUTABLE'
| 'DUE';
export interface PurgeInput {
policy: RetentionPolicyLike | null;
anchor: Date | null;
now: Date;
}
/**
* Faut-il purger ?
*
* Chaque refus porte son motif : « rien à purger » sans distinguer l'absence de
* politique, la suspension pour contentieux et l'échéance non atteinte
* empêcherait de vérifier que la conservation est tenue.
*/
export function purgeVerdict({ policy, anchor, now }: PurgeInput): PurgeVerdict {
if (!policy) return 'NO_POLICY';
// Le legal hold prime sur tout : une pièce sous séquestre ne se purge pas,
// même largement échue.
if (policy.legalHold) return 'HELD';
if (!isComputable(policy.startPoint)) return 'NOT_COMPUTABLE';
if (!anchor) return 'NOT_COMPUTABLE';
return dueAt(anchor, policy.durationMonths) <= now ? 'DUE' : 'NOT_DUE';
}
export const VERDICT_LABELS: Record<PurgeVerdict, string> = {
NO_POLICY: 'Aucune politique déclarée',
NOT_DUE: 'Échéance non atteinte',
HELD: 'Suspendue — conservation à titre probatoire',
NOT_COMPUTABLE: 'Point de départ non calculable',
DUE: 'À purger',
};
+29 -3
View File
@@ -1,5 +1,11 @@
import { can } from '@/domain/access/authorize';
import type { DocumentCategory } from '@/domain/documents/rules';
import {
dueAt,
isComputable,
resolvePolicy,
type RetentionPolicyLike,
} from '@/domain/retention/policy';
import { query } from '@/server/context';
import { signedDocumentUrl } from '@/server/documents/links';
@@ -10,6 +16,12 @@ export interface DocumentRow {
mimeType: string;
sizeBytes: number;
isSensitive: boolean;
/**
* Échéance **dérivée** de la politique en vigueur au dépôt, jamais stockée.
* La colonne existe au schéma pour une échéance fixée à la main ; tant
* qu'elle ne sert pas, deux sources de vérité vaudraient mieux qu'une seule
* uniquement dans les rapports de bogue.
*/
retentionUntil: Date | null;
uploadedAt: Date;
/** Lien signé, valable quelques minutes seulement. */
@@ -42,17 +54,31 @@ export async function listDocuments(
mimeType: true,
sizeBytes: true,
isSensitive: true,
retentionUntil: true,
uploadedAt: true,
},
});
const policies =
(await db.retentionPolicy.findMany()) as RetentionPolicyLike[];
return {
documents: rows.map((row) => {
const policy = resolvePolicy(
policies,
[`Document:${row.category}`, 'Document'],
row.uploadedAt,
);
return {
documents: rows.map((row) => ({
...row,
category: row.category as DocumentCategory,
retentionUntil:
policy && isComputable(policy.startPoint)
? dueAt(row.uploadedAt, policy.durationMonths)
: null,
href: signedDocumentUrl(row.id),
})),
};
}),
canManage: can(actor, 'members.documents.manage'),
};
});
+186
View File
@@ -0,0 +1,186 @@
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError } from '@/domain/access/authorize';
import { START_POINTS } from '@/domain/retention/policy';
import { recordAudit } from '@/server/audit';
import { mutate } from '@/server/context';
import { runRetentionPurge } from '@/server/retention/purge';
export interface RetentionState {
error?: string;
ok?: boolean;
message?: string;
}
const policyInput = z.object({
objectType: z.string().trim().min(1, 'Objet requis').max(80),
durationMonths: z.coerce
.number()
.int()
.min(1, 'La durée doit valoir au moins un mois')
.max(1200),
startPoint: z.enum(START_POINTS),
/**
* Obligatoire, et c'est le point : la matrice exige que chaque durée porte sa
* justification. Une durée sans motif est une durée qu'on ne saura pas
* défendre le jour d'un contrôle.
*/
justification: z
.string()
.trim()
.min(10, 'Justifiez la durée : elle devra être défendue lors d’un contrôle')
.max(500),
effectiveFrom: z.coerce.date(),
});
export async function saveRetentionPolicyAction(
_previous: RetentionState,
formData: FormData,
): Promise<RetentionState> {
const parsed = policyInput.safeParse({
objectType: formData.get('objectType'),
durationMonths: formData.get('durationMonths'),
startPoint: formData.get('startPoint'),
justification: formData.get('justification'),
effectiveFrom: formData.get('effectiveFrom'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
try {
await mutate('settings.access', async (db, actor) => {
const { objectType, effectiveFrom } = parsed.data;
const existing = await db.retentionPolicy.findFirst({
where: { objectType, effectiveFrom },
});
if (existing) {
await db.retentionPolicy.update({
where: { id: existing.id },
data: {
durationMonths: parsed.data.durationMonths,
startPoint: parsed.data.startPoint,
justification: parsed.data.justification,
} as never,
});
} else {
await db.retentionPolicy.create({ data: parsed.data as never });
}
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'retention.policy.save',
entityType: 'RetentionPolicy',
entityId: existing?.id ?? objectType,
before: existing
? {
durationMonths: existing.durationMonths,
startPoint: existing.startPoint,
}
: null,
after: {
objectType,
durationMonths: parsed.data.durationMonths,
startPoint: parsed.data.startPoint,
},
});
});
} catch (error) {
return toState(error, "Vous n'avez pas le droit de modifier les durées.");
}
revalidatePath('/reglages/conservation');
return { ok: true, message: 'Durée enregistrée.' };
}
/**
* Suspend ou lève une conservation à titre probatoire.
*
* Un contentieux impose de garder des pièces au-delà de leur échéance. La
* suspension prime sur la durée, et son basculement est tracé : lever un
* séquestre est une décision, pas un réglage.
*/
export async function toggleLegalHoldAction(
_previous: RetentionState,
formData: FormData,
): Promise<RetentionState> {
const id = String(formData.get('policyId') ?? '');
if (!id) return { error: 'Politique introuvable.' };
let held = false;
try {
await mutate('settings.access', async (db, actor) => {
const policy = await db.retentionPolicy.findUnique({ where: { id } });
if (!policy) return;
held = !policy.legalHold;
await db.retentionPolicy.update({
where: { id },
data: { legalHold: held } as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: held ? 'retention.hold.set' : 'retention.hold.release',
entityType: 'RetentionPolicy',
entityId: id,
before: { legalHold: policy.legalHold },
after: { legalHold: held },
});
});
} catch (error) {
return toState(error, "Vous n'avez pas le droit de modifier les durées.");
}
revalidatePath('/reglages/conservation');
return {
ok: true,
message: held
? 'Conservation suspendue : les pièces concernées ne seront pas purgées.'
: 'Suspension levée.',
};
}
/**
* Lance la purge.
*
* Exposée à l'écran **et** disponible en ligne de commande pour une exécution
* périodique : la matrice demande des purges automatiques, et un bouton qu'il
* faut penser à presser n'en est pas une.
*/
export async function runPurgeAction(
_previous: RetentionState,
_formData: FormData,
): Promise<RetentionState> {
let purged = 0;
try {
await mutate('settings.access', async (db, actor) => {
const report = await runRetentionPurge(db, actor.membershipId);
purged = report.purged;
});
} catch (error) {
return toState(error, "Vous n'avez pas le droit de lancer une purge.");
}
revalidatePath('/reglages/conservation');
return {
ok: true,
message:
purged === 0
? 'Aucune pièce échue : rien n’a été effacé.'
: `${purged} pièce${purged > 1 ? 's' : ''} effacée${purged > 1 ? 's' : ''}.`,
};
}
function toState(error: unknown, denied: string): RetentionState {
if (error instanceof AuthorizationError) return { error: denied };
throw error;
}
+129
View File
@@ -0,0 +1,129 @@
import 'server-only';
import {
purgeVerdict,
resolvePolicy,
type PurgeVerdict,
type RetentionPolicyLike,
} from '@/domain/retention/policy';
import { recordAudit } from '@/server/audit';
import { removeFile } from '@/server/documents/storage';
import type { ScopedClient } from '@/server/tenant';
/**
* Purge des pièces échues — PLAN.md §12.5.
*
* La purge ne s'applique qu'aux objets pour lesquels une politique a été
* **déclarée**. Le reste se conserve, et l'écran l'annonce : effacer faute de
* règle serait aussi fautif que garder indéfiniment.
*
* Les tables append-only en sont exclues par construction — un déclencheur
* PostgreSQL refuse d'y supprimer. C'est voulu : le journal d'audit doit
* survivre à la purge des données qu'il décrit, sans quoi on ne pourrait plus
* démontrer que la purge a eu lieu.
*/
export interface PurgeCandidate {
id: string;
name: string;
category: string;
uploadedAt: Date;
verdict: PurgeVerdict;
dueVia: string | null;
}
export interface PurgeReport {
candidates: PurgeCandidate[];
purged: number;
}
function policiesFor(category: string): string[] {
return [`Document:${category}`, 'Document'];
}
/**
* Inventaire, sans rien effacer.
*
* Un état des lieux consultable est ce qui rend la purge vérifiable plutôt que
* confiante : on doit pouvoir dire, avant de l'exécuter, ce qu'elle emportera.
*/
export async function inspectRetention(
db: ScopedClient,
now = new Date(),
): Promise<PurgeCandidate[]> {
const policies = (await db.retentionPolicy.findMany()) as RetentionPolicyLike[];
const documents = await db.document.findMany({
where: { deletedAt: null },
select: {
id: true,
name: true,
category: true,
uploadedAt: true,
retentionUntil: true,
},
orderBy: { uploadedAt: 'asc' },
});
return documents.map((document) => {
const policy = resolvePolicy(
policies,
policiesFor(document.category),
document.uploadedAt,
);
return {
id: document.id,
name: document.name,
category: document.category,
uploadedAt: document.uploadedAt,
verdict: purgeVerdict({
policy,
anchor: document.uploadedAt,
now,
}),
dueVia: policy?.objectType ?? null,
};
});
}
/**
* Exécute la purge.
*
* Le contenu disparaît ; la ligne reste, marquée supprimée, et une entrée
* d'audit dit pourquoi. Une purge silencieuse serait indistinguable d'une
* perte de données.
*/
export async function runRetentionPurge(
db: ScopedClient,
actorMembershipId: string,
now = new Date(),
): Promise<PurgeReport> {
const candidates = await inspectRetention(db, now);
const due = candidates.filter((candidate) => candidate.verdict === 'DUE');
for (const candidate of due) {
const document = await db.document.findUnique({
where: { id: candidate.id },
select: { fileKey: true, name: true, category: true },
});
if (!document) continue;
await db.document.update({
where: { id: candidate.id },
data: { deletedAt: now, deletedBy: 'retention' } as never,
});
await recordAudit(db, {
actorMembershipId,
action: 'document.purge',
entityType: 'Document',
entityId: candidate.id,
before: { name: document.name, category: document.category },
after: { purgedBy: 'retention', policy: candidate.dueVia },
});
await removeFile(document.fileKey);
}
return { candidates, purged: due.length };
}
+202
View File
@@ -0,0 +1,202 @@
import { expect, test, type Page } from '@playwright/test';
import { backdateDocument } from './support/db';
/**
* Durées de conservation — PLAN.md §12.5, matrice n° 21.
*
* Deux garanties opposées à tenir simultanément : ne rien purger faute de règle
* déclarée, et purger effectivement dès qu'une règle échue s'applique. Les
* traiter séparément laisserait passer une purge qui n'efface jamais rien.
*/
const PDF = Buffer.from('%PDF-1.4\n%%EOF\n', 'ascii');
/** Objet propre à chaque exécution : les politiques s'accumulent en base. */
function objectType(tag: string): string {
return `Test:${tag}:${Date.now()}`;
}
async function declarePolicy(
page: Page,
values: {
objectType: string;
durationMonths: string;
justification: string;
effectiveFrom: string;
},
) {
await page.goto('/reglages/conservation');
const form = page.locator('form').filter({ hasText: 'Justification' });
await form.getByLabel('Objet', { exact: true }).fill(values.objectType);
await form.getByLabel('Durée en mois').fill(values.durationMonths);
await form.getByLabel('En vigueur à partir du').fill(values.effectiveFrom);
await form.getByLabel('Justification').fill(values.justification);
await form.getByRole('button', { name: 'Enregistrer' }).click();
}
test('l’écran affirme qu’aucune durée n’est appliquée par défaut', async ({
page,
}) => {
await page.goto('/reglages/conservation');
await expect(
page.getByRole('heading', { name: 'Durées de conservation' }),
).toBeVisible();
// La matrice interdit « cinq ans partout » : le dire à l'écran est ce qui
// empêche de le rétablir par inadvertance.
await expect(page.getByText(/aucune durée par défaut/i)).toBeVisible();
// Le journal d'audit doit survivre aux données qu'il décrit.
await expect(page.getByText(/journaux d’audit échappent à la purge/i)).toBeVisible();
});
test('une durée sans justification est refusée', async ({ page }) => {
await page.goto('/reglages/conservation');
const form = page.locator('form').filter({ hasText: 'Justification' });
await form.getByLabel('Objet', { exact: true }).fill(objectType('sans-motif'));
await form.getByLabel('Durée en mois').fill('12');
await form.getByLabel('En vigueur à partir du').fill('2026-01-01');
// Le `minLength` du navigateur bloquerait une saisie manifestement courte :
// on éprouve ici le contrôle serveur, avec une forme qui passe le premier —
// des espaces, que le serveur retire avant de mesurer.
await form.getByLabel('Justification').fill(' ');
await form.getByRole('button', { name: 'Enregistrer' }).click();
// Une durée sans motif est une durée qu'on ne saura pas défendre.
await expect(form.getByText(/Justifiez la durée/)).toBeVisible();
});
test('une durée déclarée apparaît avec sa justification', async ({ page }) => {
const type = objectType('declare');
await declarePolicy(page, {
objectType: type,
durationMonths: '36',
justification: 'Décompte des jours de forfait : trois ans minimum.',
effectiveFrom: '2026-01-01',
});
await expect(page.getByText('Durée enregistrée.')).toBeVisible();
await page.reload();
const row = page.getByRole('row', { name: new RegExp(type) });
await expect(row).toBeVisible();
await expect(row.getByText('36 mois')).toBeVisible();
await expect(row.getByText(/trois ans minimum/)).toBeVisible();
});
test('un point de départ non calculable est signalé comme tel', async ({
page,
}) => {
const type = objectType('depart');
await page.goto('/reglages/conservation');
const form = page.locator('form').filter({ hasText: 'Justification' });
await form.getByLabel('Objet', { exact: true }).fill(type);
await form.getByLabel('Durée en mois').fill('60');
await form.getByLabel('Point de départ').selectOption('employee_departure');
await form.getByLabel('En vigueur à partir du').fill('2026-01-01');
await form
.getByLabel('Justification')
.fill('Registre du personnel : cinq ans après le départ.');
await form.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('Durée enregistrée.')).toBeVisible();
await page.reload();
// PlanFlow ne modèle pas de date de départ : le dire plutôt que de laisser
// croire que la politique s'applique.
const row = page.getByRole('row', { name: new RegExp(type) });
await expect(row.getByText('non calculable')).toBeVisible();
});
test('une pièce sans politique n’est jamais purgée', async ({ page }) => {
// Le salarié et sa pièce sont propres à l'exécution : la purge est globale au
// compte, et une pièce partagée verrait son sort décidé par un autre test.
const suffix = `${Date.now()}-garde`;
await page.goto('/equipe');
const create = page.locator('form').filter({ hasText: 'Ajouter' });
await create.getByLabel('Prénom').fill('Garde');
await create.getByLabel('Nom', { exact: true }).fill(`Garde${suffix}`);
await create.getByLabel('Matricule').fill(`RET${suffix}`);
await create.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
await page.getByRole('link', { name: new RegExp(`Garde${suffix}`) }).click();
const upload = page.locator('form').filter({ hasText: 'Déposer' });
await upload.getByLabel('Catégorie').selectOption('OTHER');
await upload
.getByLabel('Fichier')
.setInputFiles({
name: `sans-politique-${suffix}.pdf`,
mimeType: 'application/pdf',
buffer: PDF,
});
await upload.getByRole('button', { name: 'Déposer' }).click();
await expect(page.getByText(/déposé\./)).toBeVisible();
// La catégorie « Autre » n'est visée par aucune politique semée.
await expect(page.getByText(/échéance de conservation non fixée/)).toBeVisible();
await page.goto('/reglages/conservation');
await expect(
page.getByText(new RegExp(`sans-politique-${suffix}`)),
).toBeVisible();
await expect(
page
.locator('li')
.filter({ hasText: `sans-politique-${suffix}` })
.getByText('Aucune politique déclarée'),
).toBeVisible();
});
test('une pièce échue est effectivement effacée', async ({ page }) => {
const suffix = `${Date.now()}-purge`;
const fileName = `a-purger-${suffix}.pdf`;
// Politique propre au test : viser une catégorie partagée ferait purger les
// pièces des autres exécutions.
await declarePolicy(page, {
objectType: 'Document:REGISTER',
durationMonths: '1',
justification: 'Durée courte, déclarée pour éprouver la purge.',
effectiveFrom: '2020-01-01',
});
await expect(page.getByText('Durée enregistrée.')).toBeVisible();
await page.goto('/equipe');
const create = page.locator('form').filter({ hasText: 'Ajouter' });
await create.getByLabel('Prénom').fill('Purge');
await create.getByLabel('Nom', { exact: true }).fill(`Purge${suffix}`);
await create.getByLabel('Matricule').fill(`PUR${suffix}`);
await create.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
await page.getByRole('link', { name: new RegExp(`Purge${suffix}`) }).click();
const dossier = page.url();
const upload = page.locator('form').filter({ hasText: 'Déposer' });
await upload.getByLabel('Catégorie').selectOption('REGISTER');
await upload
.getByLabel('Fichier')
.setInputFiles({ name: fileName, mimeType: 'application/pdf', buffer: PDF });
await upload.getByRole('button', { name: 'Déposer' }).click();
await expect(page.getByText(`« ${fileName} » déposé.`)).toBeVisible();
// Fraîchement déposée, elle n'est pas échue : la purge ne doit pas l'emporter.
await page.goto('/reglages/conservation');
await expect(
page.locator('li').filter({ hasText: fileName }).getByText('Échéance non atteinte'),
).toBeVisible();
// Seule l'échéance déclenche l'effacement, et elle se compte en mois.
await backdateDocument(fileName, 3);
await page.reload();
await expect(
page.locator('li').filter({ hasText: fileName }).getByText('À purger'),
).toBeVisible();
await page.getByRole('button', { name: 'Purger les pièces échues' }).click();
await expect(page.getByText(/effacée/)).toBeVisible();
// Effacée du dossier, et le contenu avec.
await page.goto(dossier);
await expect(page.getByRole('link', { name: fileName })).toHaveCount(0);
});
+29
View File
@@ -33,3 +33,32 @@ export async function resetMfa(email: string): Promise<void> {
});
await db().mfaRecoveryCode.deleteMany({ where: { userId: user.id } });
}
/**
* Recule la date de dépôt d'une pièce.
*
* Aucune interface ne permet d'antidater, et c'est bien ainsi. Sans ce levier,
* la moitié utile de la purge — celle qui efface — resterait invérifiable :
* seule l'échéance atteinte la déclenche, et elle se compte en mois.
*
* La transaction pose `app.account_id` : la table est protégée par RLS, et une
* mise à jour sans compte courant ne toucherait aucune ligne — en silence.
*/
export async function backdateDocument(
name: string,
months: number,
): Promise<void> {
const document = await db().$queryRaw<Array<{ id: string; accountId: string }>>`
SELECT id, "accountId" FROM "Document" WHERE name = ${name} LIMIT 1
`;
const found = document[0];
if (!found) throw new Error(`Pièce introuvable : ${name}`);
const uploadedAt = new Date();
uploadedAt.setMonth(uploadedAt.getMonth() - months);
await db().$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.account_id', ${found.accountId}, true)`;
await tx.$executeRaw`UPDATE "Document" SET "uploadedAt" = ${uploadedAt} WHERE id = ${found.id}`;
});
}
+202
View File
@@ -0,0 +1,202 @@
import { describe, expect, it } from 'vitest';
import {
applicablePolicy,
dueAt,
isComputable,
purgeVerdict,
resolvePolicy,
START_POINT_LABELS,
START_POINTS,
VERDICT_LABELS,
type RetentionPolicyLike,
} from '@/domain/retention/policy';
function policy(
overrides: Partial<RetentionPolicyLike> = {},
): RetentionPolicyLike {
return {
objectType: 'Document',
durationMonths: 12,
startPoint: 'creation',
legalHold: false,
effectiveFrom: new Date('2026-01-01T00:00:00Z'),
...overrides,
};
}
describe('échéance', () => {
it('compte de date à date', () => {
expect(dueAt(new Date('2026-03-15T00:00:00Z'), 12)).toEqual(
new Date('2027-03-15T00:00:00Z'),
);
expect(dueAt(new Date('2026-03-15T00:00:00Z'), 60)).toEqual(
new Date('2031-03-15T00:00:00Z'),
);
});
it('ne déborde pas sur le mois suivant', () => {
// 31 janvier + 1 mois n'est pas le 3 mars : sans bornage, une pièce
// déposée en fin de mois se purgerait un jour trop tard, tous les mois.
expect(dueAt(new Date('2026-01-31T00:00:00Z'), 1)).toEqual(
new Date('2026-02-28T00:00:00Z'),
);
expect(dueAt(new Date('2026-08-31T00:00:00Z'), 1)).toEqual(
new Date('2026-09-30T00:00:00Z'),
);
});
it('tient compte des années bissextiles', () => {
expect(dueAt(new Date('2028-01-31T00:00:00Z'), 1)).toEqual(
new Date('2028-02-29T00:00:00Z'),
);
});
it('conserve l’heure de l’ancrage', () => {
expect(dueAt(new Date('2026-03-15T14:30:00Z'), 3)).toEqual(
new Date('2026-06-15T14:30:00Z'),
);
});
});
describe('politique applicable', () => {
const older = policy({
durationMonths: 12,
effectiveFrom: new Date('2025-01-01T00:00:00Z'),
});
const newer = policy({
durationMonths: 60,
effectiveFrom: new Date('2026-06-01T00:00:00Z'),
});
it('retient la plus récente en vigueur', () => {
expect(
applicablePolicy([older, newer], 'Document', new Date('2026-07-01Z')),
).toBe(newer);
});
it('ignore celle qui n’est pas encore en vigueur', () => {
// Effectif-daté : une pièce déposée en mars relève de la règle de mars.
// Sans cela, un durcissement rétroactif purgerait ce que la règle du moment
// autorisait à garder.
expect(
applicablePolicy([older, newer], 'Document', new Date('2026-03-01Z')),
).toBe(older);
});
it('ne rend rien quand aucune ne vise l’objet', () => {
expect(
applicablePolicy([older], 'Shift', new Date('2026-07-01Z')),
).toBeNull();
});
});
describe('résolution du plus précis au plus général', () => {
const general = policy({ objectType: 'Document', durationMonths: 60 });
const precise = policy({
objectType: 'Document:SICK_NOTE',
durationMonths: 6,
});
it('préfère l’objet précis', () => {
// Un arrêt de travail et un contrat n'ont aucune raison de se conserver
// aussi longtemps.
const found = resolvePolicy(
[general, precise],
['Document:SICK_NOTE', 'Document'],
new Date('2026-07-01Z'),
);
expect(found?.durationMonths).toBe(6);
});
it('retombe sur le général', () => {
const found = resolvePolicy(
[general, precise],
['Document:IDENTITY', 'Document'],
new Date('2026-07-01Z'),
);
expect(found?.durationMonths).toBe(60);
});
});
describe('décision de purge', () => {
const anchor = new Date('2026-01-15T00:00:00Z');
it('refuse en l’absence de politique', () => {
// Aucune durée par défaut : effacer faute de règle serait aussi fautif que
// garder indéfiniment.
expect(
purgeVerdict({ policy: null, anchor, now: new Date('2099-01-01Z') }),
).toBe('NO_POLICY');
});
it('purge une fois l’échéance atteinte', () => {
expect(
purgeVerdict({
policy: policy({ durationMonths: 12 }),
anchor,
now: new Date('2027-01-15T00:00:00Z'),
}),
).toBe('DUE');
});
it('attend l’échéance à la journée près', () => {
expect(
purgeVerdict({
policy: policy({ durationMonths: 12 }),
anchor,
now: new Date('2027-01-14T23:59:59Z'),
}),
).toBe('NOT_DUE');
});
it('suspend malgré une échéance largement dépassée', () => {
// Un contentieux impose de garder : la suspension prime sur la durée.
expect(
purgeVerdict({
policy: policy({ legalHold: true }),
anchor,
now: new Date('2099-01-01Z'),
}),
).toBe('HELD');
});
it('refuse un point de départ que le code ne sait pas situer', () => {
// PlanFlow ne modèle pas de date de départ : purger sur une date inventée
// serait pire que ne pas purger.
expect(
purgeVerdict({
policy: policy({ startPoint: 'employee_departure' }),
anchor,
now: new Date('2099-01-01Z'),
}),
).toBe('NOT_COMPUTABLE');
});
it('refuse quand l’ancrage manque', () => {
expect(
purgeVerdict({ policy: policy(), anchor: null, now: new Date() }),
).toBe('NOT_COMPUTABLE');
});
});
describe('catalogue', () => {
it('nomme chaque point de départ', () => {
for (const point of START_POINTS) {
expect(START_POINT_LABELS[point]).toBeTruthy();
}
});
it('distingue calculable et déclaré', () => {
expect(isComputable('creation')).toBe(true);
expect(isComputable('employee_departure')).toBe(false);
});
it('motive chaque verdict', () => {
// « Rien à purger » sans distinguer l'absence de politique, la suspension
// et l'échéance non atteinte empêcherait de vérifier la conservation.
for (const verdict of Object.keys(VERDICT_LABELS)) {
expect(VERDICT_LABELS[verdict as keyof typeof VERDICT_LABELS]).toBeTruthy();
}
});
});