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
65 lines
2.3 KiB
TypeScript
65 lines
2.3 KiB
TypeScript
import { PrismaPg } from '@prisma/adapter-pg';
|
|
import { PrismaClient } from '@prisma/client';
|
|
|
|
/**
|
|
* Accès direct à la base, pour la mise en place des tests seulement.
|
|
*
|
|
* Certains états ne se posent pas par l'interface — retirer un second facteur
|
|
* dont on a perdu le secret, par exemple. Les fabriquer ici garde la suite
|
|
* rejouable sans ajouter au produit une porte qui n'aurait pas lieu d'exister.
|
|
*/
|
|
let client: PrismaClient | null = null;
|
|
|
|
function db(): PrismaClient {
|
|
// Prisma 7 exige un adaptateur : le client applicatif n'est pas réutilisable
|
|
// ici, il vit derrière `server-only`.
|
|
client ??= new PrismaClient({
|
|
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
|
});
|
|
return client;
|
|
}
|
|
|
|
/** Remet un compte à l'état « aucun second facteur », sans effet s'il n'en a pas. */
|
|
export async function resetMfa(email: string): Promise<void> {
|
|
const user = await db().user.findUnique({
|
|
where: { email },
|
|
select: { id: true },
|
|
});
|
|
if (!user) return;
|
|
|
|
await db().user.update({
|
|
where: { id: user.id },
|
|
data: { mfaSecretEnc: null, mfaEnrolledAt: null, mfaLastStep: null },
|
|
});
|
|
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}`;
|
|
});
|
|
}
|