Files
parenthese/backend/prisma/schema.prisma
T
IliesAllaliandClaude Opus 5.5 f65273e95a Personnes identifiées sur un souvenir
Sur une photo ou une vidéo, on ajoute les personnes de l'arbre qui y figurent et, sur une photo,
on touche leur visage pour poser un point. Le souvenir apparaît aussi sur la fiche de chaque
personne identifiée (« Avec Odette »), un prénom ouvre sa fiche et recentre l'arbre.

L'administrateur enregistre directement. La famille propose, l'administrateur relit dans
« Propositions à relire ». Suppression d'une personne ou d'un souvenir : identifications retirées.

Table media_person_tags (migration additive), route PUT /trees/:id/media/:mediaId/people.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:32:22 +02:00

494 lines
16 KiB
Plaintext

generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
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[]
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?
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[]
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")
}
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)
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?
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?
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")
}