mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-09 07:50:58 +00:00
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:
1 parent
8574dfed9e
commit
1caa090efd
3 files changed
+276
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user