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:
13 files changed
+1371
-6
No files matched your search
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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' },
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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'),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}`;
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user