Propositions de la famille : photos posées sur l'arbre en attente de relecture, annotations posées par le serveur à l'acceptation, état d'une proposition

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-25 20:04:13 +01:00
1 parent 01aeb69aff
commit eebb8f2cb1
6 files changed
+471 -36

No files matched your search

@@ -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;
+44 -29
View File
@@ -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
+194
View File
@@ -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<boolean> {
const needle = `%/trees/${treeId}/annotation-photos/${photoId}%`
const rows = await db.$queryRaw<Array<{ id: string }>>`
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<string[]> {
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<string[]> {
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 <treeId>/annotation-photos/)
export function annotationPhotoRelativePath(treeId: string, photoId: string, extension: string): string {
return path.posix.join(treeId, 'annotation-photos', `${photoId}${extension}`)
}
+40 -3
View File
@@ -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 },
+20 -4
View File
@@ -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,
@@ -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<string, unknown>,
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)
})
})