Nettoyage : routes d'API que l'app n'appelle plus, fichiers de la landing sans lien

- GET /trees/:id/stats et /trees/:id/audit retirées, avec leur entrée dans l'OpenAPI
- annotation seule (POST, GET, PATCH, DELETE /trees/:id/annotations) retirée : l'app lit les annotations dans le graphe et écrit par /annotations/batch
- landing : six SVG de hero/, aide/accueil.webp (fr et en) et og-image-hero.png, liés par aucune page

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Fable 5.2 committed 2026-11-12 14:09:39 +01:00
1 parent bbbb5eb3ce
commit ae0d52ca46
14 files changed
+1 -632

No files matched your search

-147
View File
@@ -155,46 +155,6 @@ components:
type: string
format: date-time
required: [treeId, memberContributionPolicy, visitorPolicy, contributorPolicy]
TreeStats:
type: object
properties:
persons:
type: integer
unions:
type: integer
filiations:
type: integer
medias:
type: integer
pendingContributions:
type: integer
totalContributions:
type: integer
required: [persons, unions, filiations, medias, pendingContributions, totalContributions]
AuditLogEntry:
type: object
properties:
id:
type: string
treeId:
type: string
actorType:
type: string
actorId:
type: string
nullable: true
action:
type: string
entityType:
type: string
entityId:
type: string
payloadJson:
nullable: true
createdAt:
type: string
format: date-time
required: [id, treeId, actorType, action, entityType, entityId, createdAt]
CreatePersonRequest:
type: object
properties:
@@ -1067,48 +1027,6 @@ paths:
schema:
$ref: '#/components/schemas/ErrorResponse'
/trees/{id}/stats:
get:
tags: [Trees]
summary: Get admin statistics for a tree
security:
- BearerAuth: []
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
'200':
description: Tree statistics
content:
application/json:
schema:
type: object
properties:
stats:
$ref: '#/components/schemas/TreeStats'
required: [stats]
'400':
description: Invalid tree id
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'
/trees/{id}/settings:
get:
tags: [Trees]
@@ -1197,71 +1115,6 @@ paths:
schema:
$ref: '#/components/schemas/ErrorResponse'
/trees/{id}/audit:
get:
tags: [Trees]
summary: List admin audit logs for a tree
security:
- BearerAuth: []
parameters:
- in: path
name: id
required: true
schema:
type: string
- in: query
name: limit
required: false
schema:
type: integer
minimum: 1
maximum: 100
- in: query
name: offset
required: false
schema:
type: integer
minimum: 0
- in: query
name: action
required: false
schema:
type: string
responses:
'200':
description: Audit log entries
content:
application/json:
schema:
type: object
properties:
logs:
type: array
items:
$ref: '#/components/schemas/AuditLogEntry'
nextOffset:
type: integer
nullable: true
required: [logs, nextOffset]
'400':
description: Invalid query or tree id
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'
/trees/{id}/graph:
get:
tags: [Graph]
-180
View File
@@ -4,22 +4,15 @@ import { z } from 'zod'
import {
InvalidAnnotationContentError,
sanitizeAnnotationContentForRead,
sanitizeAnnotationContentForWrite,
} from '../lib/annotation-content.js'
import { UPLOAD_BODY_LIMIT } from '../config/env.js'
import { canReadTree } from '../lib/tree-access.js'
import type { MembershipRole } from '../types/auth.js'
const treeIdParamSchema = z.object({
id: z.string().min(1),
})
const annotationParamsSchema = z.object({
id: z.string().min(1),
annotationId: z.string().min(1),
})
// Bornes : sans elles, un envoi groupé de 12 Mo par requête et sans limite de fréquence remplissait la base
const MAX_BATCH_ITEMS = 500
const MAX_STYLE_CHARS = 4000
@@ -40,18 +33,6 @@ const createAnnotationSchema = z.object({
zIndex: z.number().int().optional(),
})
const patchAnnotationSchema = z
.object({
x: z.number().optional(),
y: z.number().optional(),
content: z.string().min(1).max(50000).optional(),
style: styleSchema.optional(),
zIndex: z.number().int().optional(),
})
.refine((value) => Object.keys(value).length > 0, {
message: 'at_least_one_field_required',
})
const batchAnnotationSchema = z.object({
create: z.array(createAnnotationSchema).max(MAX_BATCH_ITEMS).optional().default([]),
update: z.array(z.object({
@@ -117,167 +98,6 @@ async function exceedsAnnotationCap(app: FastifyInstance, treeId: string, incomi
}
export const annotationRoutes: FastifyPluginAsync = async (app) => {
// CREATE annotation
app.post('/trees/:id/annotations', { config: { rateLimit: ANNOTATION_RATE_LIMIT } }, 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_tree_id' })
const payload = createAnnotationSchema.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' })
if (await exceedsAnnotationCap(app, params.data.id, 1)) return reply.code(413).send({ error: 'annotation_limit_reached' })
const content = cleanContent(payload.data.content, params.data.id)
if (content === null) return reply.code(400).send({ error: 'invalid_annotation_photo_path' })
const annotation = await app.prisma.annotation.create({
data: {
treeId: params.data.id,
type: payload.data.type,
x: payload.data.x,
y: payload.data.y,
content,
style: (payload.data.style ?? {}) as Prisma.InputJsonValue,
zIndex: payload.data.zIndex ?? 0,
createdBy: user.userId,
},
})
await app.prisma.auditLog.create({
data: {
treeId: params.data.id,
actorType: 'user',
actorId: user.userId,
action: 'annotation_created',
entityType: 'annotation',
entityId: annotation.id,
payloadJson: { type: annotation.type },
},
})
return reply.code(201).send({ annotation })
})
// LIST annotations
app.get('/trees/:id/annotations', async (request, reply) => {
const params = treeIdParamSchema.safeParse(request.params)
if (!params.success) return reply.code(400).send({ error: 'invalid_tree_id' })
if (!request.actor) return reply.code(401).send({ error: 'authentication_required' })
if (!(await canReadTree(app, params.data.id, request.actor))) return reply.code(403).send({ error: 'forbidden' })
const annotations = await app.prisma.annotation.findMany({
where: {
treeId: params.data.id,
deletedAt: null,
},
orderBy: [{ zIndex: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }],
})
return reply.send({
annotations: annotations.map((annotation) => ({ ...annotation, content: sanitizeAnnotationContentForRead(annotation.content) })),
})
})
// UPDATE annotation
app.patch('/trees/:id/annotations/:annotationId', async (request, reply) => {
const user = requireUser(request, reply)
if (!user) return
const params = annotationParamsSchema.safeParse(request.params)
if (!params.success) return reply.code(400).send({ error: 'invalid_params' })
const payload = patchAnnotationSchema.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 data: Record<string, unknown> = {}
if (payload.data.x !== undefined) data.x = payload.data.x
if (payload.data.y !== undefined) data.y = payload.data.y
if (payload.data.content !== undefined) {
const content = cleanContent(payload.data.content, params.data.id)
if (content === null) return reply.code(400).send({ error: 'invalid_annotation_photo_path' })
data.content = content
}
if (payload.data.style !== undefined) data.style = payload.data.style
if (payload.data.zIndex !== undefined) data.zIndex = payload.data.zIndex
const updated = await app.prisma.annotation.updateMany({
where: {
id: params.data.annotationId,
treeId: params.data.id,
deletedAt: null,
},
data,
})
if (updated.count === 0) return reply.code(404).send({ error: 'annotation_not_found' })
const annotation = await app.prisma.annotation.findUnique({
where: { id: params.data.annotationId },
})
await app.prisma.auditLog.create({
data: {
treeId: params.data.id,
actorType: 'user',
actorId: user.userId,
action: 'annotation_updated',
entityType: 'annotation',
entityId: params.data.annotationId,
payloadJson: { changedFields: Object.keys(payload.data) },
},
})
return reply.send({ annotation })
})
// DELETE annotation (soft delete)
app.delete('/trees/:id/annotations/:annotationId', async (request, reply) => {
const user = requireUser(request, reply)
if (!user) return
const params = annotationParamsSchema.safeParse(request.params)
if (!params.success) return reply.code(400).send({ error: 'invalid_params' })
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.annotation.updateMany({
where: {
id: params.data.annotationId,
treeId: params.data.id,
deletedAt: null,
},
data: {
deletedAt: new Date(),
},
})
if (deleted.count === 0) return reply.code(404).send({ error: 'annotation_not_found' })
await app.prisma.auditLog.create({
data: {
treeId: params.data.id,
actorType: 'user',
actorId: user.userId,
action: 'annotation_deleted',
entityType: 'annotation',
entityId: params.data.annotationId,
},
})
return reply.send({ ok: true })
})
// BATCH annotations (create + update + delete in one request)
app.post('/trees/:id/annotations/batch', { bodyLimit: UPLOAD_BODY_LIMIT, config: { rateLimit: ANNOTATION_RATE_LIMIT } }, async (request, reply) => {
const user = requireUser(request, reply)
-113
View File
@@ -79,12 +79,6 @@ const patchTreeSettingsSchema = z
message: 'at_least_one_setting_required',
})
const listAuditQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
offset: z.coerce.number().int().min(0).default(0),
action: z.string().min(1).max(120).optional(),
})
const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/
const createPersonSchema = z.object({
@@ -1124,73 +1118,6 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
return reply.send({ ok: true })
})
app.get('/trees/:id/stats', 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_tree_id' })
}
const membership = await getAdminMembership(app, user.userId, params.data.id)
if (!hasActiveAdminMembership(membership)) {
return reply.code(403).send({ error: 'forbidden' })
}
const [persons, unions, links, medias, pendingContributions, totalContributions] = await Promise.all([
app.prisma.person.count({
where: {
treeId: params.data.id,
deletedAt: null,
},
}),
app.prisma.union.count({
where: {
treeId: params.data.id,
deletedAt: null,
},
}),
app.prisma.parentChildLink.count({
where: {
treeId: params.data.id,
deletedAt: null,
},
}),
app.prisma.mediaItem.count({
where: {
treeId: params.data.id,
status: 'approved',
deletedAt: null,
},
}),
app.prisma.contributionSession.count({
where: {
treeId: params.data.id,
status: 'pending',
},
}),
app.prisma.contributionSession.count({
where: {
treeId: params.data.id,
},
}),
])
return reply.send({
stats: {
persons,
unions,
filiations: links,
medias,
pendingContributions,
totalContributions,
},
})
})
app.get('/trees/:id/settings', async (request, reply) => {
const user = requireUser(request, reply)
if (!user) {
@@ -1272,46 +1199,6 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
return reply.send({ settings })
})
app.get('/trees/:id/audit', 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_tree_id' })
}
const query = listAuditQuerySchema.safeParse(request.query)
if (!query.success) {
return reply.code(400).send({ error: 'invalid_query', details: query.error.flatten() })
}
const membership = await getAdminMembership(app, user.userId, params.data.id)
if (!hasActiveAdminMembership(membership)) {
return reply.code(403).send({ error: 'forbidden' })
}
const logs = await app.prisma.auditLog.findMany({
where: {
treeId: params.data.id,
action: query.data.action,
},
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: query.data.offset,
take: query.data.limit + 1,
})
const hasMore = logs.length > query.data.limit
const items = hasMore ? logs.slice(0, query.data.limit) : logs
return reply.send({
logs: items,
nextOffset: hasMore ? query.data.offset + query.data.limit : null,
})
})
app.post('/trees/:id/persons', async (request, reply) => {
const user = requireUser(request, reply)
if (!user) {
@@ -1572,55 +1572,6 @@ describe('API security and authz', () => {
await app.close()
})
it('allows owner to read tree stats', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.mediaItem.count.mockResolvedValue(3)
prismaMock.contributionSession.count
.mockResolvedValueOnce(4)
.mockResolvedValueOnce(9)
const response = await app.inject({
method: 'GET',
url: '/trees/tree-1/stats',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({
stats: {
persons: 2,
unions: 1,
filiations: 1,
medias: 3,
pendingContributions: 4,
totalContributions: 9,
},
})
await app.close()
})
it('forbids non-admin from reading tree stats', 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: 'GET',
url: '/trees/tree-1/stats',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(403)
await app.close()
})
it('allows owner to update tree settings policy', async () => {
const app = createApp()
const token = await createUserToken(app)
@@ -1647,67 +1598,7 @@ describe('API security and authz', () => {
await app.close()
})
it('lists audit logs for admin with pagination cursor', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.auditLog.findMany.mockResolvedValue([
{
id: 'audit-2',
treeId: 'tree-1',
actorType: 'user',
actorId: 'user-1',
action: 'tree_settings_updated',
entityType: 'tree_settings',
entityId: 'tree-1',
payloadJson: { memberContributionPolicy: 'direct' },
createdAt: new Date('2026-02-11T12:00:00.000Z'),
},
])
const response = await app.inject({
method: 'GET',
url: '/trees/tree-1/audit?limit=20&offset=0',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({
logs: [
{
id: 'audit-2',
treeId: 'tree-1',
actorType: 'user',
actorId: 'user-1',
action: 'tree_settings_updated',
entityType: 'tree_settings',
entityId: 'tree-1',
payloadJson: { memberContributionPolicy: 'direct' },
createdAt: '2026-02-11T12:00:00.000Z',
},
],
nextOffset: null,
})
await app.close()
})
// Audit 1.0 (30/09/2026)
it('refuses the annotations of a tree the account has no access to', async () => {
const app = createApp()
const token = await createUserToken(app, 'stranger')
prismaMock.treeMembership.findUnique.mockResolvedValue(null)
prismaMock.userTreeAccess.findUnique.mockResolvedValue(null)
const response = await app.inject({
method: 'GET',
url: '/trees/tree-1/annotations',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(403)
expect(prismaMock.annotation.findMany).not.toHaveBeenCalled()
await app.close()
})
it('no longer accepts an account session in a media address', async () => {
const app = createApp()
const token = await createUserToken(app)
@@ -74,7 +74,7 @@ describe.skipIf(!TEST_DB)('propositions de la famille sur l\'arbre, vraie base',
const photoAnnotation = (photoPath: string) => ({ type: 'photo', x: 10, y: 20, content: JSON.stringify({ photoPath, width: 1, height: 1 }), style: {}, zIndex: 1 })
const propose = (token: string, changes: unknown[]) => call('POST', `/trees/${treeId}/contributions/sessions`, token, { title: 'Proposition de la famille', submittedByLabel: 'Cousine', changes })
const review = (sessionId: string, decision: 'approved' | 'rejected') => call('POST', `/trees/${treeId}/contributions/sessions/${sessionId}/review`, owner.token, { decision })
const annotations = async () => (await call('GET', `/trees/${treeId}/annotations`, owner.token)).body.annotations as Array<{ id: string; type: string; content: string }>
const annotations = async () => (await call('GET', `/trees/${treeId}/graph`, owner.token)).body.graph.annotations as Array<{ id: string; type: string; content: string }>
const fileOf = async (photoId: string) => (await prisma.annotationPhotoUpload.findUnique({ where: { id: photoId } }))?.filePath
it("personne d'extérieur ne pose une photo : sans jeton 401, un compte sans lien avec l'arbre 403", async () => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

-15
View File
@@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#f8e7cd"/>
<stop offset="100%" stop-color="#e6cfae"/>
</linearGradient>
</defs>
<rect width="320" height="320" fill="url(#bg)"/>
<circle cx="160" cy="126" r="56" fill="#f7e6cf"/>
<path d="M97 253c10-52 35-84 63-84 31 0 56 26 63 84" fill="#8b6a4c"/>
<path d="M102 103c10-31 34-57 58-57 27 0 52 22 58 57-14-12-37-24-58-24s-42 8-58 24z" fill="#5a412c"/>
<circle cx="142" cy="128" r="4" fill="#573f2a"/>
<circle cx="178" cy="128" r="4" fill="#573f2a"/>
<path d="M147 147c6 9 20 9 26 0" stroke="#8c5e42" stroke-width="3.5" fill="none" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 753 B

-16
View File
@@ -1,16 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#f0dec7"/>
<stop offset="100%" stop-color="#c6b5a0"/>
</linearGradient>
</defs>
<rect width="320" height="320" fill="url(#bg)"/>
<circle cx="160" cy="126" r="58" fill="#ecd8c0"/>
<path d="M92 251c8-54 39-86 68-86 31 0 62 21 72 86" fill="#625245"/>
<path d="M99 99c12-39 42-63 61-63 21 0 52 24 62 63-16-17-33-24-62-24s-44 7-61 24z" fill="#34251a"/>
<rect x="108" y="118" width="42" height="24" rx="8" fill="none" stroke="#3f2b1d" stroke-width="3"/>
<rect x="170" y="118" width="42" height="24" rx="8" fill="none" stroke="#3f2b1d" stroke-width="3"/>
<line x1="150" y1="129" x2="170" y2="129" stroke="#3f2b1d" stroke-width="3"/>
<path d="M146 148c6 6 22 6 28 0" stroke="#7a4f37" stroke-width="3.5" fill="none" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 932 B

-12
View File
@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#e5c49d"/>
<stop offset="100%" stop-color="#c39a69"/>
</linearGradient>
</defs>
<rect width="160" height="160" fill="url(#bg)"/>
<circle cx="80" cy="80" r="44" fill="#f6e6cf"/>
<path d="M36 142c10-33 22-51 44-51s33 14 44 51" fill="#8a6648"/>
<path d="M43 64c8-22 22-33 37-33 16 0 31 10 37 33-12-8-23-12-37-12s-25 4-37 12z" fill="#5b412c"/>
</svg>

Before

Width:  |  Height:  |  Size: 532 B

-12
View File
@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#c5ab90"/>
<stop offset="100%" stop-color="#8b7355"/>
</linearGradient>
</defs>
<rect width="160" height="160" fill="url(#bg)"/>
<circle cx="80" cy="80" r="44" fill="#ead8c4"/>
<path d="M35 142c10-34 24-52 45-52s36 15 45 52" fill="#5f4e40"/>
<path d="M40 62c7-24 22-34 40-34 16 0 32 10 40 34-13-8-25-13-40-13s-27 5-40 13z" fill="#312318"/>
</svg>

Before

Width:  |  Height:  |  Size: 532 B

-12
View File
@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#d6b188"/>
<stop offset="100%" stop-color="#a67c52"/>
</linearGradient>
</defs>
<rect width="160" height="160" fill="url(#bg)"/>
<circle cx="80" cy="80" r="44" fill="#f6e4d0"/>
<path d="M35 142c10-34 24-52 45-52s36 15 45 52" fill="#714d34"/>
<path d="M42 62c9-24 24-34 38-34s29 9 38 34c-9-7-20-14-38-14s-29 5-38 14z" fill="#4d3321"/>
</svg>

Before

Width:  |  Height:  |  Size: 526 B

-15
View File
@@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#f1dcc4"/>
<stop offset="100%" stop-color="#d5b694"/>
</linearGradient>
</defs>
<rect width="320" height="320" fill="url(#bg)"/>
<circle cx="160" cy="126" r="58" fill="#f6e6d4"/>
<path d="M91 250c11-58 45-87 69-87 30 0 59 21 71 87" fill="#7c5d43"/>
<path d="M102 94c14-43 43-58 58-58s44 13 58 58c-12-18-25-30-58-30s-46 11-58 30z" fill="#4b3322"/>
<circle cx="141" cy="126" r="4.2" fill="#543a28"/>
<circle cx="179" cy="126" r="4.2" fill="#543a28"/>
<path d="M146 147c6 7 22 7 28 0" stroke="#8d5a41" stroke-width="3.5" fill="none" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 753 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB