Déclarer et appliquer les durées de conservation
La table RetentionPolicy existait et cinq durées y étaient semées depuis la matrice ; rien ne les appliquait. Une durée déclarée que personne n'exécute est une conformité de papier. Aucune durée par défaut n'est appliquée, et c'est le point central : la matrice interdit explicitement d'aligner tout sur cinq ans. Un objet sans politique déclarée se conserve, et l'écran le signale plutôt que de le taire. Symétrie inverse, tout aussi importante : effacer faute de règle serait aussi fautif que garder indéfiniment. La justification est obligatoire au niveau du serveur. Une durée sans motif est une durée qu'on ne saura pas défendre le jour d'un contrôle. Les politiques sont effectif-datées comme le reste de l'application : une pièce déposée en mars relève de la règle en vigueur en mars. Sans cela, un durcissement rétroactif purgerait ce que la règle du moment autorisait à garder. La résolution va du précis au général — Document:SICK_NOTE avant Document — car un arrêt de travail et un contrat n'ont aucune raison de se conserver aussi longtemps. Trois refus distincts plutôt qu'un seul : absence de politique, conservation suspendue à titre probatoire, échéance non atteinte. Les confondre sous « rien à purger » empêcherait de vérifier que la conservation est réellement tenue. Un quatrième existe : employee_departure est déclaré mais non calculable, PlanFlow ne modélisant pas de date de départ — purger sur une date inventée serait pire que ne pas purger, et l'écran l'affiche comme tel. La purge s'exécute en ligne de commande pour une tâche planifiée, la matrice demandant des purges automatiques ; un bouton qu'il faut penser à presser n'en est pas une. Elle passe par le client scopé et la RLS, compte par compte. Les tables append-only en sont exclues par construction : le journal d'audit doit survivre aux données qu'il décrit, sans quoi on ne pourrait plus démontrer que la purge a eu lieu. L'échéance affichée est dérivée de la politique, jamais stockée — même discipline que la péremption d'un export. Deux pièges rencontrés : un objet de composants exporté depuis un module client ne survit pas au passage par un composant serveur, React n'en recevant qu'un undefined ; et le minLength du navigateur masquait le contrôle serveur de la justification, que des espaces suffisent à contourner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
13 files changed
+1371
-6
No files matched your search
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user