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
380 lines
12 KiB
Plaintext
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])
|
|
}
|