Revue de droits 1.5.1 : photos refusées libérées après la relecture, citations lues dans le JSON décodé, contenu normalisé à l'envoi, plafond tenu sous verrou

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-25 20:27:35 +01:00
1 parent 73be6a7cfd
commit 82c895d10a
4 files changed
+127 -36

No files matched your search

+62 -22
View File
@@ -82,6 +82,16 @@ export function validateAnnotationChange(change: AnnotationChange, treeId: strin
throw new AnnotationChangeError('unsupported_change_action_for_annotation')
}
// L'« après » d'une proposition d'annotation tel qu'il est gardé : contenu nettoyé comme à l'écriture directe
// (photoPath canonique, sans adresse), pour qu'on le relise toujours de la même façon
export function normalizedAnnotationAfter(change: AnnotationChange, treeId: string): unknown {
const after = change.after
if (!after || typeof after !== 'object' || Array.isArray(after)) return after
const content = (after as { content?: unknown }).content
if (typeof content !== 'string') return after
return { ...(after as Record<string, unknown>), content: cleanContent(content, treeId) }
}
// 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
@@ -147,29 +157,41 @@ export async function applyAnnotationChange(tx: Tx, treeId: string, change: Anno
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
// Photos citées par les propositions d'annotation encore en attente sur cet arbre (hors une session donnée).
// Lu dans le JSON décodé, pas dans son texte : un chemin échappé (\/trees\/…) reste reconnu. Un arbre a au plus
// quelques centaines de propositions en attente.
async function photosInPendingProposals(db: Db, treeId: string, excludeSessionId?: string): Promise<Set<string>> {
const changes = await db.contributionChange.findMany({
where: {
entityType: 'annotation',
session: { treeId, status: 'pending', ...(excludeSessionId ? { id: { not: excludeSessionId } } : {}) },
},
select: { afterJson: true },
})
const cited = new Set<string>()
for (const change of changes) {
const photoId = annotationPhotoId(change.afterJson, treeId)
if (photoId) cited.add(photoId)
}
return cited
}
// 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]
// Photos d'une famille refusées pendant une relecture : effacées si leur ligne d'attente existe encore (une
// acceptation la retire) et si aucune autre proposition en attente ne les cite. Appelé une fois, après toutes les
// décisions. Renvoie les chemins à effacer une fois la transaction validée.
export async function releaseRejectedPhotos(tx: Tx, treeId: string, rejectedAfters: unknown[], sessionId: string): Promise<string[]> {
const candidates = new Set(rejectedAfters.map((after) => annotationPhotoId(after, treeId)).filter((id): id is string => Boolean(id)))
if (candidates.size === 0) return []
const cited = await photosInPendingProposals(tx, treeId, sessionId)
const removed: string[] = []
for (const photoId of candidates) {
if (cited.has(photoId)) continue
const upload = await tx.annotationPhotoUpload.findFirst({ where: { id: photoId, treeId }, select: { filePath: true } })
if (!upload) continue
await tx.annotationPhotoUpload.delete({ where: { id: photoId } })
removed.push(upload.filePath)
}
return removed
}
// Envois de photos jamais proposés depuis 7 jours : effacés. Appelé à chaque nouvel envoi de la famille, sur
@@ -179,15 +201,33 @@ export async function sweepStalePendingPhotos(db: Db, treeId: string, now = new
where: { treeId, createdAt: { lt: new Date(now.getTime() - PENDING_PHOTO_TTL_MS) } },
select: { id: true, filePath: true },
})
if (stale.length === 0) return []
const cited = await photosInPendingProposals(db, treeId)
const removed: string[] = []
for (const upload of stale) {
if (await isPhotoInPendingProposal(db, treeId, upload.id)) continue
if (cited.has(upload.id)) continue
await db.annotationPhotoUpload.delete({ where: { id: upload.id } })
removed.push(upload.filePath)
}
return removed
}
// Réserve une place d'envoi en attente sous un verrou par arbre : deux envois simultanés ne dépassent pas le
// plafond. Renvoie false si l'arbre est plein. La ligne est créée avant l'écriture du fichier ; si l'écriture
// échoue, l'appelant la retire (forgetPendingPhoto).
export async function reservePendingPhoto(prisma: PrismaClient, treeId: string, photoId: string, filePath: string): Promise<boolean> {
return prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'annotation-photos:' + treeId}))`
if (await tx.annotationPhotoUpload.count({ where: { treeId } }) >= MAX_PENDING_PHOTOS_PER_TREE) return false
await tx.annotationPhotoUpload.create({ data: { id: photoId, treeId, filePath } })
return true
})
}
export async function forgetPendingPhoto(db: Db, photoId: string): Promise<void> {
await db.annotationPhotoUpload.deleteMany({ where: { id: photoId } })
}
// 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}`)
+11 -6
View File
@@ -5,7 +5,7 @@ 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 { AnnotationChangeError, applyAnnotationChange, normalizedAnnotationAfter, releaseRejectedPhotos, 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'
@@ -1295,7 +1295,8 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
entityId: effectiveEntityId,
action: change.action,
beforeJson: toPrismaJson(change.entityType === 'annotation' ? stripAnnotationUrl(before) : before),
afterJson: toPrismaJson(change.entityType === 'annotation' ? stripAnnotationUrl(change.after) : change.after),
// Annotation : gardée nettoyée (photoPath canonique), pour être relue sans ambiguïté à la relecture
afterJson: toPrismaJson(change.entityType === 'annotation' ? stripAnnotationUrl(normalizedAnnotationAfter(change, params.data.id)) : change.after),
conflictState: change.conflictState ?? 'none',
decision: context.autoStatus === 'approved' ? 'approved' : undefined,
},
@@ -1464,6 +1465,10 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
let rejectedCount = 0
let conflictCount = 0
const refs: RefMap = new Map()
// Photos posées sur l'arbre par la famille : celles des annotations refusées sont libérées après toutes les
// décisions. Une annotation acceptée dans cette même relecture a déjà retiré la ligne d'attente de sa photo,
// qui n'est donc plus effacée (avant, la libération dans la boucle effaçait une photo acceptée plus loin).
const rejectedAnnotationAfters: unknown[] = []
for (const change of byApplicationOrder(session.changes)) {
let decision = explicitDecisions.get(change.id) ?? payload.data.decision
@@ -1507,6 +1512,7 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
if (error instanceof ContributionRouteError && (error.code === 'unresolved_reference' || SKIPPABLE_ANNOTATION_ERRORS.has(error.code))) {
decision = 'rejected'
rejectedCount += 1
if (change.entityType === 'annotation') rejectedAnnotationAfters.push(change.afterJson)
} else {
throw error
}
@@ -1518,10 +1524,7 @@ 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 === 'annotation') rejectedAnnotationAfters.push(change.afterJson)
if (change.entityType === 'media' && change.entityId) {
await tx.mediaItem.updateMany({
where: { id: change.entityId, treeId: params.data.id },
@@ -1542,6 +1545,8 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
})
}
filesToRemove.push(...await releaseRejectedPhotos(tx, params.data.id, rejectedAnnotationAfters, session.id))
const status: ContributionStatus = approvedCount > 0 ? 'approved' : 'rejected'
const updatedSession = await tx.contributionSession.update({
+12 -8
View File
@@ -25,7 +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'
import { forgetPendingPhoto, reservePendingPhoto, sweepStalePendingPhotos } from '../lib/annotation-changes.js'
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
const MAX_FILE_BYTES = 20 * 1024 * 1024
@@ -1151,9 +1151,6 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
}
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')
@@ -1193,10 +1190,17 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
return reply.code(400).send({ error: 'invalid_photo_path' })
}
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 } })
// Famille : la place est réservée sous un verrou par arbre avant d'écrire, pour tenir le plafond même quand
// plusieurs envois arrivent ensemble
if (!isManager && !(await reservePendingPhoto(app.prisma, params.data.id, photoId, relativePath))) {
return reply.code(429).send({ error: 'too_many_pending_photos' })
}
try {
await mkdir(path.dirname(absolutePath), { recursive: true })
await writeFile(absolutePath, prepared.buffer)
} catch (error) {
if (!isManager) await forgetPendingPhoto(app.prisma, photoId)
throw error
}
return reply.code(201).send({
@@ -164,6 +164,48 @@ describe.skipIf(!TEST_DB)('propositions de la famille sur l\'arbre, vraie base',
expect(await fileOf(kept.body.photoId)).toBeTruthy()
})
it('revue : refusée puis acceptée dans la même relecture, la photo reste sur le 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) },
{ entityType: 'annotation', action: 'create', after: { ...photoAnnotation(sent.body.photoPath), x: 99 } },
])
const session = await prisma.contributionSession.findUnique({ where: { id: proposed.body.session.id }, include: { changes: { orderBy: { createdAt: 'asc' } } } })
const [first, second] = session!.changes
const res = await call('POST', `/trees/${treeId}/contributions/sessions/${session!.id}/review`, owner.token, {
decision: 'approved',
changeDecisions: [{ changeId: first.id, decision: 'rejected' }, { changeId: second.id, decision: 'approved' }],
})
expect(res.status).toBe(200)
expect(existsSync(path.join(storage, filePath))).toBe(true)
})
it("revue : un chemin de photo échappé dans le JSON compte quand même comme cité par une proposition en attente", async () => {
const sent = await upload(family)
const filePath = await fileOf(sent.body.photoId) as string
const plain = await propose(family, [{ entityType: 'annotation', action: 'create', after: photoAnnotation(sent.body.photoPath) }])
const escaped = `{"photoPath":"${sent.body.photoPath.replaceAll('/', '\/')}","width":1,"height":1}`
const other = await propose(family, [{ entityType: 'annotation', action: 'create', after: { type: 'photo', x: 1, y: 1, content: escaped } }])
expect(other.status).toBe(201)
await review(plain.body.session.id, 'rejected')
expect(existsSync(path.join(storage, filePath))).toBe(true)
await review(other.body.session.id, 'approved')
expect((await annotations()).some((a) => a.content.includes(sent.body.photoId))).toBe(true)
expect(existsSync(path.join(storage, filePath))).toBe(true)
})
it('revue : 70 envois simultanés sur un arbre neuf ne dépassent pas le plafond de 50', async () => {
const fresh = await call('POST', '/trees', owner.token, { name: 'Arbre neuf', slug: `neuf-${run}`, visitorPassword: 'partage-neuf-1', contributorPassword: 'partage-neuf-1' })
const freshId = fresh.body.tree?.id ?? fresh.body.id
const access = (await call('POST', `/trees/${freshId}/access/unlock`, undefined, { password: 'partage-neuf-1' })).body.token
const results = await Promise.all(Array.from({ length: 70 }, () => call('POST', `/trees/${freshId}/annotation-photos`, access, {
fileName: 'photo.png', mimeType: 'image/png', sizeBytes: Buffer.from(PIXEL, 'base64').length, dataBase64: PIXEL,
})))
expect(results.filter((r) => r.status === 201).length).toBe(50)
expect(await prisma.annotationPhotoUpload.count({ where: { treeId: freshId } })).toBe(50)
})
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({