Poser les modèles de documents et le dictionnaire RGPD

Deux livrables de WP-10 manquaient, et le second n'existait nulle part.

Les modèles de documents produisent attestations et courriers types en
résolvant les variables du dossier. Le rendu vit dans le domaine, avec une règle
qui n'est pas négociable : une variable citée sans valeur fait échouer la
génération. Une attestation trouée est un document faux, pas un document
incomplet — le blanc se remarque à la relecture une fois sur deux, et la pièce
part signée. De même, une variable inconnue est refusée à l'enregistrement du
modèle, par l'administrateur qui l'écrit, plutôt que découverte par le
gestionnaire devant le salarié qui attend.

Le corps reste de l'HTML rédigé par un administrateur ; ce sont les valeurs
venues du dossier qui sont échappées à l'insertion. Un nom de famille importé
d'un autre outil ne doit pas pouvoir ouvrir une balise.

`Document.templateId` est en ON DELETE SET NULL : une pièce déjà remise ne
disparaît pas parce qu'on a effacé le modèle qui l'a produite.

L'écran RGPD livre le dictionnaire donnée → finalité → base légale →
destinataire → durée qu'exige la matrice n° 14. Le dictionnaire vit dans le code
parce qu'il décrit le schéma ; les durées viennent de RetentionPolicy parce
qu'elles se paramètrent et s'auditent. Une catégorie sans durée déclarée est
signalée en rouge : le plan interdit d'appliquer une durée par défaut.

L'affectation des bases légales porte un avertissement explicite. C'est une
qualification juridique, pas un réglage : elle doit être confirmée par le
responsable de traitement avant d'être opposée à quiconque.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-08-11 12:44:44 +02:00
1 parent 5b60e7ddb6
commit db03768025
10 files changed
+1090 -1

No files matched your search

