// Client généré dans src/generated (ignoré par git), compilé avec le reste dans dist : rien à copier depuis // node_modules dans les images. L'adresse de la base vient de prisma.config.ts. generator client { provider = "prisma-client" output = "../src/generated/prisma" moduleFormat = "esm" importFileExtension = "js" } datasource db { provider = "postgresql" } enum MembershipRole { owner admin member } enum ContributionPolicy { direct pending } enum VisitorPolicy { read_only } enum ContributionSessionStatus { pending approved rejected } enum ContributionActorMode { user tree_access } enum ContributionEntityType { person union parent_child_link annotation media life_event // Personnes identifiées sur un souvenir (remplace la liste entière) media_people } // Souvenir envoyé par la famille : en attente jusqu'à la relecture du propriétaire enum MediaStatus { approved pending rejected } enum AnnotationType { drawing sticker text photo } enum ContributionAction { create update delete reorder } enum ConflictState { none needs_review } enum ContributionDecision { approved rejected } enum SharedAccessRole { visitor contributor } model User { id String @id @default(cuid()) email String @unique firstName String? // Vide pour un compte né d'une connexion OIDC : il n'a pas de mot de passe Parenthèse passwordHash String? lastOpenedTreeId String? lastOpenedAccessMode String? lastOpenedRole String? lastOpenedAt DateTime? createdAt DateTime @default(now()) ownedTrees Tree[] @relation("TreeOwner") memberships TreeMembership[] sharedTreeAccesses UserTreeAccess[] submittedContributionSessions ContributionSession[] @relation("ContributionSubmittedBy") reviewedContributionSessions ContributionSession[] @relation("ContributionReviewedBy") treeVisits TreeVisit[] identities UserIdentity[] sentTreeInvitations TreeInvitation[] @relation("TreeInvitationSentBy") lastOpenedTree Tree? @relation("UserLastOpenedTree", fields: [lastOpenedTreeId], references: [id], onDelete: SetNull) @@index([lastOpenedAt]) @@map("users") } // Compte d'un fournisseur OIDC relié à un compte Parenthèse : l'émetteur et le « sub » de l'ID token, jamais l'email, // qui peut changer chez le fournisseur. model UserIdentity { id String @id @default(cuid()) userId String issuer String subject String createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([issuer, subject]) @@index([userId]) @@map("user_identities") } model Tree { id String @id @default(cuid()) ownerUserId String slug String @unique name String description String? // Icône choisie pour l'arbre (un emoji), à la place de la première lettre dans les menus ; null = la lettre icon String? rootPersonId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? owner User @relation("TreeOwner", fields: [ownerUserId], references: [id], onDelete: Restrict) accessPasswords TreeAccessPasswords? sharePassword TreeSharePassword? memberships TreeMembership[] sharedUserAccesses UserTreeAccess[] settings TreeSettings? immichConfig TreeImmichConfig? persons Person[] unions Union[] parentChildLinks ParentChildLink[] mediaItems MediaItem[] mediaPersonTags MediaPersonTag[] contributionSessions ContributionSession[] auditLogs AuditLog[] annotations Annotation[] visits TreeVisit[] lifeEvents LifeEvent[] invitations TreeInvitation[] annotationPhotoUploads AnnotationPhotoUpload[] lastOpenedByUsers User[] @relation("UserLastOpenedTree") @@index([ownerUserId]) @@map("trees") } model TreeAccessPasswords { treeId String @id visitorHash String contributorHash String updatedAt DateTime @updatedAt tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade) @@map("tree_access_passwords") } // Mot de passe de partage en clair chiffré, pour que le propriétaire voie toujours celui en vigueur. // Table à part : écrire dans tree_access_passwords changerait updatedAt, qui sert de version aux accès ouverts. model TreeSharePassword { treeId String @id passwordEnc String setAt DateTime @default(now()) tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade) @@map("tree_share_passwords") } model TreeMembership { id String @id @default(cuid()) treeId String userId String role MembershipRole contributionModeOverride ContributionPolicy? createdAt DateTime @default(now()) tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([treeId, userId]) @@index([userId]) @@map("tree_memberships") } // Invitation à co-gérer un arbre (issue #145) : le propriétaire donne une adresse, Parenthèse un lien à transmettre. // Seule l'empreinte du jeton est gardée ; « Copier le lien » en tire un nouveau. Acceptée, elle donne le rôle admin // (co-gestionnaire). Le lien sert une fois, jusqu'à expiresAt. model TreeInvitation { id String @id @default(cuid()) treeId String email String tokenHash String @unique invitedByUserId String? createdAt DateTime @default(now()) expiresAt DateTime acceptedAt DateTime? acceptedUserId String? revokedAt DateTime? tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade) invitedBy User? @relation("TreeInvitationSentBy", fields: [invitedByUserId], references: [id], onDelete: SetNull) @@index([treeId]) @@index([email]) @@map("tree_invitations") } // Sessions fermées (déconnexion, compte supprimé), gardées jusqu'à la fin de leur jeton model RevokedSession { jti String @id expiresAt DateTime @@index([expiresAt]) @@map("revoked_sessions") } // Photo posée sur l'arbre par la famille, en attente de relecture (1.5.1). La ligne vit tant que la photo n'est // ni acceptée (ligne retirée, fichier gardé) ni refusée (fichier et ligne effacés). Un envoi jamais proposé est // effacé après 7 jours, la durée d'un brouillon. Les photos des gestionnaires n'ont pas de ligne. model AnnotationPhotoUpload { id String @id treeId String filePath String createdAt DateTime @default(now()) tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade) @@index([treeId, createdAt]) @@map("annotation_photo_uploads") } model UserTreeAccess { id String @id @default(cuid()) treeId String userId String role SharedAccessRole createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([treeId, userId]) @@index([userId, updatedAt]) @@map("user_tree_accesses") } model TreeSettings { treeId String @id memberContributionPolicy ContributionPolicy @default(pending) visitorPolicy VisitorPolicy @default(read_only) contributorPolicy ContributionPolicy @default(pending) updatedAt DateTime @updatedAt tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade) @@map("tree_settings") } // Connexion Immich propre à chaque arbre (reconnaissance des visages) : l'adresse et la clé de // l'instance sont choisies par ses administrateurs, jamais héritées du serveur. La clé est // chiffrée au repos ; une ligne absente signifie « aucun accès Immich » pour cet arbre. model TreeImmichConfig { treeId String @id apiUrl String apiKeyEnc String updatedAt DateTime @updatedAt tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade) @@map("tree_immich_configs") } model Person { id String @id @default(cuid()) treeId String firstName String lastName String birthName String? birthDate DateTime? deathDate DateTime? // Décédé(e) sans date connue (issue #77) : vrai seulement quand deathDate est vide isDeceased Boolean @default(false) // « À vérifier » : la famille n'est pas sûre de cette personne et le signale (retour d'Anaïs, 23/10) toVerify Boolean @default(false) birthPlace String? // Précision d'une date importée (« vers », « avant », « entre »…) et sa borne de fin pour « entre … et … » birthDatePrecision String? birthDateEnd DateTime? deathDatePrecision String? deathDateEnd DateTime? deathPlace String? nickname String? profession String? region String? nationality String? avatarPath String? avatarMimeType String? // Cadrage du portrait, appliqué à l'affichage sur l'original (jamais de fichier dérivé) : // { "x": 0..1, "y": 0..1, "zoom": 1..4 }, centre du cadrage en proportion de l'image, zoom 1 = image entière // recouvrant le cadre. Null = cadrage par défaut (centré, zoom 1). avatarCrop Json? // Aperçu flou du portrait (BlurHash), montré pendant le chargement. Null : pas encore calculé ; vide : illisible avatarBlurhash String? sex String? notes String? createdBy String? updatedBy String? // Personne Immich associée (reconnaissance des visages) : identifiant côté Immich, ou null. immichPersonId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade) @@index([treeId]) @@map("persons") } model Union { id String @id @default(cuid()) treeId String partner1PersonId String partner2PersonId String? unionType String? startDate DateTime? endDate DateTime? displayOrder Int @default(1) startPlace String? startDatePrecision String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade) @@index([treeId]) @@map("unions") } model ParentChildLink { id String @id @default(cuid()) treeId String parentPersonId String childPersonId String viaUnionId String? parentageType String? displayOrder Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade) @@index([treeId]) @@map("parent_child_links") } model MediaItem { id String @id @default(cuid()) treeId String personId String type String mimeType String filePath String thumbPath String? // Aperçu flou d'une photo (BlurHash), montré pendant le chargement. Null : pas encore calculé ; vide : illisible blurhash String? caption String? source String? sizeBytes Int displayOrder Int @default(1) isFeatured Boolean @default(false) uploadedBy String? status MediaStatus @default(approved) contributionSessionId String? // Moment de l'onglet Vie auquel ce souvenir est rattaché (photo, vidéo, acte en PDF…) lifeEventId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade) @@index([treeId, personId, displayOrder]) @@index([lifeEventId]) @@map("media_items") } // Personne identifiée sur un souvenir. Le souvenir reste à la personne qui le porte (media_items.personId) ; // il apparaît aussi sur la fiche de chaque personne identifiée. x et y : point sur le visage, en proportion // de l'image (0 à 1), facultatifs. model MediaPersonTag { id String @id @default(cuid()) treeId String mediaId String personId String x Float? y Float? createdBy String? createdAt DateTime @default(now()) tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade) @@unique([mediaId, personId]) @@index([treeId]) @@index([treeId, personId]) @@map("media_person_tags") } model ContributionSession { id String @id @default(cuid()) treeId String submittedByUserId String? submittedByLabel String comment String? mode ContributionActorMode status ContributionSessionStatus @default(pending) title String createdAt DateTime @default(now()) reviewedAt DateTime? reviewedBy String? tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade) submittedByUser User? @relation("ContributionSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull) reviewer User? @relation("ContributionReviewedBy", fields: [reviewedBy], references: [id], onDelete: SetNull) changes ContributionChange[] @@index([treeId, status, createdAt]) @@index([submittedByUserId]) @@map("contribution_sessions") } model ContributionChange { id String @id @default(cuid()) sessionId String entityType ContributionEntityType entityId String? action ContributionAction beforeJson Json? afterJson Json? conflictState ConflictState @default(none) decision ContributionDecision? createdAt DateTime @default(now()) session ContributionSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) @@index([sessionId]) @@map("contribution_changes") } model Annotation { id String @id @default(cuid()) treeId String type AnnotationType x Float y Float content String style Json @default("{}") zIndex Int @default(0) createdBy String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade) @@index([treeId]) @@map("annotations") } model AuditLog { id String @id @default(cuid()) treeId String actorType String actorId String? action String entityType String entityId String payloadJson Json? createdAt DateTime @default(now()) tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade) @@index([treeId, createdAt]) @@map("audit_log") } // Journal des visites d'un arbre, lu par ses administrateurs (onglet Visites). // Pas d'adresse IP : visitorKey est une empreinte (arbre + IP + navigateur) qui sert à compter les visiteurs distincts. model TreeVisit { id String @id @default(cuid()) treeId String userId String? accessKind String visitorName String? visitorKey String device String createdAt DateTime @default(now()) tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: SetNull) @@index([treeId, createdAt]) @@index([treeId, visitorKey, createdAt]) @@map("tree_visits") } model WaitlistEntry { id String @id @default(cuid()) email String @unique prenom String? source String @default("direct") createdAt DateTime @default(now()) @@map("waitlist_entries") } // Événements de vie d'une personne (baptême, résidence, métier, études, service militaire, inhumation…) ou // d'un couple (divorce…). Naissance, décès et mariage gardent leur date et leur lieu sur la personne ou // l'union ; leur ligne ici ne porte que les sources et les notes (type birth, death, marriage). model LifeEvent { id String @id @default(cuid()) treeId String personId String? unionId String? type String label String? date DateTime? dateEnd DateTime? datePrecision String? dateText String? place String? description String? notes String? sources String? // Ancien rattachement d'une seule photo (17/10), remplacé par media_items.lifeEventId mediaId String? // Moment proposé par la famille : « pending » jusqu'à la relecture de l'administrateur status String @default("approved") contributionSessionId String? displayOrder Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade) @@index([treeId]) @@index([treeId, personId]) @@index([treeId, unionId]) @@map("life_events") }