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.
|
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
|
## Vérifier
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"db:seed": "tsx prisma/seed.ts",
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"mfa:reset": "tsx scripts/mfa-reset.ts",
|
"mfa:reset": "tsx scripts/mfa-reset.ts",
|
||||||
|
"retention:purge": "tsx scripts/retention-purge.ts",
|
||||||
"verify": "pnpm typecheck && pnpm lint && pnpm test"
|
"verify": "pnpm typecheck && pnpm lint && pnpm test"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"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: 'registre', label: 'Registre de paramétrage', href: '/reglages/registre' },
|
||||||
{ id: 'email', label: 'Envoi de courrier', href: '/reglages/email' },
|
{ id: 'email', label: 'Envoi de courrier', href: '/reglages/email' },
|
||||||
{ id: 'securite', label: 'Sécurité', href: '/reglages/securite' },
|
{ id: 'securite', label: 'Sécurité', href: '/reglages/securite' },
|
||||||
|
{
|
||||||
|
id: 'conservation',
|
||||||
|
label: 'Durées de conservation',
|
||||||
|
href: '/reglages/conservation',
|
||||||
|
},
|
||||||
{ id: 'convention', label: 'Convention collective' },
|
{ id: 'convention', label: 'Convention collective' },
|
||||||
{ id: 'postes', label: 'Postes et étiquettes' },
|
{ id: 'postes', label: 'Postes et étiquettes' },
|
||||||
{ id: 'roles', label: 'Rôles et permissions' },
|
{ 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 { can } from '@/domain/access/authorize';
|
||||||
import type { DocumentCategory } from '@/domain/documents/rules';
|
import type { DocumentCategory } from '@/domain/documents/rules';
|
||||||
|
import {
|
||||||
|
dueAt,
|
||||||
|
isComputable,
|
||||||
|
resolvePolicy,
|
||||||
|
type RetentionPolicyLike,
|
||||||
|
} from '@/domain/retention/policy';
|
||||||
import { query } from '@/server/context';
|
import { query } from '@/server/context';
|
||||||
import { signedDocumentUrl } from '@/server/documents/links';
|
import { signedDocumentUrl } from '@/server/documents/links';
|
||||||
|
|
||||||
@@ -10,6 +16,12 @@ export interface DocumentRow {
|
|||||||
mimeType: string;
|
mimeType: string;
|
||||||
sizeBytes: number;
|
sizeBytes: number;
|
||||||
isSensitive: boolean;
|
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;
|
retentionUntil: Date | null;
|
||||||
uploadedAt: Date;
|
uploadedAt: Date;
|
||||||
/** Lien signé, valable quelques minutes seulement. */
|
/** Lien signé, valable quelques minutes seulement. */
|
||||||
@@ -42,17 +54,31 @@ export async function listDocuments(
|
|||||||
mimeType: true,
|
mimeType: true,
|
||||||
sizeBytes: true,
|
sizeBytes: true,
|
||||||
isSensitive: true,
|
isSensitive: true,
|
||||||
retentionUntil: true,
|
|
||||||
uploadedAt: true,
|
uploadedAt: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const policies =
|
||||||
|
(await db.retentionPolicy.findMany()) as RetentionPolicyLike[];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
documents: rows.map((row) => ({
|
documents: rows.map((row) => {
|
||||||
...row,
|
const policy = resolvePolicy(
|
||||||
category: row.category as DocumentCategory,
|
policies,
|
||||||
href: signedDocumentUrl(row.id),
|
[`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'),
|
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 } });
|
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