@@ -0,0 +1,51 @@
-- ---------------------------------------------------------------------------
-- Modèles de documents — PLAN.md §4.7 et WP-10.
--
-- `templateId` sur Document est en ON DELETE SET NULL et non CASCADE : une
-- pièce déjà remise à un salarié ne doit pas disparaître parce qu'on a effacé
-- le modèle qui l'a produite. Le lien se perd, le document reste.
-- ---------------------------------------------------------------------------
CREATE TABLE "DocumentTemplate" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"bodyHtml" TEXT NOT NULL,
"availableFields" JSONB NOT NULL,
"archivedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DocumentTemplate_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "DocumentTemplate_accountId_name_key" ON "DocumentTemplate"("accountId", "name");
CREATE INDEX "DocumentTemplate_accountId_idx" ON "DocumentTemplate"("accountId");
ALTER TABLE "DocumentTemplate" ADD CONSTRAINT "DocumentTemplate_accountId_fkey"
FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Document" ADD COLUMN "templateId" TEXT;
CREATE INDEX "Document_templateId_idx" ON "Document"("templateId");
ALTER TABLE "Document" ADD CONSTRAINT "Document_templateId_fkey"
FOREIGN KEY ("templateId") REFERENCES "DocumentTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- ---------------------------------------------------------------------------
-- Isolation par compte — même défense en profondeur que les autres tables.
--
-- Un modèle porte le corps d'attestations et de courriers types : sa fuite
-- livrerait la raison sociale, les mentions et la forme des actes d'un autre
-- client. L'extension Prisma filtre déjà ; ces règles valent pour le cas où
-- elle serait contournée.
-- ---------------------------------------------------------------------------
ALTER TABLE "DocumentTemplate" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "DocumentTemplate" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON "DocumentTemplate"
USING ("accountId" = planflow_current_account());
CREATE POLICY tenant_insert ON "DocumentTemplate"
FOR INSERT WITH CHECK ("accountId" = planflow_current_account());
+32 -1
View File
@@ -34,6 +34,7 @@ model Account {
jobTitles JobTitle[]
labels Label[]
absenceTypes AbsenceType[]
documentTemplates DocumentTemplate[]
legalConfig LegalConfigEntry[]
installation Installation?
}
@@ -792,6 +793,33 @@ model DailyNote {
/// pouvoir reproduire à l'identique une paie antérieure après un changement de
/// règle. Une mise à jour en place rendrait cette reproduction impossible, et
/// l'impossibilité ne se découvrirait qu'au premier contrôle.
/// Modèle de document — PLAN.md §4.7 et §9.
///
/// `bodyHtml` porte le corps avec ses variables sous la forme `{{cle}}`.
/// `availableFields` déclare les variables offertes à la rédaction : elles sont
/// **scopées par établissement**, si bien qu'un modèle rendu pour un salarié
/// résout ses variables dans le contexte de l'établissement de son contrat. Une
/// même attestation ne porte donc pas la même raison sociale d'un magasin à
/// l'autre.
///
/// Archivé plutôt que supprimé : un document généré garde `templateId`, et
/// perdre le modèle rendrait inexplicable la forme d'une pièce déjà remise.
model DocumentTemplate {
id String @id @default(cuid())
accountId String
name String
bodyHtml String
availableFields Json
archivedAt DateTime?
createdAt DateTime @default(now())
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
documents Document[]
@@unique([accountId, name])
@@index([accountId])
}
/// Pièce du dossier salarié — PLAN.md §4.7.
///
/// Le contenu vit sur disque, **chiffré**, jamais dans la base : une pièce
@@ -811,6 +839,8 @@ model Document {
/// Empreinte du clair, pour détecter une altération du fichier stocké.
checksum String
/// Donnée de santé : sa lecture est journalisée (§3.6).
/// Modèle dont la pièce est issue. Nul pour un fichier déposé à la main.
templateId String?
isSensitive Boolean @default(false)
/// Échéance de conservation. Nulle tant qu'aucune politique ne l'a fixée :
/// le plan interdit d'appliquer une durée par défaut (§12.5).
@@ -820,7 +850,8 @@ model Document {
deletedAt DateTime?
deletedBy String?
membership Membership? @relation(fields: [membershipId], references: [id], onDelete: Cascade)
membership Membership? @relation(fields: [membershipId], references: [id], onDelete: Cascade)
template DocumentTemplate? @relation(fields: [templateId], references: [id], onDelete: SetNull)
@@index([accountId])
@@index([membershipId])
@@ -0,0 +1,89 @@
'use client';
import { useActionState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import {
createTemplateAction,
updateTemplateAction,
type ActionState,
} from '@/server/settings/templates';
const empty: ActionState = {};
function BodyField({ defaultValue }: { defaultValue?: string }) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Corps du document</span>
<textarea
name="bodyHtml"
rows={10}
required
defaultValue={defaultValue}
placeholder="<p>Je soussigné…, atteste que {{salarie.prenom}} {{salarie.nom}} est employé…</p>"
className="rounded-2 border border-line-2 bg-surface px-3 py-2 font-mono text-xs text-ink-1 outline-none focus-visible:border-focus"
/>
<span className="text-micro text-ink-3">
Les variables s’écrivent entre doubles accolades. Une variable inconnue
fait refuser l’enregistrement — mieux vaut la corriger ici que de
découvrir un trou dans l’attestation au moment de la remettre.
</span>
</label>
);
}
export function AddTemplateForm() {
const [state, action] = useActionState(createTemplateAction, empty);
return (
<PersistentForm
action={action}
resetAfter={state.ok ? state : null}
className="flex flex-col gap-3"
>
<Field
label="Nom du modèle"
name="name"
required
maxLength={160}
placeholder="Attestation d’emploi"
/>
<BodyField />
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Créer le modèle</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Modèle enregistré.</span>
) : null}
</div>
</PersistentForm>
);
}
export function EditTemplateForm({
id,
name,
bodyHtml,
}: {
id: string;
name: string;
bodyHtml: string;
}) {
const [state, action] = useActionState(updateTemplateAction, empty);
return (
<PersistentForm action={action} className="flex flex-col gap-3">
<input type="hidden" name="id" value={id} />
<Field label="Nom du modèle" name="name" defaultValue={name} required maxLength={160} />
<BodyField defaultValue={bodyHtml} />
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton size="sm">Enregistrer</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Modifications prises.</span>
) : null}
</div>
</PersistentForm>
);
}
@@ -0,0 +1,164 @@
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import {
AddTemplateForm,
EditTemplateForm,
} from '@/app/(app)/reglages/modeles-documents/TemplateForms';
import { TEMPLATE_FIELDS } from '@/domain/documents/template';
import {
archiveTemplateAction,
listTemplates,
} from '@/server/settings/templates';
export const metadata = { title: 'Modèles de documents · PlanFlow' };
export const dynamic = 'force-dynamic';
const SCOPE_LABEL = {
employee: 'Salarié',
contract: 'Contrat',
location: 'Établissement',
} as const;
export default async function ModelesDocumentsPage({
searchParams,
}: {
searchParams: Promise<{ archives?: string }>;
}) {
const { archives } = await searchParams;
const showArchived = archives === '1';
const templates = await listTemplates(showArchived);
const active = templates.filter((template) => template.archivedAt === null);
return (
<PageBody>
<PageHeader
title="Modèles de documents"
subtitle={`${active.length} modèle${active.length > 1 ? 's' : ''} disponible${active.length > 1 ? 's' : ''}`}
/>
<div className="grid gap-5 lg:grid-cols-[1fr_18rem]">
<div className="flex min-w-0 flex-col gap-5">
<Card>
<CardHeader
title="Modèles"
action={
<a
href={
showArchived
? '/reglages/modeles-documents'
: '/reglages/modeles-documents?archives=1'
}
className="text-xs text-ink-2 underline underline-offset-2 hover:text-ink-1"
>
{showArchived ? 'Masquer les archivés' : 'Voir les archivés'}
</a>
}
/>
{templates.length === 0 ? (
<EmptyState
title="Aucun modèle"
description="Un modèle produit une attestation ou un courrier type en résolvant les variables du dossier."
/>
) : (
<ul className="divide-y divide-line-1">
{templates.map((template) => (
<li key={template.id} className="px-4 py-4">
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold">
{template.name}
</span>
<Badge tone="neutral">
{template.documentCount} pièce
{template.documentCount > 1 ? 's' : ''}
</Badge>
{template.archivedAt ? (
<Badge tone="warn">Archivé</Badge>
) : null}
<span className="flex-1" />
<form action={archiveTemplateAction}>
<input type="hidden" name="id" value={template.id} />
{template.archivedAt ? (
<input type="hidden" name="restore" value="1" />
) : null}
<Button size="sm" variant="ghost" type="submit">
{template.archivedAt ? 'Rétablir' : 'Archiver'}
</Button>
</form>
</div>
{template.fields.length > 0 ? (
<ul className="mb-3 flex flex-wrap gap-1.5">
{template.fields.map((field) => (
<li key={field}>
<Badge tone="info">{field}</Badge>
</li>
))}
</ul>
) : (
<p className="mb-3 text-xs text-ink-3">
Aucune variable : ce modèle produit toujours le même
texte.
</p>
)}
<details className="text-sm">
<summary className="cursor-pointer text-xs text-ink-2 underline underline-offset-2">
Modifier
</summary>
<div className="pt-3">
<EditTemplateForm
id={template.id}
name={template.name}
bodyHtml={template.bodyHtml}
/>
</div>
</details>
</li>
))}
</ul>
)}
</Card>
<Card>
<CardHeader title="Nouveau modèle" />
<div className="p-4">
<AddTemplateForm />
</div>
</Card>
</div>
<aside className="flex flex-col gap-3">
<Card>
<CardHeader title="Variables disponibles" />
<ul className="divide-y divide-line-1">
{TEMPLATE_FIELDS.map((field) => (
<li key={field.key} className="px-3 py-2">
<code className="text-xs text-ink-1">
{'{{'}
{field.key}
{'}}'}
</code>
<span className="mt-0.5 flex items-center gap-1.5">
<span className="text-micro text-ink-3">{field.label}</span>
<Badge tone="neutral" subtle>
{SCOPE_LABEL[field.scope]}
</Badge>
</span>
</li>
))}
</ul>
</Card>
<p className="text-xs leading-[var(--lh-prose)] text-ink-3">
Les variables d’établissement se résolvent dans le contexte de
l’établissement porté par le contrat du salarié : la même attestation
ne porte pas la même raison sociale d’un magasin à l’autre.
</p>
</aside>
</div>
</PageBody>
);
}
+184
View File
@@ -0,0 +1,184 @@
import Link from 'next/link';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Card, CardHeader } from '@/components/ui/Card';
import {
DATA_DICTIONARY,
LEGAL_BASIS_LABELS,
} from '@/domain/legal/data-dictionary';
import { query } from '@/server/context';
export const metadata = { title: 'RGPD · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function RgpdPage() {
// Les durées ne sont pas écrites dans le dictionnaire : elles viennent des
// politiques de conservation, qui se paramètrent et s'auditent. Un artefact
// qui afficherait sa propre durée mentirait le jour où la politique change.
const policies = await query('settings.access', async (db) =>
db.retentionPolicy.findMany({
orderBy: [{ objectType: 'asc' }, { effectiveFrom: 'desc' }],
select: {
objectType: true,
durationMonths: true,
effectiveFrom: true,
},
}),
);
const durationFor = (objectTypes: string[]): string => {
const matches = objectTypes
.map((objectType) => {
// Une politique précise l'emporte sur une politique générale :
// « Document:SICK_NOTE » avant « Document ».
const exact = policies.find((policy) => policy.objectType === objectType);
if (exact) return exact;
const base = objectType.split(':')[0] as string;
return policies.find((policy) => policy.objectType === base);
})
.filter((policy) => policy !== undefined);
if (matches.length === 0) return 'Non déclarée';
const months = [...new Set(matches.map((policy) => policy.durationMonths))];
return months
.sort((a, b) => a - b)
.map((count) =>
count % 12 === 0 ? `${count / 12} an${count > 12 ? 's' : ''}` : `${count} mois`,
)
.join(' · ');
};
const undeclared = DATA_DICTIONARY.filter(
(entry) => durationFor(entry.objectTypes) === 'Non déclarée',
).length;
return (
<PageBody>
<PageHeader
title="RGPD"
subtitle="Dictionnaire des données : finalité, base légale, destinataires, durée"
/>
<p
role="note"
className="max-w-[70ch] rounded-2 border border-warn bg-warn-soft px-3 py-2 text-xs leading-[var(--lh-prose)] text-warn-soft-ink"
>
L’affectation d’une base légale est une qualification juridique. Ce
tableau porte la lecture usuelle d’un traitement de paie et de planning ;
il doit être confirmé par le responsable de traitement avant d’être
opposé à qui que ce soit.
</p>
{undeclared > 0 ? (
<p
role="status"
className="max-w-[70ch] rounded-2 border border-danger bg-danger-soft px-3 py-2 text-xs leading-[var(--lh-prose)] text-danger-soft-ink"
>
{undeclared} catégorie{undeclared > 1 ? 's' : ''} sans durée de
conservation déclarée. Le plan interdit d’appliquer une durée par
défaut : déclarez-la dans{' '}
<Link href="/reglages/conservation" className="underline">
Durées de conservation
</Link>
.
</p>
) : null}
<Card>
<CardHeader
title="Données traitées"
badge={<Badge tone="neutral">{DATA_DICTIONARY.length}</Badge>}
/>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line-1 text-left">
{[
'Catégorie',
'Finalité',
'Base légale',
'Destinataires',
'Durée',
].map((heading) => (
<th
key={heading}
className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
>
{heading}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line-1">
{DATA_DICTIONARY.map((entry) => {
const duration = durationFor(entry.objectTypes);
return (
<tr key={entry.category}>
<td className="px-4 py-3 align-top">
<span className="font-medium">{entry.category}</span>
{entry.sensitive ? (
<Badge tone="danger" className="ml-1.5">
Catégorie particulière
</Badge>
) : null}
<span className="mt-1 block font-mono text-micro text-ink-3">
{entry.objectTypes.join(', ')}
</span>
</td>
<td className="max-w-[28ch] px-4 py-3 align-top text-ink-2">
{entry.purpose}
{entry.notes ? (
<span className="mt-1 block text-micro text-ink-3">
{entry.notes}
</span>
) : null}
</td>
<td className="px-4 py-3 align-top">
{LEGAL_BASIS_LABELS[entry.basis]}
</td>
<td className="px-4 py-3 align-top text-ink-2">
{entry.recipients.join(', ')}
</td>
<td className="px-4 py-3 align-top">
{duration === 'Non déclarée' ? (
<Badge tone="danger">Non déclarée</Badge>
) : (
<span className="tnum">{duration}</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
<Card>
<CardHeader title="Droits des personnes" />
<div className="flex flex-col gap-2 px-4 py-3 text-sm leading-[var(--lh-prose)] text-ink-2">
<p>
L’accès, la rectification et l’effacement s’exercent sur le dossier
du salarié. L’effacement ne franchit pas les durées de conservation
légales : une pièce sous obligation de conservation ne s’efface pas
à la demande, et le refus doit être motivé.
</p>
<p>
La portabilité passe par le paquet de départ : export filtré des
données du salarié, accompagné de son manifeste et de son empreinte.
</p>
<p>
Toute lecture d’une pièce de santé est journalisée et consultable
dans le{' '}
<Link href="/rapports/activite" className="underline">
journal d’activité
</Link>
.
</p>
</div>
</Card>
</PageBody>
);
}
+6
View File
@@ -126,6 +126,12 @@ export const NAVIGATION: NavSection[] = [
label: 'Durées de conservation',
href: '/reglages/conservation',
},
{ id: 'rgpd', label: 'RGPD', href: '/reglages/rgpd' },
{
id: 'modeles',
label: 'Modèles de documents',
href: '/reglages/modeles-documents',
},
{
id: 'convention',
label: 'Convention collective',
+118
View File
@@ -0,0 +1,118 @@
/**
* Modèles de documents — PLAN.md §4.7 et §9.
*
* Un modèle est un corps de texte avec des variables `{{cle}}`. Le rendu se
* fait ici, dans le domaine, pour trois raisons :
*
* 1. **Une variable inconnue n'est jamais silencieuse.** Un `{{salire}}` mal
* orthographié doit faire échouer le rendu, pas produire une attestation
* trouée. Une pièce remise à un salarié avec un blanc à la place de son
* salaire est un document faux, pas un document incomplet.
* 2. **Les variables sont scopées par établissement.** La même attestation ne
* porte pas la même raison sociale ni le même SIRET d'un magasin à l'autre ;
* le contexte de résolution est celui de l'établissement du contrat.
* 3. Le corps est de l'HTML rédigé par un administrateur du compte. Il est
* **échappé à l'insertion des valeurs**, jamais à l'écriture : c'est la
* valeur venue du dossier qui pourrait porter un chevron, pas le gabarit.
*/
export interface TemplateField {
key: string;
label: string;
/** D'où vient la valeur : salarié, contrat ou établissement. */
scope: 'employee' | 'contract' | 'location';
}
/**
* Variables offertes à la rédaction.
*
* Liste fermée : un modèle ne peut référencer que ce que l'application sait
* résoudre. Ouvrir la syntaxe à des expressions transformerait un écran de
* réglage en langage de programmation, avec la surface d'attaque qui va avec.
*/
export const TEMPLATE_FIELDS: TemplateField[] = [
{ key: 'salarie.nom', label: 'Nom du salarié', scope: 'employee' },
{ key: 'salarie.prenom', label: 'Prénom du salarié', scope: 'employee' },
{ key: 'salarie.matricule', label: 'Matricule', scope: 'employee' },
{ key: 'contrat.type', label: 'Type de contrat', scope: 'contract' },
{ key: 'contrat.debut', label: 'Date de début', scope: 'contract' },
{ key: 'contrat.fin', label: 'Date de fin', scope: 'contract' },
{ key: 'contrat.emploi', label: 'Emploi', scope: 'contract' },
{
key: 'contrat.heures',
label: 'Durée hebdomadaire',
scope: 'contract',
},
{ key: 'etablissement.nom', label: 'Établissement', scope: 'location' },
{ key: 'etablissement.siret', label: 'SIRET', scope: 'location' },
{ key: 'date.jour', label: 'Date du jour', scope: 'location' },
];
export const TEMPLATE_FIELD_KEYS: string[] = TEMPLATE_FIELDS.map(
(field) => field.key,
);
const PLACEHOLDER = /\{\{\s*([a-zA-Z0-9._-]+)\s*\}\}/g;
/** Variables citées par un corps, dans l'ordre, sans doublon. */
export function referencedFields(bodyHtml: string): string[] {
const found = new Set<string>();
for (const match of bodyHtml.matchAll(PLACEHOLDER)) {
const key = match[1];
if (key) found.add(key);
}
return [...found];
}
/** Variables citées mais que l'application ne sait pas résoudre. */
export function unknownFields(bodyHtml: string): string[] {
return referencedFields(bodyHtml).filter(
(key) => !TEMPLATE_FIELD_KEYS.includes(key),
);
}
/**
* Échappement HTML des valeurs insérées.
*
* Un nom de famille contenant `&` ou une apostrophe typographique doit sortir
* tel quel ; une valeur venue d'un import mal nettoyé ne doit pas pouvoir
* ouvrir une balise.
*/
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
export class MissingTemplateValue extends Error {
constructor(public readonly keys: string[]) {
super(
`Le modèle cite des variables sans valeur : ${keys.join(', ')}. Complétez le dossier avant de générer la pièce.`,
);
this.name = 'MissingTemplateValue';
}
}
/**
* Rend un modèle avec les valeurs fournies.
*
* Lève dès qu'une variable citée n'a pas de valeur. Produire l'attestation avec
* un blanc serait pire que de refuser : le blanc se remarque à la relecture une
* fois sur deux, et la pièce part signée.
*/
export function renderTemplate(
bodyHtml: string,
values: Record<string, string | null | undefined>,
): string {
const missing = referencedFields(bodyHtml).filter(
(key) => values[key] === null || values[key] === undefined || values[key] === '',
);
if (missing.length > 0) throw new MissingTemplateValue(missing);
return bodyHtml.replace(PLACEHOLDER, (_match, key: string) =>
escapeHtml(String(values[key])),
);
}
+147
View File
@@ -0,0 +1,147 @@
/**
* Dictionnaire des données — matrice de conformité n° 14.
*
* « Donnée → finalité → base légale → destinataire → durée », livré comme
* artefact. Il décrit ce que **cette** application collecte, catégorie par
* catégorie ; il ne décrit pas le traitement RH de l'entreprise en général.
*
* Il vit dans le code et non en base : le contenu suit le schéma, et un
* dictionnaire tenu à la main dans une table divergerait au premier champ
* ajouté sans que rien ne le signale. La **durée**, elle, vient de
* `RetentionPolicy` : elle se paramètre, se justifie et s'audite.
*
* `À VALIDER` — l'affectation d'une base légale est une qualification
* juridique. Les lignes ci-dessous portent la lecture usuelle du traitement de
* paie et de planning ; elles doivent être confirmées par le responsable de
* traitement avant d'être opposées à qui que ce soit.
*/
export type LegalBasis =
| 'contract'
| 'legal_obligation'
| 'legitimate_interest'
| 'consent';
export const LEGAL_BASIS_LABELS: Record<LegalBasis, string> = {
contract: 'Exécution du contrat de travail',
legal_obligation: 'Obligation légale',
legitimate_interest: 'Intérêt légitime',
consent: 'Consentement',
};
export interface DictionaryEntry {
/** Catégorie de données, telle qu'un salarié la reconnaîtrait. */
category: string;
/** Objets du schéma qui la portent — le lien avec la politique de purge. */
objectTypes: string[];
purpose: string;
basis: LegalBasis;
recipients: string[];
/** Vrai pour une catégorie particulière au sens de l'article 9. */
sensitive: boolean;
notes?: string;
}
export const DATA_DICTIONARY: DictionaryEntry[] = [
{
category: 'État civil et coordonnées',
objectTypes: ['EmployeeProfile'],
purpose:
'Tenue du registre unique du personnel, déclaration préalable à l’embauche, édition des contrats.',
basis: 'legal_obligation',
recipients: ['Service RH', 'Cabinet de paie'],
sensitive: false,
},
{
category: 'Numéro de sécurité sociale',
objectTypes: ['EmployeeProfile'],
purpose: 'Déclarations sociales et transmission au logiciel de paie.',
basis: 'legal_obligation',
recipients: ['Cabinet de paie'],
sensitive: false,
notes:
'Chiffré au repos. Sa lecture n’est ouverte qu’aux capacités de paie.',
},
{
category: 'Coordonnées bancaires',
objectTypes: ['EmployeeProfile'],
purpose: 'Versement du salaire.',
basis: 'contract',
recipients: ['Cabinet de paie'],
sensitive: false,
notes: 'Chiffrées au repos.',
},
{
category: 'Contrat, rémunération et avenants',
objectTypes: ['UserContract', 'Amendment'],
purpose:
'Exécution et suivi du contrat, calcul des seuils de convention, préparation de la paie.',
basis: 'contract',
recipients: ['Service RH', 'Encadrement', 'Cabinet de paie'],
sensitive: false,
notes:
'La rémunération n’est visible qu’avec la capacité `members.salary.view`.',
},
{
category: 'Planning, heures prévues et réalisées',
objectTypes: ['Shift', 'WeeklySchedule', 'Rest', 'ForfaitDayEntry'],
purpose:
'Organisation du travail, contrôle des durées maximales et des repos, variables de paie.',
basis: 'legal_obligation',
recipients: ['Encadrement', 'Service RH', 'Cabinet de paie'],
sensitive: false,
},
{
category: 'Absences et compteurs',
objectTypes: ['TimeOff', 'Counter', 'LedgerOperation'],
purpose: 'Gestion des congés, décompte des droits, variables de paie.',
basis: 'contract',
recipients: ['Encadrement', 'Service RH', 'Cabinet de paie'],
sensitive: false,
},
{
category: 'Arrêts de travail et pièces de santé',
objectTypes: ['Document:SICK_NOTE'],
purpose:
'Justification de l’absence et déclaration à la sécurité sociale. Le motif médical n’est pas exploité.',
basis: 'legal_obligation',
recipients: ['Service RH'],
sensitive: true,
notes:
'Catégorie particulière (art. 9). Lecture journalisée ; le motif est masqué en vue encadrement.',
},
{
category: 'Titre de séjour et autorisation de travail',
objectTypes: ['WorkPermit'],
purpose:
'Vérification de l’autorisation de travail et suivi des échéances.',
basis: 'legal_obligation',
recipients: ['Service RH'],
sensitive: false,
notes:
'Le statut migratoire n’est ni affiché ni exploité au-delà de l’échéance.',
},
{
category: 'Comptes, sessions et second facteur',
objectTypes: ['User', 'Session', 'MfaRecoveryCode'],
purpose: 'Authentification, révocation d’accès, sécurité du compte.',
basis: 'legitimate_interest',
recipients: ['Administrateurs de l’instance'],
sensitive: false,
},
{
category: 'Journal d’audit',
objectTypes: ['AuditLog'],
purpose:
'Traçabilité des décisions opposables : contrat, absence, publication, période de paie, permissions.',
basis: 'legal_obligation',
recipients: ['Administrateurs', 'Contrôleurs habilités'],
sensitive: false,
notes: 'Append-only, imposé par un trigger de base.',
},
];
/** Catégories particulières, à traiter avec une vigilance distincte. */
export const SENSITIVE_ENTRIES = DATA_DICTIONARY.filter(
(entry) => entry.sensitive,
);
+217
View File
@@ -0,0 +1,217 @@
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError } from '@/domain/access/authorize';
import { referencedFields, unknownFields } from '@/domain/documents/template';
import { recordAudit } from '@/server/audit';
import { mutate, query } from '@/server/context';
/**
* Modèles de documents — PLAN.md §9, réglage « /reglages/modeles-documents ».
*
* L'écran valide les variables **à l'enregistrement**, pas à la génération. Un
* modèle qui cite `{{salarie.salire}}` doit être refusé par l'administrateur
* qui l'écrit, pas découvert par le gestionnaire qui tente d'éditer une
* attestation devant le salarié qui attend.
*/
export interface TemplateRow {
id: string;
name: string;
bodyHtml: string;
fields: string[];
archivedAt: Date | null;
createdAt: Date;
/** Pièces déjà générées depuis ce modèle. */
documentCount: number;
}
export async function listTemplates(
includeArchived = false,
): Promise<TemplateRow[]> {
return query('settings.access', async (db) => {
const templates = await db.documentTemplate.findMany({
where: includeArchived ? {} : { archivedAt: null },
orderBy: { name: 'asc' },
});
const counts = await db.document.groupBy({
by: ['templateId'],
_count: { _all: true },
});
const byId = new Map(
counts.map((row) => [row.templateId, row._count._all] as const),
);
return templates.map((template) => ({
id: template.id,
name: template.name,
bodyHtml: template.bodyHtml,
fields: Array.isArray(template.availableFields)
? (template.availableFields as string[])
: [],
archivedAt: template.archivedAt,
createdAt: template.createdAt,
documentCount: byId.get(template.id) ?? 0,
}));
});
}
export interface ActionState {
error?: string;
ok?: boolean;
}
class DuplicateTemplate extends Error {}
const templateInput = z.object({
name: z.string().trim().min(1, 'Nom requis').max(160),
bodyHtml: z.string().trim().min(1, 'Corps requis').max(50_000),
});
/** Refuse un corps citant une variable que l'application ne sait pas résoudre. */
function rejectUnknownFields(bodyHtml: string): string | null {
const unknown = unknownFields(bodyHtml);
if (unknown.length === 0) return null;
return `Variables inconnues : ${unknown.join(', ')}. Utilisez la liste ci-contre.`;
}
export async function createTemplateAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = templateInput.safeParse({
name: formData.get('name'),
bodyHtml: formData.get('bodyHtml'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const rejected = rejectUnknownFields(parsed.data.bodyHtml);
if (rejected) return { error: rejected };
try {
await mutate('settings.templates.manage', async (db, actor) => {
const existing = await db.documentTemplate.findFirst({
where: { name: parsed.data.name },
});
if (existing) throw new DuplicateTemplate();
const created = await db.documentTemplate.create({
data: {
name: parsed.data.name,
bodyHtml: parsed.data.bodyHtml,
// Les variables citées sont figées à l'enregistrement : elles
// documentent ce que le modèle exige du dossier, et servent à
// prévenir avant génération plutôt qu'à échouer pendant.
availableFields: referencedFields(parsed.data.bodyHtml) as never,
} as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'document_template.create',
entityType: 'DocumentTemplate',
entityId: created.id,
after: { name: created.name },
});
});
} catch (error) {
if (error instanceof DuplicateTemplate) {
return { error: 'Un modèle porte déjà ce nom.' };
}
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de gérer les modèles." };
}
throw error;
}
revalidatePath('/reglages/modeles-documents');
return { ok: true };
}
const updateInput = templateInput.extend({ id: z.string().min(1) });
export async function updateTemplateAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = updateInput.safeParse({
id: formData.get('id'),
name: formData.get('name'),
bodyHtml: formData.get('bodyHtml'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const rejected = rejectUnknownFields(parsed.data.bodyHtml);
if (rejected) return { error: rejected };
try {
await mutate('settings.templates.manage', async (db, actor) => {
const before = await db.documentTemplate.findUnique({
where: { id: parsed.data.id },
});
if (!before) throw new AuthorizationError('settings.templates.manage');
await db.documentTemplate.update({
where: { id: before.id },
data: {
name: parsed.data.name,
bodyHtml: parsed.data.bodyHtml,
availableFields: referencedFields(parsed.data.bodyHtml) as never,
},
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'document_template.update',
entityType: 'DocumentTemplate',
entityId: before.id,
before: { name: before.name },
after: { name: parsed.data.name },
});
});
} catch (error) {
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de gérer les modèles." };
}
throw error;
}
revalidatePath('/reglages/modeles-documents');
return { ok: true };
}
export async function archiveTemplateAction(formData: FormData): Promise<void> {
const id = String(formData.get('id') ?? '');
const restore = formData.get('restore') === '1';
if (!id) return;
await mutate('settings.templates.manage', async (db, actor) => {
const before = await db.documentTemplate.findUnique({ where: { id } });
if (!before) return;
// Archiver, pas supprimer : une pièce déjà remise garde son `templateId`,
// et perdre le modèle rendrait inexplicable la forme d'un acte signé.
const archivedAt = restore ? null : new Date();
await db.documentTemplate.update({ where: { id }, data: { archivedAt } });
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: restore
? 'document_template.restore'
: 'document_template.archive',
entityType: 'DocumentTemplate',
entityId: id,
before: { archivedAt: before.archivedAt },
after: { archivedAt: archivedAt?.toISOString() ?? null },
});
});
revalidatePath('/reglages/modeles-documents');
}
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import {
MissingTemplateValue,
referencedFields,
renderTemplate,
TEMPLATE_FIELD_KEYS,
unknownFields,
} from '@/domain/documents/template';
describe('referencedFields', () => {
it('relève les variables citées, sans doublon', () => {
const body = '<p>{{salarie.nom}} — {{salarie.nom}} {{contrat.debut}}</p>';
expect(referencedFields(body)).toEqual(['salarie.nom', 'contrat.debut']);
});
it('tolère les espaces dans les accolades', () => {
expect(referencedFields('{{ salarie.nom }}')).toEqual(['salarie.nom']);
});
});
describe('unknownFields', () => {
it('signale une variable que l’application ne sait pas résoudre', () => {
// Le cas qui compte : la faute de frappe. Sans ce contrôle, le modèle
// s'enregistre et échoue au moment de générer la pièce, devant le salarié.
expect(unknownFields('{{salarie.salire}}')).toEqual(['salarie.salire']);
});
it('accepte toutes les variables du catalogue', () => {
const body = TEMPLATE_FIELD_KEYS.map((key) => `{{${key}}}`).join(' ');
expect(unknownFields(body)).toEqual([]);
});
});
describe('renderTemplate', () => {
it('remplace les variables par leur valeur', () => {
const out = renderTemplate('<p>{{salarie.nom}}</p>', {
'salarie.nom': 'Ferrand',
});
expect(out).toBe('<p>Ferrand</p>');
});
it('échappe la valeur, jamais le gabarit', () => {
// Le corps est rédigé par un administrateur et reste de l'HTML ; la valeur
// vient du dossier et pourrait porter un chevron.
const out = renderTemplate('<p><strong>{{salarie.nom}}</strong></p>', {
'salarie.nom': '<script>alert(1)</script>',
});
expect(out).toBe(
'<p><strong>&lt;script&gt;alert(1)&lt;/script&gt;</strong></p>',
);
});
it('refuse de rendre une variable sans valeur', () => {
// Une attestation trouée est un document faux, pas un document incomplet :
// le blanc se remarque une fois sur deux, et la pièce part signée.
expect(() =>
renderTemplate('{{salarie.nom}} {{contrat.debut}}', {
'salarie.nom': 'Ferrand',
}),
).toThrow(MissingTemplateValue);
});
it('traite la chaîne vide comme une valeur manquante', () => {
expect(() =>
renderTemplate('{{salarie.nom}}', { 'salarie.nom': '' }),
).toThrow(MissingTemplateValue);
});
it('nomme les variables manquantes dans l’erreur', () => {
try {
renderTemplate('{{salarie.nom}} {{contrat.debut}}', {});
expect.unreachable('le rendu aurait dû échouer');
} catch (error) {
expect(error).toBeInstanceOf(MissingTemplateValue);
expect((error as MissingTemplateValue).keys).toEqual([
'salarie.nom',
'contrat.debut',
]);
}
});
});