Files
planflow/prisma/schema.prisma
T
Claude 92667b16b9 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
2026-08-07 23:04:26 +00:00

380 lines
12 KiB
Plaintext

// PlanFlow — schéma de données. Voir PLAN.md §4.
//
// Prisma 7 : l'URL de connexion vit dans prisma.config.ts, plus ici.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
// ============================================================================
// Tenancy — PLAN.md §4.1
// ============================================================================
model Account {
id String @id @default(cuid())
name String
siren String?
apeCode String?
collectiveAgreementId String?
/// Surcharges d'accord d'entreprise (PLAN.md §6.3). Vide aujourd'hui :
/// l'organisation auditée n'a pas d'accord d'entreprise.
agreementOverrides Json?
createdAt DateTime @default(now())
locations Location[]
memberships Membership[]
roles Role[]
auditLogs AuditLog[]
retention RetentionPolicy[]
featureFlags FeatureFlag[]
jobTitles JobTitle[]
labels Label[]
absenceTypes AbsenceType[]
legalConfig LegalConfigEntry[]
}
model Location {
id String @id @default(cuid())
accountId String
name String
siret String?
timezone String @default("Europe/Paris")
/// Taux moyen de cotisations patronales, en pourcentage.
employerContributionRate Decimal @default(0) @db.Decimal(5, 2)
silaeDossier String?
archivedAt DateTime?
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
teams Team[]
scopes MembershipScope[]
@@index([accountId])
}
model Team {
id String @id @default(cuid())
accountId String
locationId String
name String
position Int @default(0)
archivedAt DateTime?
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
scopes MembershipScope[]
@@index([accountId])
@@index([locationId])
}
// ============================================================================
// Identité
// ============================================================================
model User {
id String @id @default(cuid())
email String @unique
passwordHash String?
firstName String
lastName String
locale String @default("fr")
/// Deuxième facteur, exigé des rôles administrateur et RH (matrice n° 15).
mfaSecretEnc Bytes?
mfaEnrolledAt DateTime?
lastSignInAt DateTime?
failedAttempts Int @default(0)
lockedUntil DateTime?
createdAt DateTime @default(now())
memberships Membership[]
sessions Session[]
}
/// Lien User ↔ Account. Porte le salarié : `userId` est nullable, car tous les
/// salariés ne se connectent pas — ils doivent rester plannifiables et
/// exportables sans compte (PLAN.md §4.1).
model Membership {
id String @id @default(cuid())
accountId String
userId String?
roleId String
lineManagerId String?
employeeNumber String
silaeMatricule String?
status MembershipStatus @default(INVITED)
invitedAt DateTime?
archivedAt DateTime?
createdAt DateTime @default(now())
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
role Role @relation(fields: [roleId], references: [id])
lineManager Membership? @relation("LineManager", fields: [lineManagerId], references: [id], onDelete: SetNull)
reports Membership[] @relation("LineManager")
scopes MembershipScope[]
invitations Invitation[]
auditLogs AuditLog[]
@@unique([accountId, employeeNumber])
@@index([accountId])
@@index([userId])
}
enum MembershipStatus {
INVITED
ACTIVE
ARCHIVED
}
/// Périmètre d'un membership. `allLocations` évite d'énumérer 34 établissements
/// pour un directeur — et de rater le 35ᵉ le jour de son ouverture.
model MembershipScope {
id String @id @default(cuid())
accountId String
membershipId String
allLocations Boolean @default(false)
locationId String?
teamId String?
membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade)
location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade)
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@index([accountId])
@@index([membershipId])
}
/// Session serveur. En base plutôt qu'en JWT : la matrice n° 23 impose de
/// pouvoir révoquer une session, ce qu'un jeton signé ne permet pas.
model Session {
id String @id @default(cuid())
userId String
tokenHash String @unique
expiresAt DateTime
createdAt DateTime @default(now())
lastSeenAt DateTime @default(now())
ip String?
userAgent String?
revokedAt DateTime?
revokedBy String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
}
model Invitation {
id String @id @default(cuid())
accountId String
membershipId String
tokenHash String @unique
email String
expiresAt DateTime
acceptedAt DateTime?
createdBy String
createdAt DateTime @default(now())
membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade)
@@index([accountId])
}
// ============================================================================
// Autorisation — PLAN.md §4.2 et §5
// ============================================================================
/// Rôle configurable par le client. Le code référence `key`, jamais `name` :
/// renommer « Manager » en « Responsable » ne doit rien casser.
model Role {
id String @id @default(cuid())
accountId String
key String
name String
isSystem Boolean @default(false)
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
permissions RolePermission[]
memberships Membership[]
@@unique([accountId, key])
@@index([accountId])
}
/// Capacité stable, nommée `ressource.action.qualificatif`. Référentiel global :
/// les capacités sont définies par le produit, seule leur attribution varie.
model Permission {
id String @id @default(cuid())
code String @unique
category String
label String
roles RolePermission[]
}
model RolePermission {
roleId String
permissionId String
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
@@id([roleId, permissionId])
}
// ============================================================================
// Traçabilité — PLAN.md §3.5
// ============================================================================
/// Journal d'audit. Append-only, imposé par un trigger PostgreSQL : une piste
/// qu'on peut réécrire ne prouve rien.
model AuditLog {
id String @id @default(cuid())
accountId String
actorMembershipId String?
action String
entityType String
entityId String
before Json?
after Json?
reason String?
ip String?
userAgent String?
occurredAt DateTime @default(now())
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
actor Membership? @relation(fields: [actorMembershipId], references: [id], onDelete: SetNull)
@@index([accountId, entityType, entityId])
@@index([accountId, occurredAt])
}
/// Durées de conservation par objet — PLAN.md §12.5.
/// « 5 ans partout » est explicitement proscrit : chaque durée porte son point
/// de départ et sa justification.
model RetentionPolicy {
id String @id @default(cuid())
accountId String
objectType String
durationMonths Int
startPoint String
justification String
legalHold Boolean @default(false)
effectiveFrom DateTime @db.Date
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
@@unique([accountId, objectType, effectiveFrom])
@@index([accountId])
}
/// Verrou de conformité — PLAN.md §12.4.
/// Une fonctionnalité de contrôle reste inactive tant que la notice au salarié
/// et l'avis du CSE ne sont pas enregistrés.
model FeatureFlag {
id String @id @default(cuid())
accountId String
key String
enabled Boolean @default(false)
noticeDocumentRef String?
noticeDeliveredAt DateTime?
cseOpinionAt DateTime?
activatedAt DateTime?
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
@@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])
}