From f20b58be32ee6f4b1088cd201d1024712ddad19f Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Sun, 18 Oct 2026 23:54:35 +0200 Subject: [PATCH] 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()