mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-09 15:50:58 +00:00
Les photos partent en taille réelle, sans miniature : sur un réseau lent, une vignette restait un carré vide et un portrait montrait ses initiales. Chaque photo et chaque portrait a maintenant un aperçu flou aux bonnes couleurs (une chaîne de 28 caractères), affiché jusqu'à l'arrivée de l'image, sans fondu. - Base : media_items.blurhash et persons.avatarBlurhash (migration additive 20261027_001). - API : calculé à l'envoi d'une photo et d'un portrait (sharp, 32 px), publié dans le graphe ; les autres chemins (Immich, import GEDCOM, propositions acceptées) et les photos d'avant sont rattrapés en tâche de fond (lots de 25, puis une passe par minute). Un fichier illisible reçoit un aperçu vide. - Navigateur : vignettes de la fiche, de la frise et du gestionnaire de souvenirs, portrait de la fiche, portraits et souvenirs dessinés sur l'arbre. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
554 lines
19 KiB
Plaintext
554 lines
19 KiB
Plaintext
// 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")
|
|
}
|