From eebb8f2cb1fe4c4a5ff893a676f6c832f838772b Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Sun, 25 Oct 2026 20:04:13 +0100 Subject: [PATCH] =?UTF-8?q?Propositions=20de=20la=20famille=20:=20photos?= =?UTF-8?q?=20pos=C3=A9es=20sur=20l'arbre=20en=20attente=20de=20relecture,?= =?UTF-8?q?=20annotations=20pos=C3=A9es=20par=20le=20serveur=20=C3=A0=20l'?= =?UTF-8?q?acceptation,=20=C3=A9tat=20d'une=20proposition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../migration.sql | 16 ++ backend/prisma/schema.prisma | 73 ++++--- backend/src/lib/annotation-changes.ts | 194 ++++++++++++++++++ backend/src/routes/contributions.ts | 43 +++- backend/src/routes/media.ts | 24 ++- backend/test/propositions-fantomes.db.test.ts | 157 ++++++++++++++ 6 files changed, 471 insertions(+), 36 deletions(-) create mode 100644 backend/prisma/migrations/20261025_003_photos_famille_arbre/migration.sql create mode 100644 backend/src/lib/annotation-changes.ts create mode 100644 backend/test/propositions-fantomes.db.test.ts diff --git a/backend/prisma/migrations/20261025_003_photos_famille_arbre/migration.sql b/backend/prisma/migrations/20261025_003_photos_famille_arbre/migration.sql new file mode 100644 index 0000000..0f96810 --- /dev/null +++ b/backend/prisma/migrations/20261025_003_photos_famille_arbre/migration.sql @@ -0,0 +1,16 @@ +-- Photos posées sur l'arbre par la famille, en attente de relecture (1.5.1). Migration additive, une table neuve. +-- CreateTable +CREATE TABLE "annotation_photo_uploads" ( + "id" TEXT NOT NULL, + "treeId" TEXT NOT NULL, + "filePath" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "annotation_photo_uploads_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "annotation_photo_uploads_treeId_createdAt_idx" ON "annotation_photo_uploads"("treeId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "annotation_photo_uploads" ADD CONSTRAINT "annotation_photo_uploads_treeId_fkey" FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 581350e..efe2104 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -121,36 +121,37 @@ model UserIdentity { } model Tree { - id String @id @default(cuid()) - ownerUserId String - slug String @unique - name String - description String? + id String @id @default(cuid()) + ownerUserId String + slug String @unique + name String + description String? // Icône choisie pour l'arbre (un emoji), à la place de la première lettre dans les menus ; null = la lettre - icon String? - rootPersonId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? - owner User @relation("TreeOwner", fields: [ownerUserId], references: [id], onDelete: Restrict) - accessPasswords TreeAccessPasswords? - sharePassword TreeSharePassword? - memberships TreeMembership[] - sharedUserAccesses UserTreeAccess[] - settings TreeSettings? - immichConfig TreeImmichConfig? - persons Person[] - unions Union[] - parentChildLinks ParentChildLink[] - mediaItems MediaItem[] - mediaPersonTags MediaPersonTag[] - contributionSessions ContributionSession[] - auditLogs AuditLog[] - annotations Annotation[] - visits TreeVisit[] - lifeEvents LifeEvent[] - invitations TreeInvitation[] - lastOpenedByUsers User[] @relation("UserLastOpenedTree") + icon String? + rootPersonId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + owner User @relation("TreeOwner", fields: [ownerUserId], references: [id], onDelete: Restrict) + accessPasswords TreeAccessPasswords? + sharePassword TreeSharePassword? + memberships TreeMembership[] + sharedUserAccesses UserTreeAccess[] + settings TreeSettings? + immichConfig TreeImmichConfig? + persons Person[] + unions Union[] + parentChildLinks ParentChildLink[] + mediaItems MediaItem[] + mediaPersonTags MediaPersonTag[] + contributionSessions ContributionSession[] + auditLogs AuditLog[] + annotations Annotation[] + visits TreeVisit[] + lifeEvents LifeEvent[] + invitations TreeInvitation[] + annotationPhotoUploads AnnotationPhotoUpload[] + lastOpenedByUsers User[] @relation("UserLastOpenedTree") @@index([ownerUserId]) @@map("trees") @@ -214,6 +215,20 @@ model TreeInvitation { @@map("tree_invitations") } +// Photo posée sur l'arbre par la famille, en attente de relecture (1.5.1). La ligne vit tant que la photo n'est +// ni acceptée (ligne retirée, fichier gardé) ni refusée (fichier et ligne effacés). Un envoi jamais proposé est +// effacé après 7 jours, la durée d'un brouillon. Les photos des gestionnaires n'ont pas de ligne. +model AnnotationPhotoUpload { + id String @id + treeId String + filePath String + createdAt DateTime @default(now()) + tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade) + + @@index([treeId, createdAt]) + @@map("annotation_photo_uploads") +} + model UserTreeAccess { id String @id @default(cuid()) treeId String diff --git a/backend/src/lib/annotation-changes.ts b/backend/src/lib/annotation-changes.ts new file mode 100644 index 0000000..2175bfe --- /dev/null +++ b/backend/src/lib/annotation-changes.ts @@ -0,0 +1,194 @@ +import path from 'node:path' + +import { Prisma, type PrismaClient } from '@prisma/client' +import { z } from 'zod' + +import { InvalidAnnotationContentError, sanitizeAnnotationContentForWrite } from './annotation-content.js' + +// Annotations proposées par la famille (dessin, texte, sticker, photo posée sur l'arbre). Avant la 1.5.1, le +// serveur les laissait au navigateur du relecteur, qui les posait après coup : une proposition acceptée pouvait +// ne jamais apparaître (onglet fermé, erreur réseau), et rien n'apparaissait en mode « direct ». Elles sont +// maintenant posées dans la transaction qui accepte la proposition. + +type Tx = Prisma.TransactionClient +type Db = PrismaClient | Tx + +export const MAX_ANNOTATIONS_PER_TREE = 5000 +// Un envoi de photo jamais proposé vit le temps d'un brouillon de la famille +export const PENDING_PHOTO_TTL_MS = 7 * 24 * 60 * 60 * 1000 +// Une famille pose quelques photos à la fois ; au-delà, c'est un envoi en boucle +export const MAX_PENDING_PHOTOS_PER_TREE = 50 + +const MAX_STYLE_CHARS = 4000 +const styleSchema = z.record(z.string(), z.unknown()).refine( + (value) => JSON.stringify(value).length <= MAX_STYLE_CHARS, + { message: 'style_too_large' }, +) + +const annotationCreateSchema = z.object({ + type: z.enum(['drawing', 'sticker', 'text', 'photo']), + x: z.number().finite(), + y: z.number().finite(), + content: z.string().min(1).max(50000), + style: styleSchema.optional(), + zIndex: z.number().int().optional(), +}) + +const annotationUpdateSchema = z.object({ + x: z.number().finite().optional(), + y: z.number().finite().optional(), + content: z.string().min(1).max(50000).optional(), + style: styleSchema.optional(), + zIndex: z.number().int().optional(), +}) + +export class AnnotationChangeError extends Error { + constructor(public readonly code: string) { + super(code) + } +} + +type AnnotationChange = { action: string; entityId?: string | null; after?: unknown } + +// Contenu vérifié comme à l'écriture directe : une photo doit être une photo de cet arbre +function cleanContent(content: string, treeId: string): string { + try { + return sanitizeAnnotationContentForWrite(content, treeId) + } catch (error) { + if (error instanceof InvalidAnnotationContentError) throw new AnnotationChangeError('invalid_annotation_photo_path') + throw error + } +} + +// Vérifie une proposition d'annotation dès l'envoi, pour qu'une relecture n'échoue pas sur un contenu refusé +export function validateAnnotationChange(change: AnnotationChange, treeId: string): void { + if (change.action === 'create') { + const parsed = annotationCreateSchema.safeParse(change.after) + if (!parsed.success) throw new AnnotationChangeError('invalid_annotation_change') + cleanContent(parsed.data.content, treeId) + return + } + if (change.action === 'update') { + if (!change.entityId) throw new AnnotationChangeError('invalid_annotation_change') + const parsed = annotationUpdateSchema.safeParse(change.after) + if (!parsed.success) throw new AnnotationChangeError('invalid_annotation_change') + if (parsed.data.content !== undefined) cleanContent(parsed.data.content, treeId) + return + } + if (change.action === 'delete') { + if (!change.entityId) throw new AnnotationChangeError('invalid_annotation_change') + return + } + throw new AnnotationChangeError('unsupported_change_action_for_annotation') +} + +// Identifiant de la photo d'une annotation (contenu JSON { photoPath }), ou null +export function annotationPhotoId(after: unknown, treeId: string): string | null { + if (!after || typeof after !== 'object' || Array.isArray(after)) return null + const content = (after as { content?: unknown }).content + if (typeof content !== 'string' || !content.startsWith('{')) return null + try { + const parsed = JSON.parse(content) as { photoPath?: unknown } + const prefix = `/trees/${treeId}/annotation-photos/` + if (typeof parsed.photoPath !== 'string' || !parsed.photoPath.startsWith(prefix)) return null + const photoId = parsed.photoPath.slice(prefix.length) + return /^[A-Za-z0-9-]+$/.test(photoId) ? photoId : null + } catch { + return null + } +} + +// Pose une annotation acceptée. La photo d'une famille devient définitive : sa ligne d'attente disparaît. +export async function applyAnnotationChange(tx: Tx, treeId: string, change: AnnotationChange, actorUserId: string | null): Promise<{ entityId: string | null }> { + validateAnnotationChange(change, treeId) + + if (change.action === 'create') { + const data = annotationCreateSchema.parse(change.after) + const count = await tx.annotation.count({ where: { treeId, deletedAt: null } }) + if (count + 1 > MAX_ANNOTATIONS_PER_TREE) throw new AnnotationChangeError('annotation_limit_reached') + const created = await tx.annotation.create({ + data: { + treeId, + type: data.type, + x: data.x, + y: data.y, + content: cleanContent(data.content, treeId), + style: (data.style ?? {}) as Prisma.InputJsonValue, + zIndex: data.zIndex ?? 0, + createdBy: actorUserId, + }, + select: { id: true }, + }) + const photoId = annotationPhotoId(change.after, treeId) + if (photoId) await tx.annotationPhotoUpload.deleteMany({ where: { id: photoId, treeId } }) + return { entityId: created.id } + } + + if (change.action === 'update') { + const data = annotationUpdateSchema.parse(change.after) + const patch: Prisma.AnnotationUpdateManyMutationInput = {} + if (data.x !== undefined) patch.x = data.x + if (data.y !== undefined) patch.y = data.y + if (data.content !== undefined) patch.content = cleanContent(data.content, treeId) + if (data.style !== undefined) patch.style = data.style as Prisma.InputJsonValue + if (data.zIndex !== undefined) patch.zIndex = data.zIndex + if (Object.keys(patch).length > 0) { + await tx.annotation.updateMany({ where: { id: change.entityId as string, treeId, deletedAt: null }, data: patch }) + } + const photoId = annotationPhotoId(change.after, treeId) + if (photoId) await tx.annotationPhotoUpload.deleteMany({ where: { id: photoId, treeId } }) + return { entityId: change.entityId ?? null } + } + + await tx.annotation.updateMany({ + where: { id: change.entityId as string, treeId, deletedAt: null }, + data: { deletedAt: new Date() }, + }) + return { entityId: change.entityId ?? null } +} + +// Une proposition encore en attente cite-t-elle cette photo ? (en dehors d'une session donnée) +async function isPhotoInPendingProposal(db: Db, treeId: string, photoId: string, excludeSessionId?: string): Promise { + const needle = `%/trees/${treeId}/annotation-photos/${photoId}%` + const rows = await db.$queryRaw>` + SELECT c.id FROM contribution_changes c + JOIN contribution_sessions s ON s.id = c."sessionId" + WHERE s."treeId" = ${treeId} AND s.status = 'pending' AND c."entityType" = 'annotation' + AND c."afterJson"::text LIKE ${needle} + AND (${excludeSessionId ?? null}::text IS NULL OR s.id <> ${excludeSessionId ?? null}::text) + LIMIT 1` + return rows.length > 0 +} + +// Photo d'une famille refusée : son fichier est à effacer s'il n'est cité par aucune autre proposition en +// attente. Renvoie les chemins à effacer une fois la transaction validée. +export async function releaseRejectedPhoto(tx: Tx, treeId: string, after: unknown, sessionId: string): Promise { + const photoId = annotationPhotoId(after, treeId) + if (!photoId) return [] + const upload = await tx.annotationPhotoUpload.findFirst({ where: { id: photoId, treeId }, select: { filePath: true } }) + if (!upload) return [] + if (await isPhotoInPendingProposal(tx, treeId, photoId, sessionId)) return [] + await tx.annotationPhotoUpload.delete({ where: { id: photoId } }) + return [upload.filePath] +} + +// Envois de photos jamais proposés depuis 7 jours : effacés. Appelé à chaque nouvel envoi de la famille, sur +// cet arbre seulement. Renvoie les chemins à effacer. +export async function sweepStalePendingPhotos(db: Db, treeId: string, now = new Date()): Promise { + const stale = await db.annotationPhotoUpload.findMany({ + where: { treeId, createdAt: { lt: new Date(now.getTime() - PENDING_PHOTO_TTL_MS) } }, + select: { id: true, filePath: true }, + }) + const removed: string[] = [] + for (const upload of stale) { + if (await isPhotoInPendingProposal(db, treeId, upload.id)) continue + await db.annotationPhotoUpload.delete({ where: { id: upload.id } }) + removed.push(upload.filePath) + } + return removed +} + +// Chemin relatif sûr d'une photo d'annotation (sous /annotation-photos/) +export function annotationPhotoRelativePath(treeId: string, photoId: string, extension: string): string { + return path.posix.join(treeId, 'annotation-photos', `${photoId}${extension}`) +} diff --git a/backend/src/routes/contributions.ts b/backend/src/routes/contributions.ts index 3b62127..c21347d 100644 --- a/backend/src/routes/contributions.ts +++ b/backend/src/routes/contributions.ts @@ -5,8 +5,10 @@ import { Prisma } from '@prisma/client' import { z } from 'zod' import { env, UPLOAD_BODY_LIMIT } from '../config/env.js' import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js' +import { AnnotationChangeError, applyAnnotationChange, releaseRejectedPhoto, validateAnnotationChange } from '../lib/annotation-changes.js' import { softDeletePersonCascade, softDeleteUnionCascade } from '../lib/entity-deletion.js' import { removeStoredFile } from '../lib/media-files.js' +import { canReadTree } from '../lib/tree-access.js' import { syncPersonMediaOrder } from '../lib/media-order.js' import { mediaPeopleSchema, normalizeMediaPeople, replaceMediaPeople } from '../lib/media-people.js' @@ -325,10 +327,15 @@ async function applyContributionChange( // Fichiers à effacer une fois la transaction validée (suppression d'une personne) filesToRemove: string[] = [], ): Promise<{ entityId: string | null }> { - // Les annotations sont appliquées par le client après la relecture (batchAnnotations) : - // rien à faire ici. Avant, elles tombaient dans la branche des liens et faisaient échouer la relecture. + // Annotation (dessin, texte, sticker, photo posée sur l'arbre) : posée ici, dans la transaction qui accepte + // la proposition. Avant la 1.5.1, le navigateur du relecteur la posait après coup, et pouvait la perdre. if (change.entityType === 'annotation') { - return { entityId: change.entityId ?? null } + try { + return await applyAnnotationChange(tx, treeId, { action: change.action, entityId: change.entityId, after: change.after }, actorUserId) + } catch (error) { + if (error instanceof AnnotationChangeError) throw new ContributionRouteError(400, error.code) + throw error + } } // Moment proposé par la famille : il existe déjà, on le rend visible @@ -1214,6 +1221,17 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'media_changes_not_allowed' }) } + // Une annotation refusée à l'écriture le serait aussi à la relecture : on le dit tout de suite + for (const change of payload.data.changes) { + if (change.entityType !== 'annotation') continue + try { + validateAnnotationChange(change, params.data.id) + } catch (error) { + if (error instanceof AnnotationChangeError) return reply.code(400).send({ error: error.code }) + throw error + } + } + try { const context = await resolveSubmissionContext(app, request.actor, params.data.id) const now = new Date() @@ -1374,6 +1392,21 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => { } }) + // État d'une proposition, pour la personne qui l'a envoyée : ses fantômes « en attente » s'effacent quand elle + // est relue. Seulement le statut, à qui lit l'arbre et connaît l'identifiant (rendu à l'envoi). + app.get('/trees/:id/contributions/sessions/:sessionId/status', { config: { rateLimit: { max: 120, timeWindow: '1 minute' } } }, async (request, reply) => { + const params = sessionParamsSchema.safeParse(request.params) + if (!params.success) return reply.code(400).send({ error: 'invalid_params' }) + if (!request.actor) return reply.code(401).send({ error: 'authentication_required' }) + if (!(await canReadTree(app, params.data.id, request.actor))) return reply.code(403).send({ error: 'forbidden' }) + const session = await app.prisma.contributionSession.findFirst({ + where: { id: params.data.sessionId, treeId: params.data.id }, + select: { status: true }, + }) + if (!session) return reply.code(404).send({ error: 'contribution_session_not_found' }) + return reply.send({ status: session.status }) + }) + app.post('/trees/:id/contributions/sessions/:sessionId/review', async (request, reply) => { const params = sessionParamsSchema.safeParse(request.params) if (!params.success) { @@ -1482,6 +1515,10 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => { data: { status: 'rejected', deletedAt: new Date() }, }) } + // Photo posée sur l'arbre par la famille et refusée : son fichier part avec elle + if (change.entityType === 'annotation') { + filesToRemove.push(...await releaseRejectedPhoto(tx, params.data.id, change.afterJson, session.id)) + } if (change.entityType === 'media' && change.entityId) { await tx.mediaItem.updateMany({ where: { id: change.entityId, treeId: params.data.id }, diff --git a/backend/src/routes/media.ts b/backend/src/routes/media.ts index 535f3ac..2f9b33c 100644 --- a/backend/src/routes/media.ts +++ b/backend/src/routes/media.ts @@ -25,6 +25,7 @@ import { resolveTreeMediaDirectory } from '../lib/tree-purge.js' import { canReadTree, canReadTreeWithMediaToken } from '../lib/tree-access.js' import type { MembershipRole } from '../types/auth.js' import { ContributionRouteError, resolveSubmissionContext } from './contributions.js' +import { MAX_PENDING_PHOTOS_PER_TREE, sweepStalePendingPhotos } from '../lib/annotation-changes.js' const MAX_IMAGE_BYTES = 5 * 1024 * 1024 const MAX_FILE_BYTES = 20 * 1024 * 1024 @@ -1123,8 +1124,10 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { photoId: z.string().min(1), }) + // Photo posée sur l'arbre. Un gestionnaire la pose tout de suite ; la famille (lien de partage ou membre) la + // propose : le fichier attend la relecture, noté dans annotation_photo_uploads (lib/annotation-changes.ts). app.post('/trees/:id/annotation-photos', UPLOAD_RATE_LIMIT, async (request, reply) => { - if (!request.actor || request.actor.kind !== 'user') { + if (!request.actor) { return reply.code(401).send({ error: 'authentication_required' }) } @@ -1138,9 +1141,19 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() }) } - const membership = await requireAdminMembership(app, request.actor.userId, params.data.id) - if (!membership) { - return reply.code(403).send({ error: 'forbidden' }) + const isManager = request.actor.kind === 'user' && Boolean(await requireAdminMembership(app, request.actor.userId, params.data.id)) + if (!isManager) { + try { + await resolveSubmissionContext(app, request.actor, params.data.id) + } catch (error) { + if (error instanceof ContributionRouteError) return reply.code(error.statusCode).send({ error: error.code }) + throw error + } + const stale = await sweepStalePendingPhotos(app.prisma, params.data.id) + await Promise.all(stale.map((filePath) => removeStoredFile(getStorageRoot(), filePath))) + if (await app.prisma.annotationPhotoUpload.count({ where: { treeId: params.data.id } }) >= MAX_PENDING_PHOTOS_PER_TREE) { + return reply.code(429).send({ error: 'too_many_pending_photos' }) + } } const fileBuffer = Buffer.from(stripDataUrlPrefix(payload.data.dataBase64), 'base64') @@ -1182,6 +1195,9 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { await mkdir(path.dirname(absolutePath), { recursive: true }) await writeFile(absolutePath, prepared.buffer) + if (!isManager) { + await app.prisma.annotationPhotoUpload.create({ data: { id: photoId, treeId: params.data.id, filePath: relativePath } }) + } return reply.code(201).send({ photoId, diff --git a/backend/test/propositions-fantomes.db.test.ts b/backend/test/propositions-fantomes.db.test.ts new file mode 100644 index 0000000..9ba84dc --- /dev/null +++ b/backend/test/propositions-fantomes.db.test.ts @@ -0,0 +1,157 @@ +import { existsSync, mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import type { FastifyInstance } from 'fastify' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +// Propositions de la famille sur l'arbre (1.5.1) : photos posées sur l'arbre, annotations posées par le serveur +// à l'acceptation, état d'une proposition. Sur une vraie base, comme toute règle de droits : +// TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/genealogy_test npx vitest run test/propositions-fantomes.db.test.ts +const TEST_DB = process.env.TEST_DATABASE_URL + +let app: FastifyInstance +let env: { ALLOW_REGISTRATION: boolean; MEDIA_STORAGE_PATH: string } +let prisma: typeof import('../src/lib/prisma.js')['prisma'] +let ip = 0 +const nextIp = () => `10.2.${Math.floor(++ip / 250)}.${ip % 250 + 1}` +const run = Date.now().toString(36) +const mail = (name: string) => `${name}-${run}@exemple.fr` +// PNG d'un pixel +const PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==' +const storage = mkdtempSync(path.join(tmpdir(), 'pth-151-')) + +async function call(method: string, url: string, token?: string, payload?: unknown) { + const res = await app.inject({ + method: method as 'GET', + url, + payload: payload as Record, + headers: token ? { authorization: `Bearer ${token}` } : {}, + remoteAddress: nextIp(), + }) + return { status: res.statusCode, body: res.body ? res.json() : null } +} + +async function account(name: string) { + const res = await call('POST', '/auth/register', undefined, { email: mail(name), password: 'mot-de-passe-solide', firstName: name }) + expect(res.status).toBe(201) + return { token: res.body.token as string, id: res.body.user.id as string } +} + +describe.skipIf(!TEST_DB)('propositions de la famille sur l\'arbre, vraie base', () => { + let owner: { token: string; id: string } + let treeId: string + let family: string + + beforeAll(async () => { + process.env.NODE_ENV = 'test' + process.env.DATABASE_URL = TEST_DB + process.env.JWT_SECRET = 'test-secret-with-sufficient-length' + process.env.CORS_ORIGIN = 'http://localhost:5173' + process.env.MEDIA_STORAGE_PATH = storage + ;({ env } = await import('../src/config/env.js')) + ;({ prisma } = await import('../src/lib/prisma.js')) + const { createApp } = await import('../src/app.js') + app = createApp() + env.ALLOW_REGISTRATION = true + env.MEDIA_STORAGE_PATH = storage + owner = await account('proprio') + const tree = await call('POST', '/trees', owner.token, { name: 'Arbre de démo', slug: `fantomes-${run}`, visitorPassword: 'partage-demo-1', contributorPassword: 'partage-demo-1' }) + expect(tree.status).toBe(201) + treeId = tree.body.tree?.id ?? tree.body.id + const unlocked = await call('POST', `/trees/${treeId}/access/unlock`, undefined, { password: 'partage-demo-1' }) + expect(unlocked.status).toBe(200) + family = unlocked.body.token + }) + + afterAll(async () => { + await app?.close() + }) + + const upload = (token?: string) => call('POST', `/trees/${treeId}/annotation-photos`, token, { + fileName: 'photo.png', mimeType: 'image/png', sizeBytes: Buffer.from(PIXEL, 'base64').length, dataBase64: PIXEL, + }) + const photoAnnotation = (photoPath: string) => ({ type: 'photo', x: 10, y: 20, content: JSON.stringify({ photoPath, width: 1, height: 1 }), style: {}, zIndex: 1 }) + const propose = (token: string, changes: unknown[]) => call('POST', `/trees/${treeId}/contributions/sessions`, token, { title: 'Proposition de la famille', submittedByLabel: 'Cousine', changes }) + const review = (sessionId: string, decision: 'approved' | 'rejected') => call('POST', `/trees/${treeId}/contributions/sessions/${sessionId}/review`, owner.token, { decision }) + const annotations = async () => (await call('GET', `/trees/${treeId}/annotations`, owner.token)).body.annotations as Array<{ id: string; type: string; content: string }> + const fileOf = async (photoId: string) => (await prisma.annotationPhotoUpload.findUnique({ where: { id: photoId } }))?.filePath + + it("personne d'extérieur ne pose une photo : sans jeton 401, un compte sans lien avec l'arbre 403", async () => { + expect((await upload()).status).toBe(401) + const stranger = await account('inconnu') + expect((await upload(stranger.token)).status).toBe(403) + }) + + it('la photo de la famille attend la relecture, puis devient une annotation au moment où elle est acceptée', async () => { + const sent = await upload(family) + expect(sent.status).toBe(201) + const filePath = await fileOf(sent.body.photoId) + expect(filePath).toBeTruthy() + + const proposed = await propose(family, [{ entityType: 'annotation', action: 'create', after: photoAnnotation(sent.body.photoPath) }]) + expect(proposed.status).toBe(201) + expect((await annotations()).some((a) => a.content.includes(sent.body.photoId))).toBe(false) + + expect((await review(proposed.body.session.id, 'approved')).status).toBe(200) + expect((await annotations()).some((a) => a.type === 'photo' && a.content.includes(sent.body.photoId))).toBe(true) + expect(await fileOf(sent.body.photoId)).toBeUndefined() + expect(existsSync(path.join(storage, filePath as string))).toBe(true) + }) + + it('refusée, la photo de la famille est effacée du disque', async () => { + const sent = await upload(family) + const filePath = await fileOf(sent.body.photoId) as string + const proposed = await propose(family, [{ entityType: 'annotation', action: 'create', after: photoAnnotation(sent.body.photoPath) }]) + expect((await review(proposed.body.session.id, 'rejected')).status).toBe(200) + expect(await fileOf(sent.body.photoId)).toBeUndefined() + expect(existsSync(path.join(storage, filePath))).toBe(false) + expect((await annotations()).some((a) => a.content.includes(sent.body.photoId))).toBe(false) + }) + + it('un dessin accepté est posé par le serveur, sans geste du navigateur', async () => { + const before = (await annotations()).length + const proposed = await propose(family, [{ entityType: 'annotation', action: 'create', after: { type: 'drawing', x: 1, y: 2, content: 'M0 0 L10 10', style: { color: '#b4532a' } } }]) + expect((await review(proposed.body.session.id, 'approved')).status).toBe(200) + expect((await annotations()).length).toBe(before + 1) + }) + + it("une photo qui n'est pas de cet arbre est refusée dès l'envoi", async () => { + const res = await propose(family, [{ entityType: 'annotation', action: 'create', after: photoAnnotation('/trees/un-autre-arbre/annotation-photos/abc') }]) + expect(res.status).toBe(400) + expect(res.body.error).toBe('invalid_annotation_photo_path') + }) + + it("l'état d'une proposition : lisible par qui lit l'arbre, pas par un compte sans lien", async () => { + const proposed = await propose(family, [{ entityType: 'annotation', action: 'create', after: { type: 'sticker', x: 0, y: 0, content: '🌳' } }]) + const url = `/trees/${treeId}/contributions/sessions/${proposed.body.session.id}/status` + expect((await call('GET', url, family)).body.status).toBe('pending') + const stranger = await account('curieux') + expect((await call('GET', url, stranger.token)).status).toBe(403) + await review(proposed.body.session.id, 'approved') + expect((await call('GET', url, family)).body.status).toBe('approved') + }) + + it("un envoi jamais proposé depuis 7 jours est effacé au suivant ; celui d'une proposition en attente reste", async () => { + const orphan = await upload(family) + const kept = await upload(family) + await propose(family, [{ entityType: 'annotation', action: 'create', after: photoAnnotation(kept.body.photoPath) }]) + const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000) + await prisma.annotationPhotoUpload.updateMany({ where: { id: { in: [orphan.body.photoId, kept.body.photoId] } }, data: { createdAt: old } }) + const orphanPath = await fileOf(orphan.body.photoId) as string + + expect((await upload(family)).status).toBe(201) + expect(await fileOf(orphan.body.photoId)).toBeUndefined() + expect(existsSync(path.join(storage, orphanPath))).toBe(false) + expect(await fileOf(kept.body.photoId)).toBeTruthy() + }) + + it('au-delà de 50 photos en attente sur un arbre, la famille est arrêtée ; un gestionnaire non', async () => { + const existing = await prisma.annotationPhotoUpload.count({ where: { treeId } }) + await prisma.annotationPhotoUpload.createMany({ + data: Array.from({ length: 50 - existing }, (_, index) => ({ id: `rempli-${run}-${index}`, treeId, filePath: `${treeId}/annotation-photos/rempli-${index}.png` })), + }) + expect((await upload(family)).status).toBe(429) + expect((await upload(owner.token)).status).toBe(201) + }) +})