API : réordonner une fratrie en une transaction

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 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-16 18:51:37 +02:00
1 parent 8574dfed9e
commit 1caa090efd
3 files changed
+276

No files matched your search

+66
View File
@@ -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]
+92
View File
@@ -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<string>[] = []
for (const entry of payload.data.order) {
const parents = new Set<string>()
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) {
@@ -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)