// 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[] members TeamMember[] @@index([accountId]) @@index([locationId]) } /// Rattachement d'un salarié à une équipe. /// /// Distinct de `MembershipScope`, qui dit ce qu'un manager a le droit de voir. /// Celui-ci dit où un salarié **apparaît dans la grille** — y compris une /// semaine où il n'a aucun créneau, ce qui est justement l'état de départ /// quand on construit un planning. model TeamMember { id String @id @default(cuid()) accountId String teamId String membershipId String /// Équipe d'affectation principale ; les autres sont des renforts. isPrimary Boolean @default(true) position Int @default(0) team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade) @@unique([teamId, membershipId]) @@index([accountId]) @@index([membershipId]) } // ============================================================================ // 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[] profile EmployeeProfile? contracts UserContract[] workPermits WorkPermit[] teams TeamMember[] @@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 de AB- à 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]) } // ============================================================================ // Dossier salarié et contrats — PLAN.md §4.3, WP-03 // ============================================================================ /// Dossier personnel. NIR, IBAN et BIC sont chiffrés au repos (PLAN.md §3.6) : /// une sauvegarde volée ne doit pas suffire à les lire. model EmployeeProfile { membershipId String @id accountId String /// Nom d'état civil, porté par le dossier et non par le compte utilisateur : /// la plupart des salariés n'ont pas de compte, et le registre unique du /// personnel exige leur nom. firstName String lastName String birthDate DateTime? @db.Date birthPlace String? nationality String? addressLine1 String? postalCode String? city String? country String? phone String? personalEmail String? socialSecurityNumberEnc Bytes? ibanEnc Bytes? bicEnc Bytes? emergencyContactName String? emergencyContactPhone String? updatedAt DateTime @updatedAt membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade) @@index([accountId]) } /// Titre de séjour et son échéance. Le tableau de bord RH surveille les /// expirations : un titre périmé interdit l'emploi. model WorkPermit { id String @id @default(cuid()) accountId String membershipId String permitType String reference String issuedAt DateTime? @db.Date expiresAt DateTime @db.Date membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade) @@index([accountId]) @@index([membershipId]) } model UserContract { id String @id @default(cuid()) accountId String membershipId String locationId String contractType ContractType startDate DateTime @db.Date endDate DateTime? @db.Date trialEndDate DateTime? @db.Date /// Organisation du temps. Le forfait jours exclut le décompte horaire (§6.4). workTimeArrangement WorkTimeArrangement @default(HOURLY) weeklyHours Decimal @default(35) @db.Decimal(5, 2) forfaitDaysPerYear Decimal? @db.Decimal(5, 1) /// Convention individuelle écrite. Sans elle le forfait est inopposable : /// l'activation est refusée. forfaitAgreementRef String? forfaitAgreedAt DateTime? isModulated Boolean @default(false) hourlyRate Decimal? @db.Decimal(10, 4) monthlySalary Decimal? @db.Decimal(10, 2) jobTitleId String? classification String? coefficient String? status ContractStatus @default(ACTIVE) endReason String? version Int @default(0) createdAt DateTime @default(now()) membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade) amendments Amendment[] @@index([accountId]) @@index([membershipId]) } /// Liste exhaustive relevée dans le filtre « Tous les types de contrats ». enum ContractType { APPRENTISSAGE CDD CDI DIRIGEANT_ASSIMILE_SALARIE DIRIGEANT_NON_SALARIE EXTRA INTERIM STAGIAIRE SAISONNIER } enum ContractStatus { DRAFT ACTIVE ENDED } enum WorkTimeArrangement { HOURLY FORFAIT_JOURS } /// Avenant. Conserve l'historique plutôt que d'écraser le contrat : un contrôle /// demande l'état du contrat au moment des faits, pas son état actuel. model Amendment { id String @id @default(cuid()) accountId String userContractId String effectiveDate DateTime @db.Date changes Json reason String? createdBy String createdAt DateTime @default(now()) contract UserContract @relation(fields: [userContractId], references: [id], onDelete: Cascade) @@index([accountId]) @@index([userContractId]) } /// Décompte des jours d'un salarié au forfait. Conservation 3 ans (§12.5). model ForfaitDayEntry { id String @id @default(cuid()) accountId String userContractId String localDate DateTime @db.Date quantity Decimal @db.Decimal(2, 1) createdBy String createdAt DateTime @default(now()) @@unique([userContractId, localDate]) @@index([accountId]) } /// Entretien annuel de charge, obligatoire au forfait jours (matrice n° 7). model WorkloadReview { id String @id @default(cuid()) accountId String userContractId String heldAt DateTime @db.Date summary String actions String? @@index([accountId]) @@index([userContractId]) } // ============================================================================ // Planning — PLAN.md §4.4, WP-04 // ============================================================================ /// Semaine de planning d'une équipe. /// /// La publication est **par équipe** : l'audit montre un bouton « Dépublier » /// sur chaque section de la grille, pas un seul pour l'établissement. model WeeklySchedule { id String @id @default(cuid()) accountId String locationId String teamId String isoYear Int isoWeek Int status ScheduleStatus @default(DRAFT) publishedAt DateTime? publishedBy String? /// Verrou optimiste : la grille s'édite à plusieurs (PLAN.md §3.4). version Int @default(0) shifts Shift[] rests Rest[] dailyNotes DailyNote[] violations ComplianceViolation[] @@unique([teamId, isoYear, isoWeek]) @@index([accountId]) @@index([locationId, isoYear, isoWeek]) } enum ScheduleStatus { DRAFT VALIDATED PUBLISHED } model Shift { id String @id @default(cuid()) accountId String weeklyScheduleId String /// Null = besoin non couvert, affiché sur sa propre ligne. membershipId String? /// Regroupement dans la grille uniquement. Les durées se calculent depuis /// les instants — la nuit du changement d'heure, 22 h–06 h dure 7 h ou 9 h. localDate DateTime @db.Date startAt DateTime @db.Timestamptz endAt DateTime @db.Timestamptz breakMinutes Int @default(0) actualStartAt DateTime? @db.Timestamptz actualEndAt DateTime? @db.Timestamptz actualBreakMinutes Int? labelId String? mealCount Int @default(0) isValidated Boolean @default(false) validatedAt DateTime? validatedBy String? note String? version Int @default(0) schedule WeeklySchedule @relation(fields: [weeklyScheduleId], references: [id], onDelete: Cascade) @@index([accountId]) @@index([weeklyScheduleId, localDate]) @@index([membershipId, localDate]) } /// Repos de journée — hebdomadaire ou compensateur. Les pauses *dans* un /// créneau sont `Shift.breakMinutes`. model Rest { id String @id @default(cuid()) accountId String weeklyScheduleId String membershipId String localDate DateTime @db.Date restType RestType minutes Int? schedule WeeklySchedule @relation(fields: [weeklyScheduleId], references: [id], onDelete: Cascade) @@index([accountId]) } enum RestType { WEEKLY_REST COMPENSATORY_REST } /// Ligne « Notes et événements » de la grille. model DailyNote { id String @id @default(cuid()) accountId String weeklyScheduleId String localDate DateTime @db.Date content String schedule WeeklySchedule @relation(fields: [weeklyScheduleId], references: [id], onDelete: Cascade) @@index([accountId]) } // ============================================================================ // Conformité conventionnelle — PLAN.md §6 et §12.2, WP-05 // ============================================================================ /// Convention collective, **effectif-datée**. /// /// Les versions coexistent et ne se remplacent pas : la matrice n° 1 impose de /// 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. model CollectiveAgreement { id String @id @default(cuid()) accountId String idcc String name String /// Jeu de paramètres validé par `agreementParametersSchema`. parameters Json version Int effectiveFrom DateTime @db.Date /// Origine du texte : URL Legifrance, référence de PDF, note d'entretien. source String? approvedBy String? approvedAt DateTime? createdAt DateTime @default(now()) @@unique([accountId, idcc, version]) @@index([accountId, idcc, effectiveFrom]) @@index([accountId]) } /// Constat produit par le moteur de règles. /// /// Reconstruit à chaque évaluation d'une semaine : ce n'est pas un journal mais /// l'état courant. L'acquittement, lui, survit — c'est la décision d'un manager /// de publier malgré une alerte, et elle doit rester opposable. model ComplianceViolation { id String @id @default(cuid()) accountId String weeklyScheduleId String membershipId String? ruleCode String severity Severity localDate DateTime? @db.Date message String context Json /// Créneaux mis en cause, pour poser le badge sur la bonne cellule. shiftIds String[] /// Version de convention appliquée : sans elle, un constat ancien devient /// inexplicable dès que les paramètres changent. agreementId String? detectedAt DateTime @default(now()) acknowledgedBy String? acknowledgedAt DateTime? acknowledgementReason String? schedule WeeklySchedule @relation(fields: [weeklyScheduleId], references: [id], onDelete: Cascade) @@index([accountId]) @@index([weeklyScheduleId]) @@index([membershipId, localDate]) } enum Severity { INFO WARNING BLOCKING } /// Jour férié d'un établissement. /// /// Par établissement et non par compte : l'Alsace-Moselle a deux jours fériés /// de plus, et une chaîne peut ouvrir un magasin de l'autre côté de la limite. model Holiday { id String @id @default(cuid()) accountId String locationId String localDate DateTime @db.Date name String /// Chômé par décision de l'employeur, au titre des jours garantis. isPaidOff Boolean @default(false) @@unique([locationId, localDate]) @@index([accountId]) } /// Dimanche du maire autorisé — L3132-26. /// /// La liste est arrêtée avant le 31 décembre pour l'année suivante. La stocker /// rend le quota opposable au lieu de purement indicatif. model AuthorisedSunday { id String @id @default(cuid()) accountId String locationId String localDate DateTime @db.Date /// Arrêté municipal ou décision de référence. reference String? @@unique([locationId, localDate]) @@index([accountId]) }