From 82f888b1dc6d7156b2fa7f4fbd69de5916ee70be Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Sun, 18 Oct 2026 23:52:05 +0200 Subject: [PATCH 1/8] perf(gedcom): notes et sources partagees lues une fois par import Un fichier dont chaque fiche cite une note ou une source enorme faisait relire ce texte pour chaque personne : environ 35 min de calcul pour 20 000 fiches, 0,1 s maintenant (memo par noeud, pour la duree de l'import). - La lecture des medias ne descend que dans les branches qui menent a un fichier, avec un budget par personne et une profondeur bornee. - Les niveaux au-dela de 99 (limite de la norme) sont ignores : une chaine de sources faisait deborder la pile. - Les notes ne sont calculees que pour une personne creee, pas reutilisee. Co-Authored-By: Claude Opus 5.5 --- backend/src/lib/gedcom-photos.ts | 72 +++++++++++++++++++++++++------ backend/src/routes/gedcom.ts | 25 ++++------- backend/test/gedcom-media.test.ts | 9 ++++ 3 files changed, 76 insertions(+), 30 deletions(-) diff --git a/backend/src/lib/gedcom-photos.ts b/backend/src/lib/gedcom-photos.ts index d790a82..9388577 100644 --- a/backend/src/lib/gedcom-photos.ts +++ b/backend/src/lib/gedcom-photos.ts @@ -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() +const recordTitleCache = new WeakMap() +const hasMediaCache = new WeakMap() +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 } diff --git a/backend/src/routes/gedcom.ts b/backend/src/routes/gedcom.ts index 5fe070c..00cb2c7 100644 --- a/backend/src/routes/gedcom.ts +++ b/backend/src/routes/gedcom.ts @@ -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,7 @@ 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 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 +95,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 +140,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 } @@ -675,8 +666,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 +682,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 +710,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 }) } diff --git a/backend/test/gedcom-media.test.ts b/backend/test/gedcom-media.test.ts index 86923e0..5b1aa26 100644 --- a/backend/test/gedcom-media.test.ts +++ b/backend/test/gedcom-media.test.ts @@ -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) + }) }) From f20b58be32ee6f4b1088cd201d1024712ddad19f Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Sun, 18 Oct 2026 23:54:35 +0200 Subject: [PATCH 2/8] fix(api): ecritures en plusieurs temps dans une transaction Une erreur au milieu laissait un etat partiel, et un nouvel essai du client recreait des doublons. - Lot d'annotations : creations, modifications, suppressions et trace. - Moments : session de contribution, moment, souvenirs rattaches et trace ; modification et suppression d'un moment avec ses souvenirs. - Reordonnancement des souvenirs d'une personne. - La famille ne rattache a son moment que des souvenirs libres : elle ne detache plus un souvenir du moment d'un administrateur. Co-Authored-By: Claude Opus 5.5 --- backend/src/routes/annotations.ts | 111 ++++++++++++++------------- backend/src/routes/media.ts | 55 +++++++------- backend/src/routes/trees.ts | 121 ++++++++++++++++-------------- backend/test/api-routes.test.ts | 104 +++++++++++++++++++++++++ 4 files changed, 256 insertions(+), 135 deletions(-) diff --git a/backend/src/routes/annotations.ts b/backend/src/routes/annotations.ts index 8429a91..21f547d 100644 --- a/backend/src/routes/annotations.ts +++ b/backend/src/routes/annotations.ts @@ -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 = {} + 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 = {} - 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 }) diff --git a/backend/src/routes/media.ts b/backend/src/routes/media.ts index 9d1bf4e..99cee28 100644 --- a/backend/src/routes/media.ts +++ b/backend/src/routes/media.ts @@ -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 }) diff --git a/backend/src/routes/trees.ts b/backend/src/routes/trees.ts index a549262..3ce83c1 100644 --- a/backend/src/routes/trees.ts +++ b/backend/src/routes/trees.ts @@ -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() }) diff --git a/backend/test/api-routes.test.ts b/backend/test/api-routes.test.ts index 8fa3317..82c6da3 100644 --- a/backend/test/api-routes.test.ts +++ b/backend/test/api-routes.test.ts @@ -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,107 @@ 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) => 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) => 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) => 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) => 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('Visit log', () => { it('refuses the visit log to a non-admin member', async () => { const app = createApp() From 93307e41abadc2d898f5d329ab7a8714a79bceb9 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Sun, 18 Oct 2026 23:58:17 +0200 Subject: [PATCH 3/8] perf(arbre): la boucle d'animation ne recalcule plus rien au repos Positions des noeuds et ensembles de recherche ou de chemin etaient recalcules 60 fois par seconde meme quand rien ne bougeait. Ils ne le sont plus que pour une image a redessiner (ensemble de recherche construit a son changement). Co-Authored-By: Claude Opus 5.5 --- frontend/src/components/Galaxy.jsx | 46 ++++++++++++++++-------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx index f1628ab..85f5574 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -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) From 51232d81503922b120ef3d847442fe532290c980 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Sun, 18 Oct 2026 23:58:17 +0200 Subject: [PATCH 4/8] fix(app): delai maximal sur les appels a l'API Un reseau mobile qui decroche laissait l'app en chargement pour toujours. 30 s par appel, 5 min pour un envoi de fichier (corps de plus de 100 Ko), 3 min pour un import GEDCOM. Le message dit que le serveur ne repond pas. Co-Authored-By: Claude Opus 5.5 --- frontend/src/api/client.js | 37 +++++++++++++++++++++++++++------ frontend/src/api/client.test.js | 36 ++++++++++++++++++++++++++++++++ frontend/src/api/treeApi.js | 2 ++ frontend/src/i18n/en/app.js | 1 + 4 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 frontend/src/api/client.test.js diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 090df6a..c4310a7 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -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) { diff --git a/frontend/src/api/client.test.js b/frontend/src/api/client.test.js new file mode 100644 index 0000000..f5a3de7 --- /dev/null +++ b/frontend/src/api/client.test.js @@ -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) + }) +}) diff --git a/frontend/src/api/treeApi.js b/frontend/src/api/treeApi.js index 58fa8b5..ca950b5 100644 --- a/frontend/src/api/treeApi.js +++ b/frontend/src/api/treeApi.js @@ -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 } diff --git a/frontend/src/i18n/en/app.js b/frontend/src/i18n/en/app.js index 4681889..885661f 100644 --- a/frontend/src/i18n/en/app.js +++ b/frontend/src/i18n/en/app.js @@ -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.', } From 9c84bd0efcb4cc70498a470204f14550c6bd1f2e Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 00:00:36 +0200 Subject: [PATCH 5/8] fix(visites): duree de conservation d'un an tenue, comptes supprimes anonymises La purge des visites de plus d'un an ne passait qu'a l'ouverture de l'onglet Visites par un administrateur : un arbre jamais consulte gardait tout. Elle passe aussi a l'enregistrement d'une visite, au plus une fois par heure, pour tous les arbres. A la suppression d'un compte, ses visites d'autres arbres gardaient son identifiant dans la cle du visiteur : elles restent comptees, sous une cle anonyme. Co-Authored-By: Claude Opus 5.5 --- backend/src/lib/tree-visits.ts | 18 +++++++++++++++- backend/src/routes/auth.ts | 7 ++++++ backend/test/data-deletion.test.ts | 7 ++++++ backend/test/tree-visits.test.ts | 34 ++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 backend/test/tree-visits.test.ts diff --git a/backend/src/lib/tree-visits.ts b/backend/src/lib/tree-visits.ts index e2a1098..84dd2d7 100644 --- a/backend/src/lib/tree-visits.ts +++ b/backend/src/lib/tree-visits.ts @@ -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 { + 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') } diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 5ede013..8b0adeb 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -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 }) diff --git a/backend/test/data-deletion.test.ts b/backend/test/data-deletion.test.ts index b3ea86c..de7be4f 100644 --- a/backend/test/data-deletion.test.ts +++ b/backend/test/data-deletion.test.ts @@ -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). diff --git a/backend/test/tree-visits.test.ts b/backend/test/tree-visits.test.ts new file mode 100644 index 0000000..1a800cb --- /dev/null +++ b/backend/test/tree-visits.test.ts @@ -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) + }) +}) From 1504a1e1244e5c9c73d83ddfa9308e0c6398266d Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 00:02:18 +0200 Subject: [PATCH 6/8] fix(gedcom): import sans union d'une personne avec elle-meme ni boucle parent-enfant Un fichier abime pouvait marier une personne a elle-meme ou faire de quelqu'un son propre ancetre, ce que l'API refuse partout ailleurs. Le controle se fait en memoire pendant l'import (les validateurs relisent tout l'arbre a chaque lien), et la reponse compte les relations ecartees (relationsSkipped). Premier test de la route d'import. Co-Authored-By: Claude Opus 5.5 --- backend/src/routes/gedcom.ts | 36 ++++++++++++++++++++++++++++++--- backend/test/api-routes.test.ts | 34 +++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/backend/src/routes/gedcom.ts b/backend/src/routes/gedcom.ts index 00cb2c7..3419d8f 100644 --- a/backend/src/routes/gedcom.ts +++ b/backend/src/routes/gedcom.ts @@ -21,6 +21,8 @@ const importBodySchema = z.object({ // 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 @@ -636,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() + 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() + 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)])) @@ -722,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 @@ -768,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, @@ -779,6 +808,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { }, }) linkKeys.add(linkKey) + parentsOf.set(childId, [...(parentsOf.get(childId) ?? []), parentId]) linksCreated++ } } @@ -792,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 }) @@ -802,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 }) }) } diff --git a/backend/test/api-routes.test.ts b/backend/test/api-routes.test.ts index 82c6da3..cb0a1d9 100644 --- a/backend/test/api-routes.test.ts +++ b/backend/test/api-routes.test.ts @@ -857,6 +857,40 @@ describe('Écritures en plusieurs temps : tout passe par une transaction', () => }) }) +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() From 217d503e20af29d74cf67ed6457a6aa85e56100c Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 00:04:22 +0200 Subject: [PATCH 7/8] fix(sauvegardes): restauration testee en CI, copie de la base avant migration en tout-en-un - La CI joue les commandes de sauvegarde et de restauration du README sur une vraie installation compose (base videe, medias effaces) et verifie comptes, arbres, personnes, migrations et fichiers (tools/docker-restore-check.sh). - README : restauration avec --if-exists, sinon pg_restore echoue sur une base vide. Sauvegarde avant chaque mise a jour, dite clairement. - Image tout-en-un : copie de la base dans /data/backups avant d'appliquer une migration (5 gardees), demarrage arrete si la copie echoue. - backup.sh : l'empreinte d'une archive de medias gardee disparaissait avec la rotation des fichiers d'empreintes, elle est recalculee. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 10 +++++++ README.fr.md | 4 +-- README.md | 4 +-- deploy/aio/entrypoint.sh | 16 +++++++++++ deploy/backup.sh | 9 +++---- tools/docker-restore-check.sh | 51 +++++++++++++++++++++++++++++++++++ 6 files changed, 84 insertions(+), 10 deletions(-) create mode 100755 tools/docker-restore-check.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75a3631..8edfe70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 + 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() diff --git a/README.fr.md b/README.fr.md index 910b661..8533931 100644 --- a/README.fr.md +++ b/README.fr.md @@ -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 diff --git a/README.md b/README.md index 1d428d2..707f57a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/deploy/aio/entrypoint.sh b/deploy/aio/entrypoint.sh index edf3efe..4025ec7 100755 --- a/deploy/aio/entrypoint.sh +++ b/deploy/aio/entrypoint.sh @@ -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 diff --git a/deploy/backup.sh b/deploy/backup.sh index d852bf8..9b29689 100644 --- a/deploy/backup.sh +++ b/deploy/backup.sh @@ -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..." diff --git a/tools/docker-restore-check.sh b/tools/docker-restore-check.sh new file mode 100755 index 0000000..8b686e0 --- /dev/null +++ b/tools/docker-restore-check.sh @@ -0,0 +1,51 @@ +#!/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 (depuis le dossier du dépôt, installation démarrée) +# La CI le lance après le test de fumée, qui a déjà créé un compte et un arbre. +# 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")/.." +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)"; } + +docker run --rm -v "$MEDIA_VOLUME":/data alpine sh -c 'echo "souvenir de test" > /data/restore-check.txt' +before="$(counts)" +read -r users trees _ _ <<< "$before" +[ "$users" -ge 1 ] && [ "$trees" -ge 1 ] || fail "il faut au moins un compte et un arbre avant la sauvegarde (lancer d'abord tools/docker-smoke.sh)" +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" From 6fa86d67160259af447ae7824c52770f037f6f30 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 00:11:28 +0200 Subject: [PATCH 8/8] fix(ci): le controle de restauration cree ses propres donnees Le test de fumee supprime son arbre a la fin : le controle partait d'une base sans arbre. Il cree maintenant un compte, un arbre et une personne par l'API. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- tools/docker-restore-check.sh | 21 +++++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8edfe70..59eac7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -186,7 +186,7 @@ jobs: - name: Sauvegarde puis restauration run: | set -euo pipefail - bash tools/docker-restore-check.sh + 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. diff --git a/tools/docker-restore-check.sh b/tools/docker-restore-check.sh index 8b686e0..3148bf0 100755 --- a/tools/docker-restore-check.sh +++ b/tools/docker-restore-check.sh @@ -2,12 +2,14 @@ # 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 (depuis le dossier du dépôt, installation démarrée) -# La CI le lance après le test de fumée, qui a déjà créé un compte et un arbre. +# 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" @@ -18,10 +20,21 @@ 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 _ _ <<< "$before" -[ "$users" -ge 1 ] && [ "$trees" -ge 1 ] || fail "il faut au moins un compte et un arbre avant la sauvegarde (lancer d'abord tools/docker-smoke.sh)" +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)