WP-02: locations, teams and the legal configuration register

Adds the referential models, the first database-backed settings
screens, and the register the compliance matrix requires before any
parameter is enforceable.

The register is the point of the lot. The matrix is explicit that
copying another product's configuration is not enough — each parameter
must carry its value, source, effective date, population and an
approver. Approval records the session's actor, never a form field: a
signature you can type yourself is worth nothing. The screen names the
domains that have no approved parameter yet, so the gap is visible
rather than assumed closed.

Two bugs of the same family, both now structurally impossible:

- The Prisma scoping extension read a hand-written list of models
  carrying accountId. The four models added here were missing from it,
  so writes failed with an opaque Prisma error — and a read would have
  silently returned every account's rows. The list is now derived from
  the schema itself.
- The RLS policies were likewise per-table. A new integration test
  fails if any table with an accountId column lacks forced RLS and both
  policies, which is the failure mode that hides best: nobody writes a
  wrong rule, someone forgets to write one.

An end-to-end test signs in as a manager and confirms the settings
screens refuse to render — the sidebar hiding them is a convenience,
the server check is the control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
Claude committed 2026-08-07 23:04:26 +00:00
1 parent 7ab036379a
commit 92667b16b9
20 files changed
+1475 -17

No files matched your search

@@ -0,0 +1,95 @@
-- CreateTable
CREATE TABLE "JobTitle" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"archivedAt" TIMESTAMP(3),
CONSTRAINT "JobTitle_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Label" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"paletteKey" TEXT NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"archivedAt" TIMESTAMP(3),
CONSTRAINT "Label_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AbsenceType" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"colorKey" TEXT NOT NULL,
"isPaid" BOOLEAN NOT NULL DEFAULT true,
"countsAsWorkTime" BOOLEAN NOT NULL DEFAULT false,
"affectsPaidLeaveAccrual" BOOLEAN NOT NULL DEFAULT true,
"isSocialSecurity" BOOLEAN NOT NULL DEFAULT false,
"requiresJustification" BOOLEAN NOT NULL DEFAULT false,
"minNoticeDays" INTEGER,
"silaeCode" TEXT,
"archivedAt" TIMESTAMP(3),
CONSTRAINT "AbsenceType_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LegalConfigEntry" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"domain" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"source" TEXT NOT NULL,
"effectiveFrom" DATE NOT NULL,
"population" TEXT NOT NULL,
"approvedBy" TEXT,
"approvedAt" TIMESTAMP(3),
"attachmentRef" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LegalConfigEntry_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "JobTitle_accountId_idx" ON "JobTitle"("accountId");
-- CreateIndex
CREATE UNIQUE INDEX "JobTitle_accountId_name_key" ON "JobTitle"("accountId", "name");
-- CreateIndex
CREATE INDEX "Label_accountId_idx" ON "Label"("accountId");
-- CreateIndex
CREATE UNIQUE INDEX "Label_accountId_code_key" ON "Label"("accountId", "code");
-- CreateIndex
CREATE INDEX "AbsenceType_accountId_idx" ON "AbsenceType"("accountId");
-- CreateIndex
CREATE UNIQUE INDEX "AbsenceType_accountId_code_key" ON "AbsenceType"("accountId", "code");
-- CreateIndex
CREATE INDEX "LegalConfigEntry_accountId_idx" ON "LegalConfigEntry"("accountId");
-- CreateIndex
CREATE UNIQUE INDEX "LegalConfigEntry_accountId_domain_key_effectiveFrom_key" ON "LegalConfigEntry"("accountId", "domain", "key", "effectiveFrom");
-- AddForeignKey
ALTER TABLE "JobTitle" ADD CONSTRAINT "JobTitle_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Label" ADD CONSTRAINT "Label_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AbsenceType" ADD CONSTRAINT "AbsenceType_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LegalConfigEntry" ADD CONSTRAINT "LegalConfigEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,25 @@
-- Étend l'isolation aux tables ajoutées par WP-02.
--
-- Une table portant `accountId` sans politique associée est un trou : elle
-- répond à tout le monde. C'est le mode de défaillance le plus discret de la
-- RLS — on n'ajoute pas une règle, on oublie d'en ajouter une.
DO $$
DECLARE
t text;
BEGIN
FOREACH t IN ARRAY ARRAY['JobTitle', 'Label', 'AbsenceType', 'LegalConfigEntry']
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', t);
EXECUTE format(
'CREATE POLICY tenant_isolation ON %I USING ("accountId" = planflow_current_account())',
t
);
EXECUTE format(
'CREATE POLICY tenant_insert ON %I FOR INSERT WITH CHECK ("accountId" = planflow_current_account())',
t
);
END LOOP;
END;
$$;
+91
View File
@@ -31,6 +31,10 @@ model Account {
auditLogs AuditLog[]
retention RetentionPolicy[]
featureFlags FeatureFlag[]
jobTitles JobTitle[]
labels Label[]
absenceTypes AbsenceType[]
legalConfig LegalConfigEntry[]
}
model Location {
@@ -286,3 +290,90 @@ model FeatureFlag {
@@unique([accountId, key])
}
// ============================================================================
// Référentiels — PLAN.md §4.3 et WP-02
// ============================================================================
/// Intitulé d'emploi. Distinct du poste de planning : l'emploi qualifie le
/// contrat, le poste qualifie une occupation dans la journée.
model JobTitle {
id String @id @default(cuid())
accountId String
name String
archivedAt DateTime?
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
@@unique([accountId, name])
@@index([accountId])
}
/// Étiquette de planning — le « poste » coloré de la grille.
model Label {
id String @id @default(cuid())
accountId String
code String
name String
/// Code de la palette catégorielle (voir src/lib/design/postes.ts).
paletteKey String
position Int @default(0)
archivedAt DateTime?
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
@@unique([accountId, code])
@@index([accountId])
}
/// Type d'absence. `isSocialSecurity` isole maladie, maternité et AT : le
/// journal des absences les filtre séparément, et ce sont des données de santé.
model AbsenceType {
id String @id @default(cuid())
accountId String
code String
name String
colorKey String
isPaid Boolean @default(true)
countsAsWorkTime Boolean @default(false)
affectsPaidLeaveAccrual Boolean @default(true)
isSocialSecurity Boolean @default(false)
requiresJustification Boolean @default(false)
minNoticeDays Int?
/// Partie <code> de AB-<code> à l'export Silae. Null tant qu'elle n'a pas
/// été fournie par le dossier du client (PLAN.md §8.2).
silaeCode String?
archivedAt DateTime?
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
@@unique([accountId, code])
@@index([accountId])
}
/// Registre de paramétrage juridique — PLAN.md §12.7.
///
/// La matrice impose de faire **signer** chaque paramètre avant migration, avec
/// sa valeur, sa source, sa date d'effet, sa population et son approbateur. Un
/// paramètre sans cette traçabilité n'est pas opposable : c'est ce registre qui
/// distingue une configuration justifiée d'une valeur recopiée d'un autre
/// logiciel.
model LegalConfigEntry {
id String @id @default(cuid())
accountId String
domain String
key String
value String
source String
effectiveFrom DateTime @db.Date
population String
approvedBy String?
approvedAt DateTime?
attachmentRef String?
createdAt DateTime @default(now())
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
@@unique([accountId, domain, key, effectiveFrom])
@@index([accountId])
}