Merge pull request #21 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre

Déclarer et appliquer les durées de conservation
This commit is contained in:
LogiFlow authored and GitHub committed 2026-08-09 09:43:58 +02:00
commit 0b82283397
13 files changed
+1371 -6

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',
};
+32 -6
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) => ({
...row,
category: row.category as DocumentCategory,
href: signedDocumentUrl(row.id),
})),
documents: rows.map((row) => {
const policy = resolvePolicy(
policies,
[`Document:${row.category}`, 'Document'],
row.uploadedAt,
);
return {
...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();
}
});
});