Files
parenthese/backend/prisma/schema.prisma
T
Mistral Vibe a3386824d3 Immich par arbre : connexion propre à chaque arbre, instances autorisées par l'opérateur
- Nouvelle table tree_immich_configs (adresse + clé d'API chiffrée AES-256-GCM,
  clé dérivée de JWT_SECRET) et routes GET/PUT/DELETE /trees/:id/immich/config
  réservées aux administrateurs de l'arbre ; journal d'audit sans secret
  (adresse et « clé changée » uniquement).
- Fin des variables d'env IMMICH_ENABLED / IMMICH_API_URL / IMMICH_API_KEY :
  l'adresse se choisit parmi IMMICH_ALLOWED_API_URLS, la liste établie par
  l'opérateur du serveur. Elle filtre l'enregistrement ET chaque requête :
  retirer une adresse coupe aussitôt les connexions qui l'utilisent.
- Changer d'instance exige une nouvelle clé : l'ancienne est écrasée et ne
  voyage jamais vers le nouveau serveur.
- Le désassignement reste possible sans connexion : purement local, il nettoie
  association et portrait stocké même après suppression de la connexion ; le
  panneau affiche « Retirer l'association » dans tous les états.
- Onglet Immich dans Paramètres de l'arbre : menu des instances autorisées,
  test de connexion, suppression avec confirmation ; i18n anglais des nouveaux
  messages.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-10-13 14:04:45 +02:00

398 lines
12 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
}
// 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
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[]
lastOpenedTree Tree? @relation("UserLastOpenedTree", fields: [lastOpenedTreeId], references: [id], onDelete: SetNull)
@@index([lastOpenedAt])
@@map("users")
}
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[]
contributionSessions ContributionSession[]
auditLogs AuditLog[]
annotations Annotation[]
visits TreeVisit[]
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?
birthPlace String?
profession String?
region String?
nationality String?
avatarPath String?
avatarMimeType 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)
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?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
@@index([treeId, personId, displayOrder])
@@map("media_items")
}
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")
}