mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
Merge pull request #97 from IliesAllali/fix/audit-1-1
Audit du 18/10 : import GEDCOM, transactions, arbre au repos, délai réseau, visites, sauvegardes
This commit is contained in:
22 files changed
+655
-207
No files matched your search
@@ -182,6 +182,13 @@ jobs:
|
||||
- name: Test de fumée par le port web
|
||||
run: bash tools/docker-smoke.sh http://localhost:8080
|
||||
|
||||
# Les commandes de sauvegarde et de restauration du README, jouées pour de vrai
|
||||
- name: Sauvegarde puis restauration
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash tools/docker-restore-check.sh http://localhost:8080
|
||||
bash tools/docker-smoke.sh http://localhost:8080
|
||||
|
||||
# Un second front qui joint l'API par le nom de son conteneur (API_UPSTREAM), comme sur Umbrel.
|
||||
- name: Front avec une autre adresse d'API
|
||||
run: |
|
||||
@@ -233,6 +240,9 @@ jobs:
|
||||
docker restart aio
|
||||
bash tools/docker-smoke.sh http://localhost:8080
|
||||
docker logs aio 2>&1 | grep -c "première installation" | grep -qx 1
|
||||
# Base à jour au redémarrage : pas de sauvegarde avant migration, mais pg_dump marche dans l'image
|
||||
[ -z "$(docker exec aio ls -A /data/backups 2>/dev/null)" ]
|
||||
[ "$(docker exec aio runuser -u postgres -- pg_dump -Fc parenthese | wc -c)" -gt 1000 ]
|
||||
|
||||
- name: Journaux en cas d'échec
|
||||
if: failure()
|
||||
|
||||
+2
-2
@@ -99,7 +99,7 @@ docker run --rm -v parenthese_media:/data -v "$PWD":/backup alpine tar -czf /bac
|
||||
|
||||
La première commande sauvegarde la base. Elle contient les personnes, leurs liens, les comptes et les contributions, et elle sait à qui appartient chaque photo, vidéo ou enregistrement. La seconde sauvegarde les fichiers eux-mêmes. Il faut les deux. Restaurez la base seule et les souvenirs pointent vers des fichiers disparus. Restaurez l'archive seule et vous avez un dossier de fichiers rattachés à personne. L'export GEDCOM n'est pas une sauvegarde non plus. Il sert à emmener l'arbre vers un autre logiciel de généalogie et laisse de côté les médias et les annotations.
|
||||
|
||||
Adaptez `-U parenthese` et le nom de base si vous avez changé `POSTGRES_USER` ou `POSTGRES_DB`. Pour restaurer la base : `docker compose exec -T db pg_restore -U parenthese -d parenthese --clean < parenthese-db-AAAA-MM-JJ.dump`, puis décompressez l'archive des médias dans le volume avec la même commande `docker run`, `tar -xzf` à la place de `-czf`.
|
||||
Adaptez `-U parenthese` et le nom de base si vous avez changé `POSTGRES_USER` ou `POSTGRES_DB`. Pour restaurer la base : `docker compose exec -T db pg_restore -U parenthese -d parenthese --clean --if-exists < parenthese-db-AAAA-MM-JJ.dump`, puis décompressez l'archive des médias dans le volume avec la même commande `docker run`, `tar -xzf` à la place de `-czf`.
|
||||
|
||||
Le script `deploy/backup.sh` fait la même chose (dump PostgreSQL, archive des médias, sommes de contrôle, rotation, envoi S3 optionnel) pour une installation sans Docker : il lit `backend/.env` et a besoin de `pg_dump` sur la machine. Ses réglages passent par les variables `BACKUP_ROOT`, `BACKUP_RETENTION_DAYS` et `BACKUP_S3_BUCKET`.
|
||||
|
||||
@@ -111,7 +111,7 @@ docker compose pull && docker compose up -d
|
||||
|
||||
`latest` suit la branche main. Pour rester sur une version, renseignez `PARENTHESE_VERSION` dans `.env` (par exemple `1.0`). Depuis un clone, `git pull && docker compose up -d --build` reconstruit depuis les sources.
|
||||
|
||||
Les migrations de base de données s'appliquent toutes seules au démarrage de l'API. Faites une sauvegarde avant, par habitude.
|
||||
Les migrations de base de données s'appliquent toutes seules au démarrage de l'API, et une migration ne se défait pas. Lancez les deux commandes de sauvegarde ci-dessus avant chaque mise à jour. L'image tout-en-un le fait seule : avant d'appliquer une migration, elle copie la base dans `/data/backups` et garde les cinq dernières copies.
|
||||
|
||||
## Développer
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ docker run --rm -v parenthese_media:/data -v "$PWD":/backup alpine tar -czf /bac
|
||||
|
||||
The first command saves the database. It holds the people, their links, the accounts and the contributions, plus the record of which photo, video or recording belongs to whom. The second saves the files themselves. You need both. Restore only the database and the memories point to files that are gone. Restore only the archive and you get a folder of files with no names attached. The GEDCOM export is not a backup either. It moves the tree to another genealogy program and leaves the media and the annotations behind.
|
||||
|
||||
Adjust `-U parenthese` and the database name if you changed `POSTGRES_USER` or `POSTGRES_DB`. To restore the database, run `docker compose exec -T db pg_restore -U parenthese -d parenthese --clean < parenthese-db-YYYY-MM-DD.dump`, then extract the media archive into the volume with the same `docker run` command, using `tar -xzf` instead of `-czf`.
|
||||
Adjust `-U parenthese` and the database name if you changed `POSTGRES_USER` or `POSTGRES_DB`. To restore the database, run `docker compose exec -T db pg_restore -U parenthese -d parenthese --clean --if-exists < parenthese-db-YYYY-MM-DD.dump`, then extract the media archive into the volume with the same `docker run` command, using `tar -xzf` instead of `-czf`.
|
||||
|
||||
The `deploy/backup.sh` script does the same (PostgreSQL dump, media archive, checksums, rotation, optional S3 upload) for an installation without Docker. It reads `backend/.env` and needs `pg_dump` on the machine. It is configured through the `BACKUP_ROOT`, `BACKUP_RETENTION_DAYS` and `BACKUP_S3_BUCKET` variables.
|
||||
|
||||
@@ -117,7 +117,7 @@ docker compose pull && docker compose up -d
|
||||
|
||||
`latest` follows the main branch. To stay on a version, set `PARENTHESE_VERSION` in `.env` (for example `1.0`). From a clone, `git pull && docker compose up -d --build` builds from the source instead.
|
||||
|
||||
Database migrations apply on their own when the API starts. Make a backup first, as a habit.
|
||||
Database migrations apply on their own when the API starts, and a migration cannot be undone. Run the two backup commands above before each update. The all-in-one image does it by itself: before applying a migration, it copies the database to `/data/backups` and keeps the last five copies.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -148,10 +148,51 @@ export function plainNoteText(raw: string): string {
|
||||
return text.replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, '\n').trim()
|
||||
}
|
||||
|
||||
// Une note ou une source partagée est citée par des milliers de fiches : son texte se calcule une fois
|
||||
// par import (les nœuds sont neufs à chaque import, la WeakMap se vide avec eux)
|
||||
const noteTextCache = new WeakMap<GedcomPhotoNode, string>()
|
||||
const recordTitleCache = new WeakMap<GedcomPhotoNode, string>()
|
||||
const hasMediaCache = new WeakMap<GedcomPhotoNode, boolean>()
|
||||
const MAX_RECORD_DEPTH = 200
|
||||
const MAX_WALKED_NODES_PER_PERSON = 5000
|
||||
|
||||
export function noteText(node: GedcomPhotoNode | undefined, records: GedcomRecords): string {
|
||||
if (!node) return ''
|
||||
const pointer = pointerOf(node.value)
|
||||
return plainNoteText(joinedText(pointer ? records.note.get(pointer) : node))
|
||||
const target = pointer ? records.note.get(pointer) : node
|
||||
if (!target) return ''
|
||||
let text = noteTextCache.get(target)
|
||||
if (text === undefined) {
|
||||
text = plainNoteText(joinedText(target))
|
||||
noteTextCache.set(target, text)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function recordTitle(record: GedcomPhotoNode): string {
|
||||
let title = recordTitleCache.get(record)
|
||||
if (title === undefined) {
|
||||
title = joinedText(child(record, 'TITL')) || joinedText(child(record, 'ABBR'))
|
||||
recordTitleCache.set(record, title)
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
// Un nœud mène-t-il à un fichier (OBJE), directement ou par une source ou une union citée ?
|
||||
// Un renvoi en boucle compte pour non pendant le calcul.
|
||||
function hasMedia(node: GedcomPhotoNode, records: GedcomRecords, depth = 0): boolean {
|
||||
const known = hasMediaCache.get(node)
|
||||
if (known !== undefined) return known
|
||||
if (depth > MAX_RECORD_DEPTH) return false
|
||||
hasMediaCache.set(node, false)
|
||||
let found = false
|
||||
for (const c of node.children) {
|
||||
const pointer = pointerOf(c.value)
|
||||
const linked = !pointer ? undefined : c.tag === 'SOUR' ? records.sour.get(pointer) : c.tag === 'FAMS' ? records.fam.get(pointer) : undefined
|
||||
if (c.tag === 'OBJE' || hasMedia(c, records, depth + 1) || (linked && hasMedia(linked, records, depth + 1))) { found = true; break }
|
||||
}
|
||||
hasMediaCache.set(node, found)
|
||||
return found
|
||||
}
|
||||
|
||||
function clipText(value: string, max: number): string | null {
|
||||
@@ -170,7 +211,7 @@ function baseNameWithoutExtension(path: string): string {
|
||||
export function sourceLabel(citation: GedcomPhotoNode, records: GedcomRecords): string | null {
|
||||
const pointer = pointerOf(citation.value)
|
||||
const record = pointer ? records.sour.get(pointer) : null
|
||||
const title = joinedText(child(record ?? undefined, 'TITL')) || joinedText(child(record ?? undefined, 'ABBR')) || (pointer ? '' : citation.value)
|
||||
const title = (record ? recordTitle(record) : '') || (pointer ? '' : citation.value)
|
||||
const page = joinedText(child(citation, 'PAGE'))
|
||||
return clipText([title, page].filter(Boolean).join(', '), MAX_SOURCE_LENGTH)
|
||||
}
|
||||
@@ -203,32 +244,37 @@ export function mediaRefsForPerson(indi: GedcomPhotoNode, records: GedcomRecords
|
||||
}
|
||||
}
|
||||
|
||||
const walk = (node: GedcomPhotoNode, source: string | null) => {
|
||||
let budget = MAX_WALKED_NODES_PER_PERSON
|
||||
const walk = (node: GedcomPhotoNode, source: string | null, depth: number) => {
|
||||
if (depth > MAX_RECORD_DEPTH) return
|
||||
for (const c of node.children) {
|
||||
if (--budget < 0 || refs.length >= MAX_MEDIA_PER_PERSON) return
|
||||
if (c.tag === 'OBJE') {
|
||||
addObje(c, source)
|
||||
} else if (c.tag === 'SOUR') {
|
||||
const label = sourceLabel(c, records) ?? source
|
||||
walk(c, label)
|
||||
const pointer = pointerOf(c.value)
|
||||
const record = pointer ? records.sour.get(pointer) : null
|
||||
if (record && !visitedRecords.has(record)) {
|
||||
visitedRecords.add(record)
|
||||
walk(record, label)
|
||||
const recordToWalk = record && !visitedRecords.has(record) && hasMedia(record, records) ? record : null
|
||||
if (!hasMedia(c, records) && !recordToWalk) continue
|
||||
const label = sourceLabel(c, records) ?? source
|
||||
walk(c, label, depth + 1)
|
||||
if (recordToWalk) {
|
||||
visitedRecords.add(recordToWalk)
|
||||
walk(recordToWalk, label, depth + 1)
|
||||
}
|
||||
} else if (c.tag === 'FAMS') {
|
||||
const pointer = pointerOf(c.value)
|
||||
const fam = pointer ? records.fam.get(pointer) : null
|
||||
if (fam && !visitedRecords.has(fam)) {
|
||||
if (fam && !visitedRecords.has(fam) && hasMedia(fam, records)) {
|
||||
visitedRecords.add(fam)
|
||||
walk({ ...fam, children: fam.children.filter((f) => !['HUSB', 'WIFE', 'CHIL'].includes(f.tag)) }, source)
|
||||
walk({ ...fam, children: fam.children.filter((f) => !['HUSB', 'WIFE', 'CHIL'].includes(f.tag)) }, source, depth + 1)
|
||||
}
|
||||
} else if (!pointerOf(c.value) && c.children.length > 0) {
|
||||
walk(c, source)
|
||||
} else if (!pointerOf(c.value) && c.children.length > 0 && hasMedia(c, records)) {
|
||||
walk(c, source, depth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(indi, null)
|
||||
walk(indi, null, 0)
|
||||
return refs
|
||||
}
|
||||
@@ -6,8 +6,11 @@ import { env } from '../config/env.js'
|
||||
// Une ouverture d'arbre = une visite. Les rechargements du même visiteur dans cette fenêtre
|
||||
// (graphe rechargé après une modification, onglet rouvert) ne comptent qu'une fois.
|
||||
export const VISIT_DEDUP_WINDOW_MS = 30 * 60 * 1000
|
||||
// Au-delà, les visites sont effacées (lecture de l'onglet Visites)
|
||||
// Au-delà, les visites sont effacées : à l'enregistrement d'une visite (au plus une fois par heure,
|
||||
// pour tous les arbres) et à la lecture de l'onglet Visites
|
||||
export const VISIT_RETENTION_DAYS = 365
|
||||
const PURGE_INTERVAL_MS = 60 * 60 * 1000
|
||||
let lastPurgeAt = 0
|
||||
|
||||
export type VisitAccessKind = 'share' | 'member' | 'shared_account'
|
||||
|
||||
@@ -34,6 +37,18 @@ export function visitorKeyOf(treeId: string, ip: string, userAgent: string): str
|
||||
return createHash('sha256').update(`${env.JWT_SECRET}|${treeId}|${ip}|${userAgent}`).digest('hex').slice(0, 32)
|
||||
}
|
||||
|
||||
// Compte supprimé : ses visites restent comptées, sans son identifiant
|
||||
export function deletedAccountVisitorKey(userId: string): string {
|
||||
return createHash('sha256').update(`${env.JWT_SECRET}|deleted|${userId}`).digest('hex').slice(0, 32)
|
||||
}
|
||||
|
||||
async function purgeExpiredVisits(app: FastifyInstance): Promise<void> {
|
||||
const now = Date.now()
|
||||
if (now - lastPurgeAt < PURGE_INTERVAL_MS) return
|
||||
lastPurgeAt = now
|
||||
await app.prisma.treeVisit.deleteMany({ where: { createdAt: { lt: new Date(now - VISIT_RETENTION_DAYS * 24 * 60 * 60 * 1000) } } })
|
||||
}
|
||||
|
||||
// N'échoue jamais : un journal qui casse ne doit pas empêcher d'ouvrir l'arbre
|
||||
export async function recordTreeVisit(
|
||||
app: FastifyInstance,
|
||||
@@ -69,6 +84,7 @@ export async function recordTreeVisit(
|
||||
device: deviceFromUserAgent(userAgent),
|
||||
},
|
||||
})
|
||||
await purgeExpiredVisits(app)
|
||||
} catch (error) {
|
||||
request.log.warn({ err: error, treeId }, 'tree_visit_record_failed')
|
||||
}
|
||||
|
||||
@@ -306,64 +306,67 @@ export const annotationRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(400).send({ error: 'invalid_annotation_photo_path' })
|
||||
}
|
||||
|
||||
// Creates
|
||||
for (const [index, item] of payload.data.create.entries()) {
|
||||
await app.prisma.annotation.create({
|
||||
// Créations, modifications, suppressions et trace : tout ou rien, un nouvel essai ne double rien
|
||||
await app.prisma.$transaction(async (tx) => {
|
||||
// Creates
|
||||
for (const [index, item] of payload.data.create.entries()) {
|
||||
await tx.annotation.create({
|
||||
data: {
|
||||
treeId,
|
||||
type: item.type,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
content: createContents[index] as string,
|
||||
style: (item.style ?? {}) as Prisma.InputJsonValue,
|
||||
zIndex: item.zIndex ?? 0,
|
||||
createdBy: user.userId,
|
||||
},
|
||||
})
|
||||
results.created++
|
||||
}
|
||||
|
||||
// Updates
|
||||
for (const [index, item] of payload.data.update.entries()) {
|
||||
const data: Record<string, unknown> = {}
|
||||
if (item.x !== undefined) data.x = item.x
|
||||
if (item.y !== undefined) data.y = item.y
|
||||
if (item.content !== undefined) data.content = updateContents[index]
|
||||
if (item.style !== undefined) data.style = item.style
|
||||
if (item.zIndex !== undefined) data.zIndex = item.zIndex
|
||||
|
||||
if (Object.keys(data).length > 0) {
|
||||
const updated = await tx.annotation.updateMany({
|
||||
where: { id: item.id, treeId, deletedAt: null },
|
||||
data,
|
||||
})
|
||||
if (updated.count > 0) results.updated++
|
||||
}
|
||||
}
|
||||
|
||||
// Deletes
|
||||
if (payload.data.delete.length > 0) {
|
||||
const deleted = await tx.annotation.updateMany({
|
||||
where: {
|
||||
id: { in: payload.data.delete },
|
||||
treeId,
|
||||
deletedAt: null,
|
||||
},
|
||||
data: { deletedAt: new Date() },
|
||||
})
|
||||
results.deleted = deleted.count
|
||||
}
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
treeId,
|
||||
type: item.type,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
content: createContents[index] as string,
|
||||
style: (item.style ?? {}) as Prisma.InputJsonValue,
|
||||
zIndex: item.zIndex ?? 0,
|
||||
createdBy: user.userId,
|
||||
actorType: 'user',
|
||||
actorId: user.userId,
|
||||
action: 'annotations_batch',
|
||||
entityType: 'annotation',
|
||||
entityId: treeId,
|
||||
payloadJson: results,
|
||||
},
|
||||
})
|
||||
results.created++
|
||||
}
|
||||
|
||||
// Updates
|
||||
for (const [index, item] of payload.data.update.entries()) {
|
||||
const data: Record<string, unknown> = {}
|
||||
if (item.x !== undefined) data.x = item.x
|
||||
if (item.y !== undefined) data.y = item.y
|
||||
if (item.content !== undefined) data.content = updateContents[index]
|
||||
if (item.style !== undefined) data.style = item.style
|
||||
if (item.zIndex !== undefined) data.zIndex = item.zIndex
|
||||
|
||||
if (Object.keys(data).length > 0) {
|
||||
const updated = await app.prisma.annotation.updateMany({
|
||||
where: { id: item.id, treeId, deletedAt: null },
|
||||
data,
|
||||
})
|
||||
if (updated.count > 0) results.updated++
|
||||
}
|
||||
}
|
||||
|
||||
// Deletes
|
||||
if (payload.data.delete.length > 0) {
|
||||
const deleted = await app.prisma.annotation.updateMany({
|
||||
where: {
|
||||
id: { in: payload.data.delete },
|
||||
treeId,
|
||||
deletedAt: null,
|
||||
},
|
||||
data: { deletedAt: new Date() },
|
||||
})
|
||||
results.deleted = deleted.count
|
||||
}
|
||||
|
||||
await app.prisma.auditLog.create({
|
||||
data: {
|
||||
treeId,
|
||||
actorType: 'user',
|
||||
actorId: user.userId,
|
||||
action: 'annotations_batch',
|
||||
entityType: 'annotation',
|
||||
entityId: treeId,
|
||||
payloadJson: results,
|
||||
},
|
||||
})
|
||||
|
||||
return reply.send({ ok: true, ...results })
|
||||
|
||||
@@ -5,6 +5,7 @@ import { z } from 'zod'
|
||||
import { env } from '../config/env.js'
|
||||
import { hashPassword, verifyPassword } from '../lib/auth.js'
|
||||
import { revokeUserToken } from '../lib/token-revocation.js'
|
||||
import { deletedAccountVisitorKey } from '../lib/tree-visits.js'
|
||||
import { deleteTreeRecords, removeTreeMediaDirectories } from '../lib/tree-purge.js'
|
||||
import type { AnyJwtPayload } from '../types/auth.js'
|
||||
|
||||
@@ -381,6 +382,12 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
data: { submittedByLabel: 'Contributeur' },
|
||||
})
|
||||
|
||||
// Ses visites des autres arbres restent comptées, sans son identifiant
|
||||
await tx.treeVisit.updateMany({
|
||||
where: { visitorKey: `user:${userId}` },
|
||||
data: { visitorKey: deletedAccountVisitorKey(userId) },
|
||||
})
|
||||
|
||||
await tx.user.delete({ where: { id: userId } })
|
||||
return treeIds
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from 'zod'
|
||||
import { UPLOAD_BODY_LIMIT } from '../config/env.js'
|
||||
import { parseGedcomDate } from '../lib/gedcom-dates.js'
|
||||
import { adoptedFamilies, FAMILY_EVENT_TYPES, familyExtras, PERSON_EVENT_TYPES, personExtras } from '../lib/gedcom-events.js'
|
||||
import { indexGedcomRecords, mediaRefsForPerson, photoPathForPerson, plainNoteText } from '../lib/gedcom-photos.js'
|
||||
import { indexGedcomRecords, mediaRefsForPerson, noteText, photoPathForPerson } from '../lib/gedcom-photos.js'
|
||||
import type { GedcomRecords } from '../lib/gedcom-photos.js'
|
||||
import type { MembershipRole } from '../types/auth.js'
|
||||
import { nextSiblingOrder } from '../lib/sibling-order.js'
|
||||
@@ -20,6 +20,9 @@ const importBodySchema = z.object({
|
||||
// Un fichier GEDCOM vient de l'extérieur : longueur des lignes, nombre de fiches et taille des champs
|
||||
// plafonnés comme partout ailleurs (sinon un seul fichier bloque l'API ou remplit la base)
|
||||
const MAX_LINE_LENGTH = 4096
|
||||
const MAX_GEDCOM_LEVEL = 99
|
||||
// Remontées d'ascendance permises sur tout l'import : au-delà, les liens ne sont plus contrôlés (fichier hors norme)
|
||||
const MAX_ANCESTRY_STEPS = 5_000_000
|
||||
const MAX_IMPORTED_PERSONS = 20000
|
||||
const MAX_IMPORTED_FAMILIES = 20000
|
||||
// Le parseur construit un objet par ligne : 10 Mo de lignes courtes montaient à 370 Mo de mémoire, et le
|
||||
@@ -94,6 +97,8 @@ export function parseGedcom(text: string): GedcomNode[] {
|
||||
if (!match) continue
|
||||
|
||||
const level = parseInt(match[1], 10)
|
||||
// La norme s'arrête à 99 niveaux : au-delà, la lecture récursive de l'import déborderait la pile
|
||||
if (level > MAX_GEDCOM_LEVEL) continue
|
||||
const xref = match[2] ? match[2].slice(1, -1) : undefined
|
||||
const tag = match[3]
|
||||
const rawValue = match[4] ?? ''
|
||||
@@ -137,19 +142,7 @@ function gedcomDateToIso(value: string): string | null {
|
||||
// Notes d'une personne : écrites sous elle ou partagées (« 1 NOTE @N1@ »), lignes recollées (CONT, CONC),
|
||||
// RTF de Heredis Mac nettoyé. Plusieurs notes sont séparées par une ligne vide.
|
||||
export function personNotes(indi: GedcomNode, records: GedcomRecords): string | null {
|
||||
const texts: string[] = []
|
||||
for (const note of findAllChildren(indi, 'NOTE')) {
|
||||
const pointer = note.value.match(/^@([^@]+)@$/)?.[1]
|
||||
const source = pointer ? records.note.get(pointer) : note
|
||||
if (!source) continue
|
||||
let text = (pointer ? source.rawValue ?? source.value : note.rawValue ?? note.value)
|
||||
for (const part of source.children) {
|
||||
if (part.tag === 'CONT') text += `\n${part.rawValue ?? part.value}`
|
||||
else if (part.tag === 'CONC') text += part.rawValue ?? part.value
|
||||
}
|
||||
const clean = plainNoteText(text)
|
||||
if (clean) texts.push(clean)
|
||||
}
|
||||
const texts = findAllChildren(indi, 'NOTE').map((note) => noteText(note, records)).filter(Boolean)
|
||||
return texts.length ? texts.join('\n\n') : null
|
||||
}
|
||||
|
||||
@@ -645,12 +638,30 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
}
|
||||
const unionByPair = new Map(existingUnions.map((u) => [pairKey(u.partner1PersonId, u.partner2PersonId), u.id]))
|
||||
const linkKeys = new Set(existingLinks.map((l) => `${l.parentPersonId}:${l.childPersonId}`))
|
||||
// Parents de chaque personne, pour refuser un lien qui ferait de quelqu'un son propre ancêtre
|
||||
// (les validateurs de l'API relisent tout l'arbre à chaque lien : trop lent pour un import)
|
||||
const parentsOf = new Map<string, string[]>()
|
||||
for (const link of existingLinks) parentsOf.set(link.childPersonId, [...(parentsOf.get(link.childPersonId) ?? []), link.parentPersonId])
|
||||
let ancestryBudget = MAX_ANCESTRY_STEPS
|
||||
const isAncestorOf = (candidate: string, personId: string): boolean => {
|
||||
const seen = new Set<string>()
|
||||
const stack = [personId]
|
||||
while (stack.length) {
|
||||
const current = stack.pop()!
|
||||
if (current === candidate) return true
|
||||
if (seen.has(current) || --ancestryBudget < 0) continue
|
||||
seen.add(current)
|
||||
stack.push(...(parentsOf.get(current) ?? []))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
let personsCreated = 0
|
||||
let personsReused = 0
|
||||
let unionsCreated = 0
|
||||
let linksCreated = 0
|
||||
let eventsCreated = 0
|
||||
let relationsSkipped = 0
|
||||
// Adoptions déclarées sous l'enfant (« 2 PEDI adopted ») : enfant → familles où il est adopté
|
||||
const adoptedByChild = new Map(indiNodes.map((indi) => [indi.xref!, adoptedFamilies(indi)]))
|
||||
|
||||
@@ -675,8 +686,6 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
const profession = clip(findChild(indi, 'OCCU')?.value ?? null, 200)
|
||||
|
||||
const notes = clip(personNotes(indi, records), 2000)
|
||||
|
||||
// Prénom absent du fichier : chaîne vide, comme un prénom inconnu dans l'arbre
|
||||
const personFirstName = (firstName || '').slice(0, 120)
|
||||
const personLastName = (lastName || '').slice(0, 120)
|
||||
@@ -693,6 +702,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
continue
|
||||
}
|
||||
|
||||
const notes = clip(personNotes(indi, records), 2000)
|
||||
const { events, ...extras } = personExtras(indi, records)
|
||||
const person = await tx.person.create({
|
||||
data: {
|
||||
@@ -720,7 +730,8 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
xrefToPersonId.set(indi.xref!, person.id)
|
||||
personsCreated++
|
||||
if (photoPath) photoRefs.push({ personId: person.id, file: photoPath })
|
||||
for (const ref of mediaRefsForPerson(indi, records, photoPath)) {
|
||||
const personMedia = mediaRefs.length < MAX_IMPORTED_MEDIA_REFS ? mediaRefsForPerson(indi, records, photoPath) : []
|
||||
for (const ref of personMedia) {
|
||||
if (mediaRefs.length >= MAX_IMPORTED_MEDIA_REFS) break
|
||||
mediaRefs.push({ personId: person.id, ...ref })
|
||||
}
|
||||
@@ -731,7 +742,12 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
const wifeXref = findChild(fam, 'WIFE')?.value?.replace(/@/g, '').trim()
|
||||
|
||||
const partner1Id = husbXref ? xrefToPersonId.get(husbXref) : undefined
|
||||
const partner2Id = wifeXref ? xrefToPersonId.get(wifeXref) : undefined
|
||||
let partner2Id = wifeXref ? xrefToPersonId.get(wifeXref) : undefined
|
||||
// Une même personne des deux côtés d'une union : un seul partenaire
|
||||
if (partner2Id && partner2Id === partner1Id) {
|
||||
partner2Id = undefined
|
||||
relationsSkipped++
|
||||
}
|
||||
|
||||
if (!partner1Id && !partner2Id) continue
|
||||
|
||||
@@ -777,6 +793,10 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
for (const parentId of parentIds) {
|
||||
const linkKey = `${parentId}:${childId}`
|
||||
if (linkKeys.has(linkKey)) continue
|
||||
if (parentId === childId || isAncestorOf(childId, parentId)) {
|
||||
relationsSkipped++
|
||||
continue
|
||||
}
|
||||
await tx.parentChildLink.create({
|
||||
data: {
|
||||
treeId,
|
||||
@@ -788,6 +808,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
},
|
||||
})
|
||||
linkKeys.add(linkKey)
|
||||
parentsOf.set(childId, [...(parentsOf.get(childId) ?? []), parentId])
|
||||
linksCreated++
|
||||
}
|
||||
}
|
||||
@@ -801,7 +822,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
action: 'gedcom_imported',
|
||||
entityType: 'tree',
|
||||
entityId: treeId,
|
||||
payloadJson: { personsCreated, personsReused, unionsCreated, linksCreated, eventsCreated, photoRefs: photoRefs.length, mediaRefs: mediaRefs.length },
|
||||
payloadJson: { personsCreated, personsReused, unionsCreated, linksCreated, eventsCreated, relationsSkipped, photoRefs: photoRefs.length, mediaRefs: mediaRefs.length },
|
||||
},
|
||||
})
|
||||
}, { timeout: 60000 })
|
||||
@@ -811,6 +832,6 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
// photoRefs : chemin de la photo que le logiciel d'origine associait à chaque personne ; mediaRefs : les
|
||||
// autres fichiers cités, avec leur titre et leur source (rien n'est gardé en base)
|
||||
return reply.send({ personsCreated, personsReused, unionsCreated, linksCreated, eventsCreated, photoRefs, mediaRefs })
|
||||
return reply.send({ personsCreated, personsReused, unionsCreated, linksCreated, eventsCreated, relationsSkipped, photoRefs, mediaRefs })
|
||||
})
|
||||
}
|
||||
+29
-26
@@ -795,35 +795,38 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(400).send({ error: 'invalid_media_order_payload' })
|
||||
}
|
||||
|
||||
for (let index = 0; index < orderedIds.length; index += 1) {
|
||||
await app.prisma.mediaItem.updateMany({
|
||||
where: {
|
||||
id: orderedIds[index],
|
||||
treeId: params.data.id,
|
||||
personId: params.data.personId,
|
||||
deletedAt: null,
|
||||
},
|
||||
const actorId = request.actor.userId
|
||||
await app.prisma.$transaction(async (tx) => {
|
||||
for (let index = 0; index < orderedIds.length; index += 1) {
|
||||
await tx.mediaItem.updateMany({
|
||||
where: {
|
||||
id: orderedIds[index],
|
||||
treeId: params.data.id,
|
||||
personId: params.data.personId,
|
||||
deletedAt: null,
|
||||
},
|
||||
data: {
|
||||
displayOrder: index + 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await syncPersonMediaOrder(tx, params.data.id, params.data.personId)
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
displayOrder: index + 1,
|
||||
treeId: params.data.id,
|
||||
actorType: 'user',
|
||||
actorId,
|
||||
action: 'media_reordered',
|
||||
entityType: 'media_item',
|
||||
entityId: params.data.personId,
|
||||
payloadJson: {
|
||||
personId: params.data.personId,
|
||||
orderedMediaIds: payload.data.orderedMediaIds,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await syncPersonMediaOrder(app.prisma, params.data.id, params.data.personId)
|
||||
|
||||
await app.prisma.auditLog.create({
|
||||
data: {
|
||||
treeId: params.data.id,
|
||||
actorType: 'user',
|
||||
actorId: request.actor.userId,
|
||||
action: 'media_reordered',
|
||||
entityType: 'media_item',
|
||||
entityId: params.data.personId,
|
||||
payloadJson: {
|
||||
personId: params.data.personId,
|
||||
orderedMediaIds: payload.data.orderedMediaIds,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return reply.send({ ok: true })
|
||||
|
||||
+66
-55
@@ -2095,21 +2095,23 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.send({ link })
|
||||
})
|
||||
|
||||
// Réordonner une fratrie : displayOrder des liens parent-enfant réécrit en une transaction
|
||||
// Souvenirs rattachables : approuvés, d'une de ces personnes, dans cet arbre
|
||||
const validLifeEventMedia = async (treeId: string, ownerIds: string[], mediaIds: string[] | undefined) => {
|
||||
// Souvenirs rattachables : approuvés, d'une de ces personnes, dans cet arbre. La famille ne prend
|
||||
// que des souvenirs libres : elle ne détache pas un souvenir du moment d'un autre.
|
||||
const validLifeEventMedia = async (treeId: string, ownerIds: string[], mediaIds: string[] | undefined, onlyFree = false) => {
|
||||
if (!mediaIds?.length) return true
|
||||
const unique = [...new Set(mediaIds)]
|
||||
const count = await app.prisma.mediaItem.count({ where: { id: { in: unique }, treeId, personId: { in: ownerIds }, status: 'approved', deletedAt: null } })
|
||||
const count = await app.prisma.mediaItem.count({
|
||||
where: { id: { in: unique }, treeId, personId: { in: ownerIds }, status: 'approved', deletedAt: null, ...(onlyFree ? { lifeEventId: null } : {}) },
|
||||
})
|
||||
return count === unique.length
|
||||
}
|
||||
|
||||
// Rattache exactement ces souvenirs au moment (et détache ceux qui ne sont plus dans la liste)
|
||||
const setLifeEventMedia = async (treeId: string, eventId: string, mediaIds: string[] | undefined) => {
|
||||
const setLifeEventMedia = async (db: Prisma.TransactionClient, treeId: string, eventId: string, mediaIds: string[] | undefined) => {
|
||||
if (mediaIds === undefined) return
|
||||
const unique = [...new Set(mediaIds)]
|
||||
await app.prisma.mediaItem.updateMany({ where: { treeId, lifeEventId: eventId, id: { notIn: unique } }, data: { lifeEventId: null } })
|
||||
if (unique.length) await app.prisma.mediaItem.updateMany({ where: { treeId, id: { in: unique } }, data: { lifeEventId: eventId } })
|
||||
await db.mediaItem.updateMany({ where: { treeId, lifeEventId: eventId, id: { notIn: unique } }, data: { lifeEventId: null } })
|
||||
if (unique.length) await db.mediaItem.updateMany({ where: { treeId, id: { in: unique } }, data: { lifeEventId: eventId } })
|
||||
}
|
||||
|
||||
app.post('/trees/:id/persons/:personId/life/events', async (request, reply) => {
|
||||
@@ -2170,55 +2172,59 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
})
|
||||
if (existing) return reply.code(409).send({ error: 'event_exists', eventId: existing.id })
|
||||
}
|
||||
if (!(await validLifeEventMedia(params.data.id, ownerIds, payload.data.mediaIds))) {
|
||||
if (!(await validLifeEventMedia(params.data.id, ownerIds, payload.data.mediaIds, !isAdmin))) {
|
||||
return reply.code(400).send({ error: 'invalid_media_reference' })
|
||||
}
|
||||
|
||||
const isPending = contribution?.autoStatus === 'pending'
|
||||
const title = payload.data.label?.trim() || payload.data.description?.trim() || payload.data.type
|
||||
const session = contribution
|
||||
? await app.prisma.contributionSession.create({
|
||||
// Session, moment, souvenirs et trace de la contribution : tout ou rien
|
||||
const event = await app.prisma.$transaction(async (tx) => {
|
||||
const session = contribution
|
||||
? await tx.contributionSession.create({
|
||||
data: {
|
||||
treeId: params.data.id,
|
||||
submittedByUserId: contribution.submittedByUserId,
|
||||
submittedByLabel: payload.data.submittedByLabel?.trim() || contribution.submittedByLabel,
|
||||
mode: contribution.mode,
|
||||
status: contribution.autoStatus,
|
||||
title: `Moment pour ${person.firstName || person.lastName}`.slice(0, 120),
|
||||
reviewedAt: isPending ? undefined : new Date(),
|
||||
reviewedBy: isPending ? undefined : contribution.submittedByUserId,
|
||||
},
|
||||
})
|
||||
: null
|
||||
|
||||
const event = await tx.lifeEvent.create({
|
||||
data: {
|
||||
treeId: params.data.id,
|
||||
submittedByUserId: contribution.submittedByUserId,
|
||||
submittedByLabel: payload.data.submittedByLabel?.trim() || contribution.submittedByLabel,
|
||||
mode: contribution.mode,
|
||||
status: contribution.autoStatus,
|
||||
title: `Moment pour ${person.firstName || person.lastName}`.slice(0, 120),
|
||||
reviewedAt: isPending ? undefined : new Date(),
|
||||
reviewedBy: isPending ? undefined : contribution.submittedByUserId,
|
||||
personId: unionId ? null : person.id,
|
||||
unionId,
|
||||
type: payload.data.type,
|
||||
...lifeEventData(payload.data, reserved),
|
||||
status: isPending ? 'pending' : 'approved',
|
||||
contributionSessionId: session?.id ?? null,
|
||||
},
|
||||
})
|
||||
: null
|
||||
await setLifeEventMedia(tx, params.data.id, event.id, payload.data.mediaIds)
|
||||
|
||||
const event = await app.prisma.lifeEvent.create({
|
||||
data: {
|
||||
treeId: params.data.id,
|
||||
personId: unionId ? null : person.id,
|
||||
unionId,
|
||||
type: payload.data.type,
|
||||
...lifeEventData(payload.data, reserved),
|
||||
status: isPending ? 'pending' : 'approved',
|
||||
contributionSessionId: session?.id ?? null,
|
||||
},
|
||||
})
|
||||
await setLifeEventMedia(params.data.id, event.id, payload.data.mediaIds)
|
||||
|
||||
if (session) {
|
||||
await app.prisma.contributionChange.create({
|
||||
data: {
|
||||
sessionId: session.id,
|
||||
entityType: 'life_event',
|
||||
entityId: event.id,
|
||||
action: 'create',
|
||||
afterJson: {
|
||||
personId: person.id, type: event.type, title, date: payload.data.date ?? null, dateEnd: payload.data.dateEnd ?? null,
|
||||
datePrecision: payload.data.datePrecision ?? null, place: event.place, notes: event.notes, sources: event.sources,
|
||||
if (session) {
|
||||
await tx.contributionChange.create({
|
||||
data: {
|
||||
sessionId: session.id,
|
||||
entityType: 'life_event',
|
||||
entityId: event.id,
|
||||
action: 'create',
|
||||
afterJson: {
|
||||
personId: person.id, type: event.type, title, date: payload.data.date ?? null, dateEnd: payload.data.dateEnd ?? null,
|
||||
datePrecision: payload.data.datePrecision ?? null, place: event.place, notes: event.notes, sources: event.sources,
|
||||
},
|
||||
decision: isPending ? undefined : 'approved',
|
||||
},
|
||||
decision: isPending ? undefined : 'approved',
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
return event
|
||||
})
|
||||
return reply.code(201).send({ event: { id: event.id, status: event.status } })
|
||||
})
|
||||
|
||||
@@ -2249,11 +2255,13 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(400).send({ error: 'invalid_media_reference' })
|
||||
}
|
||||
|
||||
await app.prisma.lifeEvent.update({
|
||||
where: { id: params.data.eventId },
|
||||
data: lifeEventData(payload.data, RESERVED_LIFE_EVENT_TYPES.has(existing.type)),
|
||||
await app.prisma.$transaction(async (tx) => {
|
||||
await tx.lifeEvent.update({
|
||||
where: { id: params.data.eventId },
|
||||
data: lifeEventData(payload.data, RESERVED_LIFE_EVENT_TYPES.has(existing.type)),
|
||||
})
|
||||
await setLifeEventMedia(tx, params.data.id, params.data.eventId, payload.data.mediaIds)
|
||||
})
|
||||
await setLifeEventMedia(params.data.id, params.data.eventId, payload.data.mediaIds)
|
||||
return reply.send({ ok: true })
|
||||
})
|
||||
|
||||
@@ -2266,13 +2274,16 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
const membership = await getAdminMembership(app, user.userId, params.data.id)
|
||||
if (!hasActiveAdminMembership(membership)) return reply.code(403).send({ error: 'forbidden' })
|
||||
|
||||
const deleted = await app.prisma.lifeEvent.updateMany({
|
||||
where: { id: params.data.eventId, treeId: params.data.id, deletedAt: null, personId: { not: null }, type: { notIn: [...RESERVED_LIFE_EVENT_TYPES] } },
|
||||
data: { deletedAt: new Date() },
|
||||
const deletedCount = await app.prisma.$transaction(async (tx) => {
|
||||
const deleted = await tx.lifeEvent.updateMany({
|
||||
where: { id: params.data.eventId, treeId: params.data.id, deletedAt: null, personId: { not: null }, type: { notIn: [...RESERVED_LIFE_EVENT_TYPES] } },
|
||||
data: { deletedAt: new Date() },
|
||||
})
|
||||
// Les souvenirs restent dans la fiche, simplement détachés du moment
|
||||
if (deleted.count > 0) await tx.mediaItem.updateMany({ where: { treeId: params.data.id, lifeEventId: params.data.eventId }, data: { lifeEventId: null } })
|
||||
return deleted.count
|
||||
})
|
||||
if (deleted.count === 0) return reply.code(404).send({ error: 'event_not_found' })
|
||||
// Les souvenirs restent dans la fiche, simplement détachés du moment
|
||||
await app.prisma.mediaItem.updateMany({ where: { treeId: params.data.id, lifeEventId: params.data.eventId }, data: { lifeEventId: null } })
|
||||
if (deletedCount === 0) return reply.code(404).send({ error: 'event_not_found' })
|
||||
return reply.code(204).send()
|
||||
})
|
||||
|
||||
|
||||
@@ -80,6 +80,9 @@ const { prismaMock } = vi.hoisted(() => ({
|
||||
},
|
||||
annotation: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
create: vi.fn(),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
contributionSession: {
|
||||
create: vi.fn(),
|
||||
@@ -753,6 +756,141 @@ describe('Moments de l’onglet Vie', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Écritures en plusieurs temps : tout passe par une transaction', () => {
|
||||
// Le client de la transaction est un autre objet que app.prisma : une écriture faite hors transaction se voit
|
||||
const txClient = () => ({
|
||||
annotation: { create: vi.fn().mockResolvedValue({ id: 'a' }), updateMany: vi.fn().mockResolvedValue({ count: 1 }) },
|
||||
mediaItem: { updateMany: vi.fn().mockResolvedValue({ count: 1 }), findMany: vi.fn().mockResolvedValue([]), update: vi.fn() },
|
||||
lifeEvent: { create: vi.fn().mockResolvedValue({ id: 'e-1', status: 'approved' }), update: vi.fn(), updateMany: vi.fn().mockResolvedValue({ count: 1 }) },
|
||||
contributionSession: { create: vi.fn() },
|
||||
contributionChange: { create: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
})
|
||||
|
||||
it('lot d’annotations : créations, modifications, suppressions et trace dans la même transaction', async () => {
|
||||
const app = createApp()
|
||||
const auth = { authorization: `Bearer ${await createUserToken(app)}` }
|
||||
const tx = txClient()
|
||||
prismaMock.$transaction.mockImplementationOnce(async (callback: (client: unknown) => Promise<unknown>) => callback(tx))
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST', url: '/trees/tree-1/annotations/batch', headers: auth,
|
||||
payload: { create: [{ type: 'text', x: 0, y: 0, content: 'Été 1952' }, { type: 'text', x: 1, y: 1, content: 'Noël' }], update: [{ id: 'a-1', x: 5 }], delete: ['a-2'] },
|
||||
})
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(response.json()).toEqual({ ok: true, created: 2, updated: 1, deleted: 1 })
|
||||
expect(tx.annotation.create).toHaveBeenCalledTimes(2)
|
||||
expect(tx.annotation.updateMany).toHaveBeenCalledTimes(2)
|
||||
expect(tx.auditLog.create).toHaveBeenCalledTimes(1)
|
||||
expect(prismaMock.annotation.create).not.toHaveBeenCalled()
|
||||
expect(prismaMock.annotation.updateMany).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('lot d’annotations : une erreur au milieu fait échouer toute la transaction', async () => {
|
||||
const app = createApp()
|
||||
const auth = { authorization: `Bearer ${await createUserToken(app)}` }
|
||||
const tx = txClient()
|
||||
tx.annotation.create.mockResolvedValueOnce({ id: 'a' }).mockRejectedValueOnce(new Error('base indisponible'))
|
||||
prismaMock.$transaction.mockImplementationOnce(async (callback: (client: unknown) => Promise<unknown>) => callback(tx))
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST', url: '/trees/tree-1/annotations/batch', headers: auth,
|
||||
payload: { create: [{ type: 'text', x: 0, y: 0, content: 'a' }, { type: 'text', x: 1, y: 1, content: 'b' }] },
|
||||
})
|
||||
expect(response.statusCode).toBe(500)
|
||||
expect(tx.auditLog.create).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('réordonner les souvenirs : positions, renumérotation et trace dans la même transaction', async () => {
|
||||
const app = createApp()
|
||||
const auth = { authorization: `Bearer ${await createUserToken(app)}` }
|
||||
const tx = txClient()
|
||||
prismaMock.mediaItem.findMany.mockResolvedValueOnce([{ id: 'm-1' }, { id: 'm-2' }])
|
||||
prismaMock.$transaction.mockImplementationOnce(async (callback: (client: unknown) => Promise<unknown>) => callback(tx))
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'PATCH', url: '/trees/tree-1/persons/person-1/media/reorder', headers: auth,
|
||||
payload: { orderedMediaIds: ['m-2', 'm-1'] },
|
||||
})
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(tx.mediaItem.updateMany).toHaveBeenCalledTimes(2)
|
||||
expect(tx.auditLog.create).toHaveBeenCalledTimes(1)
|
||||
expect(prismaMock.mediaItem.updateMany).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('moment : création et rattachement des souvenirs dans la même transaction', async () => {
|
||||
const app = createApp()
|
||||
const auth = { authorization: `Bearer ${await createUserToken(app)}` }
|
||||
const tx = txClient()
|
||||
prismaMock.mediaItem.count.mockResolvedValue(1)
|
||||
prismaMock.$transaction.mockImplementationOnce(async (callback: (client: unknown) => Promise<unknown>) => callback(tx))
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST', url: '/trees/tree-1/persons/person-1/life/events', headers: auth,
|
||||
payload: { label: 'Départ pour Paris', mediaIds: ['m-1'] },
|
||||
})
|
||||
expect(response.statusCode).toBe(201)
|
||||
expect(tx.lifeEvent.create).toHaveBeenCalledTimes(1)
|
||||
expect(tx.mediaItem.updateMany).toHaveBeenCalledWith({ where: { treeId: 'tree-1', id: { in: ['m-1'] } }, data: { lifeEventId: 'e-1' } })
|
||||
expect(prismaMock.lifeEvent.create).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('la famille ne prend que des souvenirs libres, l’administrateur peut déplacer un souvenir d’un moment à l’autre', async () => {
|
||||
const app = createApp()
|
||||
const auth = { authorization: `Bearer ${await createUserToken(app)}` }
|
||||
const post = () => app.inject({ method: 'POST', url: '/trees/tree-1/persons/person-1/life/events', headers: auth, payload: { label: 'Communion', mediaIds: ['m-1'] } })
|
||||
prismaMock.mediaItem.count.mockResolvedValue(1)
|
||||
prismaMock.lifeEvent.create.mockResolvedValue({ id: 'e-1', status: 'approved' })
|
||||
|
||||
expect((await post()).statusCode).toBe(201)
|
||||
expect(prismaMock.mediaItem.count.mock.calls.at(-1)[0].where).not.toHaveProperty('lifeEventId')
|
||||
|
||||
prismaMock.treeMembership.findUnique.mockResolvedValue({ role: 'member', tree: { deletedAt: null, settings: null } })
|
||||
prismaMock.contributionSession.create.mockResolvedValue({ id: 's-1' })
|
||||
expect((await post()).statusCode).toBe(201)
|
||||
expect(prismaMock.mediaItem.count.mock.calls.at(-1)[0].where).toMatchObject({ lifeEventId: null })
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Import GEDCOM', () => {
|
||||
it('une personne des deux côtés d’une union garde un seul partenaire, une boucle parent-enfant est refusée', async () => {
|
||||
const app = createApp()
|
||||
const auth = { authorization: `Bearer ${await createUserToken(app)}` }
|
||||
prismaMock.person.findMany.mockResolvedValue([])
|
||||
prismaMock.union.findMany.mockResolvedValue([])
|
||||
prismaMock.parentChildLink.findMany.mockResolvedValue([])
|
||||
prismaMock.parentChildLink.findFirst.mockResolvedValue(null)
|
||||
prismaMock.person.create.mockImplementation(async ({ data }: { data: { firstName: string } }) => ({ id: `p-${data.firstName}` }))
|
||||
let unionCount = 0
|
||||
prismaMock.union.create.mockImplementation(async () => ({ id: `u-${++unionCount}` }))
|
||||
|
||||
const content = [
|
||||
'0 HEAD',
|
||||
'0 @I1@ INDI', '1 NAME Jean /Martin/',
|
||||
'0 @I2@ INDI', '1 NAME Paul /Martin/',
|
||||
// Jean marié à lui-même (fichier abîmé)
|
||||
'0 @F1@ FAM', '1 HUSB @I1@', '1 WIFE @I1@',
|
||||
// Paul enfant de Jean, puis Jean enfant de Paul : la seconde famille ferait une boucle
|
||||
'0 @F2@ FAM', '1 HUSB @I1@', '1 CHIL @I2@',
|
||||
'0 @F3@ FAM', '1 HUSB @I2@', '1 CHIL @I1@',
|
||||
'0 TRLR',
|
||||
].join('\n')
|
||||
const response = await app.inject({ method: 'POST', url: '/trees/tree-1/import/gedcom', headers: auth, payload: { content } })
|
||||
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(response.json()).toMatchObject({ personsCreated: 2, linksCreated: 1, relationsSkipped: 2 })
|
||||
expect(prismaMock.union.create.mock.calls[0][0].data).toMatchObject({ partner1PersonId: 'p-Jean', partner2PersonId: null })
|
||||
expect(prismaMock.parentChildLink.create).toHaveBeenCalledTimes(1)
|
||||
expect(prismaMock.parentChildLink.create.mock.calls[0][0].data).toMatchObject({ parentPersonId: 'p-Jean', childPersonId: 'p-Paul' })
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Visit log', () => {
|
||||
it('refuses the visit log to a non-admin member', async () => {
|
||||
const app = createApp()
|
||||
|
||||
@@ -28,6 +28,9 @@ const { prismaMock } = vi.hoisted(() => ({
|
||||
contributionSession: {
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
treeVisit: {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
$disconnect: vi.fn(),
|
||||
},
|
||||
@@ -214,6 +217,10 @@ describe('DELETE /auth/me', () => {
|
||||
where: { submittedByUserId: 'user-1' },
|
||||
data: { submittedByLabel: 'Contributeur' },
|
||||
})
|
||||
// Ses visites d'autres arbres restent comptées, sans son identifiant
|
||||
const visitUpdate = prismaMock.treeVisit.updateMany.mock.calls[0][0]
|
||||
expect(visitUpdate.where).toEqual({ visitorKey: 'user:user-1' })
|
||||
expect(visitUpdate.data.visitorKey).toMatch(/^[0-9a-f]{32}$/)
|
||||
expect(prismaMock.user.delete).toHaveBeenCalledWith({ where: { id: 'user-1' } })
|
||||
|
||||
// Les arbres doivent partir avant l'utilisateur (relation propriétaire en Restrict).
|
||||
|
||||
@@ -220,4 +220,13 @@ describe('localMediaPath', () => {
|
||||
expect(localMediaPath('C:\\Photos\\arbre.ged')).toBeNull()
|
||||
expect(localMediaPath('')).toBeNull()
|
||||
})
|
||||
|
||||
it('fichier piégé : sources en chaîne et niveaux trop profonds, sans débordement ni lenteur', () => {
|
||||
const chain = Array.from({ length: 20000 }, (_, i) => [`0 @S${i}@ SOUR`, `1 SOUR @S${i + 1}@`]).flat()
|
||||
const deep = Array.from({ length: 5000 }, (_, i) => `${i + 2} NOTE niveau`)
|
||||
const text = ['0 @I1@ INDI', '1 NAME A /B/', '1 SOUR @S0@', '1 BIRT', ...deep, ...chain, '0 @S20000@ SOUR', '1 OBJE', '2 FILE C:/fin.jpg'].join('\n')
|
||||
const started = Date.now()
|
||||
expect(() => mediaOf(text)).not.toThrow()
|
||||
expect(Date.now() - started).toBeLessThan(2000)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { recordTreeVisit, VISIT_RETENTION_DAYS } from '../src/lib/tree-visits'
|
||||
|
||||
function fakeApp() {
|
||||
return {
|
||||
prisma: {
|
||||
treeVisit: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue({ id: 'v' }),
|
||||
update: vi.fn(),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const request = { headers: { 'user-agent': 'Mozilla/5.0 (iPhone)' }, ip: '203.0.113.10', log: { warn: vi.fn() } }
|
||||
|
||||
describe('journal des visites', () => {
|
||||
it('efface les visites de plus d’un an à l’enregistrement, au plus une fois par heure', async () => {
|
||||
const app = fakeApp()
|
||||
const before = Date.now()
|
||||
await recordTreeVisit(app as never, request as never, 'tree-1', 'share', null)
|
||||
await recordTreeVisit(app as never, request as never, 'tree-2', 'share', null)
|
||||
|
||||
expect(app.prisma.treeVisit.create).toHaveBeenCalledTimes(2)
|
||||
expect(app.prisma.treeVisit.deleteMany).toHaveBeenCalledTimes(1)
|
||||
const cutoff = app.prisma.treeVisit.deleteMany.mock.calls[0][0].where.createdAt.lt.getTime()
|
||||
// Tous les arbres, pas seulement celui qu'on ouvre : un arbre qu'on ne rouvre plus est purgé aussi
|
||||
expect(app.prisma.treeVisit.deleteMany.mock.calls[0][0].where).not.toHaveProperty('treeId')
|
||||
expect(before - cutoff).toBeGreaterThanOrEqual(VISIT_RETENTION_DAYS * 24 * 60 * 60 * 1000 - 1000)
|
||||
})
|
||||
})
|
||||
@@ -78,6 +78,22 @@ stop_all() {
|
||||
trap 'stop_all; exit 0' TERM INT
|
||||
|
||||
cd /app
|
||||
# Migration en attente sur une base existante : copie de la base dans /data/backups avant de l'appliquer
|
||||
# (une migration ne se défait pas). Les 5 dernières sont gardées. Si la copie échoue, le conteneur s'arrête.
|
||||
if [ "$FIRST_START" = 0 ] && ! runuser -u node -- env PATH="$PATH" prisma migrate status >/dev/null 2>&1; then
|
||||
mkdir -p "$DATA_DIR/backups"
|
||||
chmod 700 "$DATA_DIR/backups"
|
||||
dump="$DATA_DIR/backups/parenthese-avant-migration-$(date -u +%Y%m%dT%H%M%SZ).dump"
|
||||
log "migration en attente : sauvegarde de la base dans $dump"
|
||||
if ! (umask 077 && runuser -u postgres -- pg_dump -Fc "$DB_NAME" > "$dump"); then
|
||||
rm -f "$dump"
|
||||
log "la sauvegarde a échoué, migration annulée (place sur le disque ?)"
|
||||
stop_all
|
||||
exit 1
|
||||
fi
|
||||
ls -1t "$DATA_DIR"/backups/parenthese-avant-migration-*.dump | tail -n +6 | xargs -r rm -f
|
||||
fi
|
||||
|
||||
log "migrations"
|
||||
runuser -u node -- env PATH="$PATH" prisma migrate deploy
|
||||
|
||||
|
||||
+3
-6
@@ -98,12 +98,9 @@ echo "[3/5] Generate checksums..."
|
||||
(
|
||||
cd "$BACKUP_ROOT"
|
||||
sha256sum "db/$(basename "$DB_BACKUP_FILE")"
|
||||
if [ "$MEDIA_IS_NEW" = "1" ]; then
|
||||
sha256sum "media/$(basename "$MEDIA_BACKUP_FILE")"
|
||||
else
|
||||
# empreinte déjà calculée le jour où l'archive a été faite
|
||||
grep -h " media/$(basename "$MEDIA_BACKUP_FILE")" "$META_DIR"/genealogy-*.sha256 2>/dev/null | head -1 || true
|
||||
fi
|
||||
# Archive du jour ou archive gardée : son empreinte est recalculée, celle du jour où elle a été faite
|
||||
# disparaît avec la rotation des fichiers d'empreintes (14 jours) alors que l'archive reste
|
||||
sha256sum "media/$(basename "$MEDIA_BACKUP_FILE")"
|
||||
) > "$CHECKSUM_FILE"
|
||||
|
||||
echo "[4/5] Optional offsite upload..."
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t } from '../i18n/index.js'
|
||||
|
||||
const rawApiBaseUrl = import.meta.env.VITE_API_BASE_URL
|
||||
const defaultApiBaseUrl = import.meta.env.DEV ? 'http://localhost:4000' : ''
|
||||
const API_BASE_URL = (rawApiBaseUrl ?? defaultApiBaseUrl).replace(/\/$/, '')
|
||||
@@ -23,7 +25,21 @@ async function safeParseJson(text) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sans limite, un réseau mobile qui décroche laisse l'arbre « en chargement » pour toujours.
|
||||
// Un corps de plus de 100 Ko est un envoi de fichier : il a plus de temps sur une connexion lente.
|
||||
const DEFAULT_TIMEOUT_MS = 30_000
|
||||
const UPLOAD_TIMEOUT_MS = 5 * 60_000
|
||||
const LARGE_BODY_CHARS = 100_000
|
||||
|
||||
function timeoutSignal(options) {
|
||||
if (options.signal) return options.signal
|
||||
if (typeof AbortSignal === 'undefined' || typeof AbortSignal.timeout !== 'function') return undefined
|
||||
const isLargeBody = typeof options.body === 'string' && options.body.length > LARGE_BODY_CHARS
|
||||
return AbortSignal.timeout(options.timeoutMs ?? (isLargeBody ? UPLOAD_TIMEOUT_MS : DEFAULT_TIMEOUT_MS))
|
||||
}
|
||||
|
||||
async function apiRequest(path, options = {}) {
|
||||
const { timeoutMs: _timeoutMs, ...fetchOptions } = options
|
||||
const providedHeaders = options.headers || {}
|
||||
const hasExplicitContentType = Object.keys(providedHeaders).some((key) => key.toLowerCase() === 'content-type')
|
||||
|
||||
@@ -32,12 +48,21 @@ async function apiRequest(path, options = {}) {
|
||||
...providedHeaders,
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
|
||||
const text = await response.text()
|
||||
let response
|
||||
let text
|
||||
try {
|
||||
response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...fetchOptions,
|
||||
headers,
|
||||
signal: timeoutSignal(options),
|
||||
})
|
||||
text = await response.text()
|
||||
} catch (error) {
|
||||
if (error?.name === 'TimeoutError') {
|
||||
throw new ApiError(t('Le serveur ne répond pas. Vérifiez la connexion et réessayez.'), 0, { error: 'request_timeout' })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const payload = await safeParseJson(text)
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ApiError, apiRequest } from './client'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('apiRequest', () => {
|
||||
it('une requête qui ne répond pas finit en erreur lisible', async () => {
|
||||
const timeout = vi.spyOn(AbortSignal, 'timeout')
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new DOMException('trop long', 'TimeoutError')))
|
||||
await expect(apiRequest('/trees')).rejects.toMatchObject({
|
||||
name: 'ApiError', message: 'Le serveur ne répond pas. Vérifiez la connexion et réessayez.', status: 0, payload: { error: 'request_timeout' },
|
||||
})
|
||||
expect(timeout).toHaveBeenCalledWith(30_000)
|
||||
})
|
||||
|
||||
it('un envoi de fichier a plus de temps, et un délai demandé passe devant', async () => {
|
||||
const timeout = vi.spyOn(AbortSignal, 'timeout')
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => new Response('{"ok":true}', { status: 200 })))
|
||||
await apiRequest('/upload', { method: 'POST', body: 'x'.repeat(200_000) })
|
||||
expect(timeout).toHaveBeenLastCalledWith(5 * 60_000)
|
||||
await apiRequest('/import', { method: 'POST', body: '{}', timeoutMs: 1234 })
|
||||
expect(timeout).toHaveBeenLastCalledWith(1234)
|
||||
expect(fetch.mock.calls[1][1]).not.toHaveProperty('timeoutMs')
|
||||
})
|
||||
|
||||
it('une autre panne réseau remonte telle quelle', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||
const error = await apiRequest('/trees').catch((e) => e)
|
||||
expect(error).toBeInstanceOf(TypeError)
|
||||
expect(error).not.toBeInstanceOf(ApiError)
|
||||
})
|
||||
})
|
||||
@@ -538,6 +538,8 @@ export async function importGedcom(treeId, fileContent, token) {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ content: fileContent }),
|
||||
// Un gros arbre se crée en une transaction d'une minute au plus côté serveur
|
||||
timeoutMs: 3 * 60_000,
|
||||
})
|
||||
return data
|
||||
}
|
||||
@@ -70,7 +70,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
const selectedPersonIdRef = useRef(null)
|
||||
const selectedGlowScales = useRef(new Map())
|
||||
const searchGlowScales = useRef(new Map())
|
||||
const searchHighlightIdsRef = useRef([])
|
||||
const searchHighlightSetRef = useRef(new Set())
|
||||
const immediateRelativesRef = useRef(new Set())
|
||||
const pathEdgesRef = useRef([])
|
||||
const pathNodesRef = useRef([])
|
||||
@@ -567,7 +567,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
|
||||
// Sync searchHighlightIds
|
||||
useEffect(() => {
|
||||
searchHighlightIdsRef.current = searchHighlightIds
|
||||
searchHighlightSetRef.current = new Set(searchHighlightIds.map(id => `p-${id}`))
|
||||
}, [searchHighlightIds])
|
||||
|
||||
// Recalculer les proches immédiats
|
||||
@@ -646,15 +646,6 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
if (!layoutData || !canvas) { rafId = requestAnimationFrame(animate); return }
|
||||
const { x, y, scale } = transformRef.current
|
||||
|
||||
// Positions animées
|
||||
const posMap = new Map()
|
||||
layoutData.children.forEach(node => {
|
||||
posMap.set(node.id, getAnimatedPos(node, timestamp))
|
||||
})
|
||||
|
||||
const nodeMap = new Map()
|
||||
layoutData.children.forEach(n => nodeMap.set(n.id, n))
|
||||
|
||||
// Lissage hover states
|
||||
const hoverEasing = 0.12
|
||||
layoutData.children.forEach(node => {
|
||||
@@ -667,11 +658,10 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
const pMedias = getPersonMedias(node._data.id)
|
||||
const orbitSlots = rd?.orbitSlots || []
|
||||
const orbitCount = Math.min(pMedias.length, MAX_ORBIT_MEDIAS)
|
||||
const pos = posMap.get(node.id)
|
||||
for (let i = 0; i < orbitCount; i++) {
|
||||
const key = `${node.id}:${i}`
|
||||
const slot = orbitSlots[i]
|
||||
if (!slot || !pos) continue
|
||||
if (!slot) continue
|
||||
const isHovered = hoveredMediaKey.current === key
|
||||
const prevMh = mediaHoverState.current.get(key) || { scale: 0, dx: 0, dy: 0 }
|
||||
const dirX = Math.cos(slot.angle)
|
||||
@@ -717,7 +707,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
|
||||
// Animation search highlight glow
|
||||
const searchEasing = 0.12
|
||||
const searchHighlightSet = new Set(searchHighlightIdsRef.current.map(id => `p-${id}`))
|
||||
const searchHighlightSet = searchHighlightSetRef.current
|
||||
layoutData.children.forEach(node => {
|
||||
if (node._type !== 'person') return
|
||||
const targetSearchGlow = searchHighlightSet.has(node.id) ? 1.2 : 0
|
||||
@@ -752,8 +742,6 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
const PATH_EDGE_DURATION = 300
|
||||
const pathElapsed = pathStartTime.current > 0 ? timestamp - pathStartTime.current : -1
|
||||
const pathEasing = 0.12
|
||||
const activePathEdges = new Set(pathEdgesRef.current.map(e => e.id))
|
||||
const activePathNodes = new Set(pathNodesRef.current)
|
||||
|
||||
for (let i = 0; i < pathEdgesRef.current.length; i++) {
|
||||
const e = pathEdgesRef.current[i]
|
||||
@@ -771,12 +759,17 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
if (pathProgressRef.current.get(n) !== staggeredTarget) { dirty = true; pathProgressRef.current.set(n, staggeredTarget) }
|
||||
}
|
||||
|
||||
for (const [key, val] of pathProgressRef.current) {
|
||||
if (!activePathEdges.has(key) && !activePathNodes.has(key)) {
|
||||
dirty = true
|
||||
const next = val + (0 - val) * pathEasing
|
||||
if (Math.abs(next) < 0.001) pathProgressRef.current.delete(key)
|
||||
else pathProgressRef.current.set(key, next)
|
||||
// Au repos, rien à éteindre : pas d'ensembles reconstruits à chaque frame
|
||||
if (pathProgressRef.current.size > 0) {
|
||||
const activePathEdges = new Set(pathEdgesRef.current.map(e => e.id))
|
||||
const activePathNodes = new Set(pathNodesRef.current)
|
||||
for (const [key, val] of pathProgressRef.current) {
|
||||
if (!activePathEdges.has(key) && !activePathNodes.has(key)) {
|
||||
dirty = true
|
||||
const next = val + (0 - val) * pathEasing
|
||||
if (Math.abs(next) < 0.001) pathProgressRef.current.delete(key)
|
||||
else pathProgressRef.current.set(key, next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -796,6 +789,15 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
// Rien n'a changé : on ne redessine pas
|
||||
if (!dirty) { rafId = requestAnimationFrame(animate); return }
|
||||
|
||||
// Positions animées, calculées seulement quand on redessine
|
||||
const posMap = new Map()
|
||||
layoutData.children.forEach(node => {
|
||||
posMap.set(node.id, getAnimatedPos(node, timestamp))
|
||||
})
|
||||
|
||||
const nodeMap = new Map()
|
||||
layoutData.children.forEach(n => nodeMap.set(n.id, n))
|
||||
|
||||
// === RENDU ===
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.setTransform(dprRef.current, 0, 0, dprRef.current, 0, 0)
|
||||
|
||||
@@ -100,4 +100,5 @@ export default {
|
||||
'Ressource introuvable.': 'Not found.',
|
||||
'Conflit détecté (email ou slug déjà utilisé).': 'Conflict detected (email or slug already in use).',
|
||||
"Erreur backend. Vérifiez que l'API et la base sont lancées.": 'Server error. Check that the API and database are running.',
|
||||
'Le serveur ne répond pas. Vérifiez la connexion et réessayez.': 'The server is not responding. Check your connection and try again.',
|
||||
}
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sauvegarde puis restauration d'une installation compose avec les commandes du README, et contrôle
|
||||
# que rien ne s'est perdu : comptes, arbres, personnes, migrations et fichiers des médias.
|
||||
#
|
||||
# Usage : bash tools/docker-restore-check.sh [http://localhost:8080] (depuis le dossier du dépôt, installation démarrée)
|
||||
# Nécessite curl et jq. La CI le lance après le test de fumée.
|
||||
# Attention : la base et les médias sont effacés puis restaurés. Jamais sur une installation en service.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
BASE_URL="${1:-http://localhost}"
|
||||
BASE_URL="${BASE_URL%/}"
|
||||
DB_USER="${POSTGRES_USER:-parenthese}"
|
||||
DB_NAME="${POSTGRES_DB:-parenthese}"
|
||||
MEDIA_VOLUME="parenthese_media"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
fail() { echo "ÉCHEC : $*" >&2; exit 1; }
|
||||
sql() { docker compose exec -T db psql -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 -At -c "$1"; }
|
||||
counts() { sql "SELECT (SELECT count(*) FROM users) || ' ' || (SELECT count(*) FROM trees) || ' ' || (SELECT count(*) FROM persons) || ' ' || (SELECT count(*) FROM _prisma_migrations)"; }
|
||||
|
||||
# Données à retrouver : un compte, un arbre et une personne créés par l'API, un fichier dans les médias
|
||||
api() {
|
||||
local method="$1" path="$2" body="$3" token="${4:-}"
|
||||
curl -fsS --max-time 30 -X "$method" "$BASE_URL$path" -H 'Content-Type: application/json' \
|
||||
${token:+-H "Authorization: Bearer $token"} --data "$body"
|
||||
}
|
||||
stamp="$(date +%s)-$RANDOM"
|
||||
token="$(api POST /auth/register "$(jq -nc --arg e "restore-$stamp@example.com" '{firstName: "Test", email: $e, password: "restauration-test-1"}')" | jq -r .token)"
|
||||
tree_id="$(api POST /trees "$(jq -nc --arg s "restore-$stamp" '{name: "Arbre de restauration", slug: $s, visitorPassword: "visiteur-test-1", contributorPassword: "contributeur-test-1"}')" "$token" | jq -r .tree.id)"
|
||||
api POST "/trees/$tree_id/persons" '{"firstName": "Jeanne", "lastName": "Test"}' "$token" >/dev/null
|
||||
docker run --rm -v "$MEDIA_VOLUME":/data alpine sh -c 'echo "souvenir de test" > /data/restore-check.txt'
|
||||
|
||||
before="$(counts)"
|
||||
read -r users trees persons _ <<< "$before"
|
||||
[ "$users" -ge 1 ] && [ "$trees" -ge 1 ] && [ "$persons" -ge 1 ] || fail "données de test absentes avant la sauvegarde ($before)"
|
||||
echo "Avant : $before (comptes, arbres, personnes, migrations)"
|
||||
|
||||
# Sauvegarde, mêmes commandes que le README (fichiers écrits dans un dossier temporaire)
|
||||
DUMP="$WORK/parenthese-db-$(date +%F).dump"
|
||||
ARCHIVE="parenthese-media-$(date +%F).tar.gz"
|
||||
docker compose exec -T db pg_dump -U "$DB_USER" -Fc "$DB_NAME" > "$DUMP"
|
||||
docker run --rm -v "$MEDIA_VOLUME":/data -v "$WORK":/backup alpine tar -czf "/backup/$ARCHIVE" -C /data .
|
||||
[ -s "$DUMP" ] || fail "dump vide"
|
||||
|
||||
# Perte simulée : base vidée, médias effacés
|
||||
docker compose stop api
|
||||
sql "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" >/dev/null
|
||||
docker run --rm -v "$MEDIA_VOLUME":/data alpine find /data -mindepth 1 -delete
|
||||
[ "$(sql "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public'")" = 0 ] || fail "la base n'a pas été vidée"
|
||||
|
||||
# Restauration, mêmes commandes que le README
|
||||
docker compose exec -T db pg_restore -U "$DB_USER" -d "$DB_NAME" --clean --if-exists < "$DUMP"
|
||||
docker run --rm -v "$MEDIA_VOLUME":/data -v "$WORK":/backup alpine tar -xzf "/backup/$ARCHIVE" -C /data
|
||||
|
||||
after="$(counts)"
|
||||
echo "Après : $after"
|
||||
[ "$after" = "$before" ] || fail "la restauration ne rend pas les mêmes données ($before avant, $after après)"
|
||||
[ "$(docker run --rm -v "$MEDIA_VOLUME":/data alpine cat /data/restore-check.txt)" = "souvenir de test" ] || fail "fichier des médias absent après restauration"
|
||||
docker run --rm -v "$MEDIA_VOLUME":/data alpine rm -f /data/restore-check.txt
|
||||
|
||||
docker compose start api
|
||||
echo "OK : sauvegarde et restauration du README vérifiées"
|
||||
Reference in new issue
Block a user