From 1caa090efd548a3e5be3e6c8eb0fa974501f5982 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Fri, 16 Oct 2026 18:51:37 +0200 Subject: [PATCH] =?UTF-8?q?API=20:=20r=C3=A9ordonner=20une=20fratrie=20en?= =?UTF-8?q?=20une=20transaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PUT /trees/:id/links/order réécrit le displayOrder des liens parent-enfant d'une fratrie, dans l'ordre reçu. Administrateurs seulement ; chaque lien doit appartenir à l'arbre et à son enfant, et les enfants partager au moins un parent. Journalisé (children_reordered). Co-Authored-By: Claude Opus 5.5 --- backend/openapi/v1.yaml | 66 ++++++++++ backend/src/routes/trees.ts | 92 ++++++++++++++ backend/test/api-security.integration.test.ts | 118 ++++++++++++++++++ 3 files changed, 276 insertions(+) diff --git a/backend/openapi/v1.yaml b/backend/openapi/v1.yaml index 981c01b..9ae9c27 100644 --- a/backend/openapi/v1.yaml +++ b/backend/openapi/v1.yaml @@ -289,6 +289,27 @@ components: displayOrder: type: integer required: [parentPersonId, childPersonId] + ChildrenOrderRequest: + type: object + required: [order] + properties: + order: + type: array + minItems: 2 + maxItems: 200 + description: Children in the wanted order, each with the links that attach it to its parents + items: + type: object + required: [childId, linkIds] + properties: + childId: + type: string + linkIds: + type: array + minItems: 1 + maxItems: 4 + items: + type: string PatchParentChildLinkRequest: type: object properties: @@ -1541,6 +1562,51 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' + /trees/{id}/links/order: + put: + tags: [Trees] + summary: Reorder siblings (rewrites displayOrder of their parent-child links) + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChildrenOrderRequest' + responses: + '200': + description: Order saved + '400': + description: Invalid payload, links that do not belong to their child, or children without a shared parent + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Link not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /trees/{id}/links/{linkId}: patch: tags: [Trees] diff --git a/backend/src/routes/trees.ts b/backend/src/routes/trees.ts index 642db99..b80fd47 100644 --- a/backend/src/routes/trees.ts +++ b/backend/src/routes/trees.ts @@ -169,6 +169,19 @@ const linkParamsSchema = z.object({ linkId: z.string().min(1), }) +// Ordre d'une fratrie : pour chaque enfant, dans l'ordre voulu, les liens qui le rattachent à ses parents +const childrenOrderSchema = z.object({ + order: z + .array( + z.object({ + childId: z.string().min(1), + linkIds: z.array(z.string().min(1)).min(1).max(4), + }), + ) + .min(2) + .max(200), +}) + function requireUser(request: FastifyRequest, reply: FastifyReply): { userId: string; email: string } | null { if (!request.actor || request.actor.kind !== 'user') { reply.code(401).send({ error: 'authentication_required' }) @@ -1979,6 +1992,85 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { return reply.send({ link }) }) + // Réordonner une fratrie : displayOrder des liens parent-enfant réécrit en une transaction + app.put('/trees/:id/links/order', async (request, reply) => { + const user = requireUser(request, reply) + if (!user) { + return + } + + const params = treeIdParamSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_params' }) + } + + const payload = childrenOrderSchema.safeParse(request.body) + if (!payload.success) { + return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() }) + } + + const membership = await getAdminMembership(app, user.userId, params.data.id) + if (!hasActiveAdminMembership(membership)) { + return reply.code(403).send({ error: 'forbidden' }) + } + + const childIds = payload.data.order.map((entry) => entry.childId) + const linkIds = payload.data.order.flatMap((entry) => entry.linkIds) + if (new Set(childIds).size !== childIds.length || new Set(linkIds).size !== linkIds.length) { + return reply.code(400).send({ error: 'invalid_payload' }) + } + + const links = await app.prisma.parentChildLink.findMany({ + where: { id: { in: linkIds }, treeId: params.data.id, deletedAt: null }, + select: { id: true, parentPersonId: true, childPersonId: true }, + }) + const linkById = new Map(links.map((link) => [link.id, link])) + if (links.length !== linkIds.length) { + return reply.code(404).send({ error: 'link_not_found' }) + } + + // Chaque lien appartient bien à son enfant, et les enfants partagent au moins un parent + const parentSets: Set[] = [] + for (const entry of payload.data.order) { + const parents = new Set() + for (const linkId of entry.linkIds) { + const link = linkById.get(linkId) + if (!link || link.childPersonId !== entry.childId) { + return reply.code(400).send({ error: 'invalid_link_reference' }) + } + parents.add(link.parentPersonId) + } + parentSets.push(parents) + } + const hasSharedParent = [...parentSets[0]].some((parentId) => parentSets.every((parents) => parents.has(parentId))) + if (!hasSharedParent) { + return reply.code(400).send({ error: 'not_siblings' }) + } + + await app.prisma.$transaction( + payload.data.order.map((entry, index) => + app.prisma.parentChildLink.updateMany({ + where: { id: { in: entry.linkIds }, treeId: params.data.id, deletedAt: null }, + data: { displayOrder: index + 1 }, + }), + ), + ) + + await app.prisma.auditLog.create({ + data: { + treeId: params.data.id, + actorType: 'user', + actorId: user.userId, + action: 'children_reordered', + entityType: 'parent_child_link', + entityId: payload.data.order[0].linkIds[0], + payloadJson: { childIds }, + }, + }) + + return reply.send({ ok: true }) + }) + app.delete('/trees/:id/links/:linkId', async (request, reply) => { const user = requireUser(request, reply) if (!user) { diff --git a/backend/test/api-security.integration.test.ts b/backend/test/api-security.integration.test.ts index 4e1c299..d23082f 100644 --- a/backend/test/api-security.integration.test.ts +++ b/backend/test/api-security.integration.test.ts @@ -514,6 +514,124 @@ describe('API security and authz', () => { await app.close() }) + describe('PUT /trees/:id/links/order', () => { + const siblingLinks = [ + { id: 'link-a1', parentPersonId: 'father', childPersonId: 'anne' }, + { id: 'link-a2', parentPersonId: 'mother', childPersonId: 'anne' }, + { id: 'link-h1', parentPersonId: 'father', childPersonId: 'hugo' }, + { id: 'link-h2', parentPersonId: 'mother', childPersonId: 'hugo' }, + ] + const order = [ + { childId: 'hugo', linkIds: ['link-h1', 'link-h2'] }, + { childId: 'anne', linkIds: ['link-a1', 'link-a2'] }, + ] + + it('rewrites the order of a sibling group in one transaction', async () => { + const app = createApp() + const token = await createUserToken(app) + prismaMock.parentChildLink.findMany.mockResolvedValue(siblingLinks) + prismaMock.$transaction.mockResolvedValue([]) + + const response = await app.inject({ + method: 'PUT', + url: '/trees/tree-1/links/order', + headers: { authorization: `Bearer ${token}` }, + payload: { order }, + }) + + expect(response.statusCode).toBe(200) + expect(prismaMock.$transaction).toHaveBeenCalledTimes(1) + expect(prismaMock.parentChildLink.updateMany).toHaveBeenNthCalledWith(1, { + where: { id: { in: ['link-h1', 'link-h2'] }, treeId: 'tree-1', deletedAt: null }, + data: { displayOrder: 1 }, + }) + expect(prismaMock.parentChildLink.updateMany).toHaveBeenNthCalledWith(2, { + where: { id: { in: ['link-a1', 'link-a2'] }, treeId: 'tree-1', deletedAt: null }, + data: { displayOrder: 2 }, + }) + expect(prismaMock.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ action: 'children_reordered', payloadJson: { childIds: ['hugo', 'anne'] } }), + })) + await app.close() + }) + + it('forbids a member who is not admin', async () => { + const app = createApp() + const token = await createUserToken(app) + prismaMock.treeMembership.findUnique.mockResolvedValue({ + treeId: 'tree-1', + userId: 'user-1', + role: 'member', + tree: { deletedAt: null, rootPersonId: null }, + }) + + const response = await app.inject({ + method: 'PUT', + url: '/trees/tree-1/links/order', + headers: { authorization: `Bearer ${token}` }, + payload: { order }, + }) + + expect(response.statusCode).toBe(403) + expect(prismaMock.$transaction).not.toHaveBeenCalled() + await app.close() + }) + + it('refuses links that do not belong to the tree', async () => { + const app = createApp() + const token = await createUserToken(app) + prismaMock.parentChildLink.findMany.mockResolvedValue(siblingLinks.slice(0, 3)) + + const response = await app.inject({ + method: 'PUT', + url: '/trees/tree-1/links/order', + headers: { authorization: `Bearer ${token}` }, + payload: { order }, + }) + + expect(response.statusCode).toBe(404) + expect(prismaMock.$transaction).not.toHaveBeenCalled() + await app.close() + }) + + it('refuses a link sent for the wrong child', async () => { + const app = createApp() + const token = await createUserToken(app) + prismaMock.parentChildLink.findMany.mockResolvedValue([siblingLinks[0], siblingLinks[2]]) + + const response = await app.inject({ + method: 'PUT', + url: '/trees/tree-1/links/order', + headers: { authorization: `Bearer ${token}` }, + payload: { order: [{ childId: 'hugo', linkIds: ['link-a1'] }, { childId: 'anne', linkIds: ['link-h1'] }] }, + }) + + expect(response.statusCode).toBe(400) + expect(response.json()).toEqual({ error: 'invalid_link_reference' }) + await app.close() + }) + + it('refuses children who share no parent', async () => { + const app = createApp() + const token = await createUserToken(app) + prismaMock.parentChildLink.findMany.mockResolvedValue([ + { id: 'link-x', parentPersonId: 'father', childPersonId: 'anne' }, + { id: 'link-y', parentPersonId: 'stranger', childPersonId: 'hugo' }, + ]) + + const response = await app.inject({ + method: 'PUT', + url: '/trees/tree-1/links/order', + headers: { authorization: `Bearer ${token}` }, + payload: { order: [{ childId: 'anne', linkIds: ['link-x'] }, { childId: 'hugo', linkIds: ['link-y'] }] }, + }) + + expect(response.statusCode).toBe(400) + expect(response.json()).toEqual({ error: 'not_siblings' }) + await app.close() + }) + }) + it('forbids member from patching person records', async () => { const app = createApp() const token = await createUserToken(app)