diff --git a/prisma/migrations/20260811120000_document_templates/migration.sql b/prisma/migrations/20260811120000_document_templates/migration.sql new file mode 100644 index 0000000..f0e9641 --- /dev/null +++ b/prisma/migrations/20260811120000_document_templates/migration.sql @@ -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()); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 970e028..bc4076b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) diff --git a/src/app/(app)/reglages/modeles-documents/TemplateForms.tsx b/src/app/(app)/reglages/modeles-documents/TemplateForms.tsx new file mode 100644 index 0000000..de1cb12 --- /dev/null +++ b/src/app/(app)/reglages/modeles-documents/TemplateForms.tsx @@ -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 ( +