diff --git a/backend/openapi/v1.yaml b/backend/openapi/v1.yaml index 95027b7..f65e8d2 100644 --- a/backend/openapi/v1.yaml +++ b/backend/openapi/v1.yaml @@ -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] diff --git a/backend/src/routes/annotations.ts b/backend/src/routes/annotations.ts index 3b73674..d51a089 100644 --- a/backend/src/routes/annotations.ts +++ b/backend/src/routes/annotations.ts @@ -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 = {} - 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) diff --git a/backend/src/routes/trees.ts b/backend/src/routes/trees.ts index 6026e89..fb06ac8 100644 --- a/backend/src/routes/trees.ts +++ b/backend/src/routes/trees.ts @@ -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) { diff --git a/backend/test/api-security.integration.test.ts b/backend/test/api-security.integration.test.ts index f178306..91dc2e8 100644 --- a/backend/test/api-security.integration.test.ts +++ b/backend/test/api-security.integration.test.ts @@ -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) diff --git a/backend/test/propositions-fantomes.db.test.ts b/backend/test/propositions-fantomes.db.test.ts index 21b4408..55f86f8 100644 --- a/backend/test/propositions-fantomes.db.test.ts +++ b/backend/test/propositions-fantomes.db.test.ts @@ -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 () => { diff --git a/landing/public/aide/accueil.webp b/landing/public/aide/accueil.webp deleted file mode 100644 index 059ae2b..0000000 Binary files a/landing/public/aide/accueil.webp and /dev/null differ diff --git a/landing/public/en/help/accueil.webp b/landing/public/en/help/accueil.webp deleted file mode 100644 index 99787ab..0000000 Binary files a/landing/public/en/help/accueil.webp and /dev/null differ diff --git a/landing/public/hero/child.svg b/landing/public/hero/child.svg deleted file mode 100644 index 40e5675..0000000 --- a/landing/public/hero/child.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/landing/public/hero/father.svg b/landing/public/hero/father.svg deleted file mode 100644 index 8c66951..0000000 --- a/landing/public/hero/father.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/landing/public/hero/memory-child.svg b/landing/public/hero/memory-child.svg deleted file mode 100644 index 6afd1c3..0000000 --- a/landing/public/hero/memory-child.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/landing/public/hero/memory-father.svg b/landing/public/hero/memory-father.svg deleted file mode 100644 index 789b153..0000000 --- a/landing/public/hero/memory-father.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/landing/public/hero/memory-mother.svg b/landing/public/hero/memory-mother.svg deleted file mode 100644 index 55c3de9..0000000 --- a/landing/public/hero/memory-mother.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/landing/public/hero/mother.svg b/landing/public/hero/mother.svg deleted file mode 100644 index a379d8f..0000000 --- a/landing/public/hero/mother.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/landing/public/og-image-hero.png b/landing/public/og-image-hero.png deleted file mode 100644 index 7b5375c..0000000 Binary files a/landing/public/og-image-hero.png and /dev/null differ