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 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-19 00:06:29 +02:00
1 parent 82f888b1dc
commit f20b58be32
4 files changed
+256 -135

No files matched your search

+57 -54
View File
@@ -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<string, unknown> = {}
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<string, unknown> = {}
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 })
+29 -26
View File
@@ -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 })
+66 -55
View File
@@ -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()
})
+104
View File
@@ -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<unknown>) => 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<unknown>) => 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<unknown>) => 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<unknown>) => 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()