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>
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 24 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
|
Before Width: | Height: | Size: 148 KiB |