fix(arbre): bornes des positions et conflit de relecture

- Les coordonnées de disposition sont bornées à ±1 000 000 (PATCH personne,
  modification en session de contribution, OpenAPI).
- Un déplacement par le propriétaire ne met plus en conflit les
  contributions en attente sur la même personne : layoutX/layoutY sont
  exclus de la comparaison de conflit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-16 18:08:50 +02:00
1 parent 43441f31e1
commit 420bda5e38
5 files changed
+110 -4

No files matched your search

+4
View File
@@ -356,9 +356,13 @@ components:
layoutX:
type: number
nullable: true
minimum: -1000000
maximum: 1000000
layoutY:
type: number
nullable: true
minimum: -1000000
maximum: 1000000
sex:
type: string
nullable: true
+9 -2
View File
@@ -10,8 +10,11 @@ import { removeStoredFile } from '../lib/media-files.js'
import { syncPersonMediaOrder } from '../lib/media-order.js'
import type { Actor, MembershipRole } from '../types/auth.js'
import { LAYOUT_COORDINATE_LIMIT } from '../utils/graph-payload.js'
import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js'
const layoutCoordinateSchema = z.number().finite().min(-LAYOUT_COORDINATE_LIMIT).max(LAYOUT_COORDINATE_LIMIT)
const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/
const treeIdParamSchema = z.object({
@@ -86,8 +89,8 @@ const personUpdateSchema = z
birthName: z.string().max(120).optional().nullable(),
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
layoutX: z.number().finite().optional().nullable(),
layoutY: z.number().finite().optional().nullable(),
layoutX: layoutCoordinateSchema.optional().nullable(),
layoutY: layoutCoordinateSchema.optional().nullable(),
birthPlace: z.string().max(200).optional().nullable(),
profession: z.string().max(200).optional().nullable(),
region: z.string().max(120).optional().nullable(),
@@ -781,6 +784,8 @@ async function applyContributionChange(
}
const SNAPSHOT_ENTITY_TYPES = new Set(['person', 'union', 'parent_child_link'])
// Champs qui ne créent jamais de conflit de relecture (positions dans l'arbre, déplacées à la main)
const NON_CONFLICTING_KEYS = new Set(['layoutX', 'layoutY'])
// État actuel d'une personne, union ou lien, tel qu'on le montre à la relecture. null : introuvable dans l'arbre.
async function snapshotEntity(
@@ -853,6 +858,8 @@ async function detectConflict(
const snap = await snapshotEntity(tx, treeId, change.entityType, change.entityId)
if (!snap) return true
for (const key of Object.keys(before)) {
// La place dans l'arbre bouge à chaque glisser du propriétaire : ce n'est pas une modification concurrente
if (NON_CONFLICTING_KEYS.has(key)) continue
if (key in snap && String(snap[key] ?? '') !== String(before[key] ?? '')) return true
}
return false
+5 -2
View File
@@ -13,8 +13,11 @@ import { decryptSharePassword, encryptSharePassword } from '../lib/share-passwor
import { purgeTrees } from '../lib/tree-purge.js'
import { reserveTreeUnlockAttempt, serializePasswordCheck } from '../lib/unlock-guard.js'
import type { MembershipRole } from '../types/auth.js'
import { LAYOUT_COORDINATE_LIMIT } from '../utils/graph-payload.js'
import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js'
const layoutCoordinateSchema = z.number().finite().min(-LAYOUT_COORDINATE_LIMIT).max(LAYOUT_COORDINATE_LIMIT)
const MAX_TREES_PER_ACCOUNT = 10
const treeIdParamSchema = z.object({
@@ -101,8 +104,8 @@ const patchPersonSchema = z
birthName: z.string().max(120).optional().nullable(),
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
layoutX: z.number().finite().optional().nullable(),
layoutY: z.number().finite().optional().nullable(),
layoutX: layoutCoordinateSchema.optional().nullable(),
layoutY: layoutCoordinateSchema.optional().nullable(),
birthPlace: z.string().max(200).optional().nullable(),
profession: z.string().max(200).optional().nullable(),
region: z.string().max(120).optional().nullable(),
+3
View File
@@ -2,6 +2,9 @@ import type { Annotation, MediaItem, ParentChildLink, Person, Union } from '@pri
import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js'
// Espace des coordonnées de disposition manuelle (unités du graphe). Au-delà, rien de réel dans un arbre.
export const LAYOUT_COORDINATE_LIMIT = 1_000_000
export type GraphPayload = {
persons: Array<{
id: string
@@ -541,6 +541,44 @@ describe('API security and authz', () => {
await app.close()
})
it('refuses a manual layout coordinate outside the tree space', async () => {
const app = createApp()
const token = await createUserToken(app)
const response = await app.inject({
method: 'PATCH',
url: '/trees/tree-1/persons/person-1',
headers: { authorization: `Bearer ${token}` },
payload: { layoutX: 2_000_000, layoutY: 10 },
})
expect(response.statusCode).toBe(400)
expect(prismaMock.person.updateMany).not.toHaveBeenCalled()
await app.close()
})
it('forbids a member from moving a person by hand', 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: 'PATCH',
url: '/trees/tree-1/persons/person-1',
headers: { authorization: `Bearer ${token}` },
payload: { layoutX: 120, layoutY: 240 },
})
expect(response.statusCode).toBe(403)
expect(prismaMock.person.updateMany).not.toHaveBeenCalled()
await app.close()
})
it('allows contributor access token to submit a pending contribution session', async () => {
const app = createApp()
await app.ready()
@@ -930,6 +968,57 @@ describe('API security and authz', () => {
await app.close()
})
it('does not flag a pending person change as a conflict when the owner only moved that person', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.contributionSession.findFirst.mockResolvedValue({
id: 'session-1',
treeId: 'tree-1',
status: 'pending',
changes: [
{
id: 'change-1',
entityType: 'person',
action: 'update',
entityId: 'person-1',
beforeJson: { firstName: 'Alice', layoutX: null, layoutY: null },
afterJson: { firstName: 'Alicia' },
conflictState: 'none',
},
],
})
// Le propriétaire a glissé la personne depuis l'envoi : seule la place a changé
prismaMock.person.findFirst.mockResolvedValue({
firstName: 'Alice',
lastName: 'Martin',
birthName: null,
birthDate: null,
deathDate: null,
layoutX: 300,
layoutY: 400,
birthPlace: null,
profession: null,
region: null,
nationality: null,
sex: null,
notes: null,
})
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/contributions/sessions/session-1/review',
headers: { authorization: `Bearer ${token}` },
payload: { decision: 'approved' },
})
expect(response.statusCode).toBe(200)
expect(prismaMock.contributionChange.update).not.toHaveBeenCalledWith(expect.objectContaining({
data: { conflictState: 'needs_review' },
}))
await app.close()
})
it('allows admin to list pending contribution sessions', async () => {
const app = createApp()
const token = await createUserToken(app)