mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
* Prisma 7 : client généré dans src/generated, pilote pg, prisma.config.ts - générateur prisma-client (ESM, extension .js), compilé dans dist : plus de node_modules/.prisma à copier dans les images - @prisma/adapter-pg, délai de connexion de 5 s comme le moteur de Prisma 6 - prisma.config.ts : adresse de la base et .env pour le CLI, sans dépendre de prisma/config à l'exécution (CLI à part dans les images) - CI : migrate diff --from-config-datasource --to-schema - deploy.sh : arrêt avant installation si Node est trop vieux pour Prisma 7 - remplace les PR Dependabot #135 et #137 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Prisma 7 : import du client généré dans token-revocation (#163) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
550 lines
18 KiB
Plaintext
550 lines
18 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?
|
|
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")
|
|
}
|