diff --git a/backend/src/routes/contributions.ts b/backend/src/routes/contributions.ts index 126c63f..bbe67da 100644 --- a/backend/src/routes/contributions.ts +++ b/backend/src/routes/contributions.ts @@ -71,6 +71,7 @@ const personCreateSchema = z.object({ firstName: z.string().min(1).max(120), lastName: z.string().min(1).max(120), birthName: z.string().max(120).optional().nullable(), + nickname: z.string().max(120).optional().nullable(), birthDate: z.string().regex(isoDateRegex).optional().nullable(), deathDate: z.string().regex(isoDateRegex).optional().nullable(), isDeceased: z.boolean().optional(), @@ -87,6 +88,7 @@ const personUpdateSchema = z firstName: z.string().min(1).max(120).optional(), lastName: z.string().min(1).max(120).optional(), birthName: z.string().max(120).optional().nullable(), + nickname: z.string().max(120).optional().nullable(), birthDate: z.string().regex(isoDateRegex).optional().nullable(), deathDate: z.string().regex(isoDateRegex).optional().nullable(), isDeceased: z.boolean().optional(), @@ -378,6 +380,7 @@ async function applyContributionChange( firstName: parsed.data.firstName.trim(), lastName: parsed.data.lastName.trim(), birthName: toTrimmedStringOrNull(parsed.data.birthName), + nickname: toTrimmedStringOrNull(parsed.data.nickname), birthDate: toDateOrNull(parsed.data.birthDate), deathDate: toDateOrNull(parsed.data.deathDate), isDeceased: !parsed.data.deathDate && Boolean(parsed.data.isDeceased), @@ -435,6 +438,7 @@ async function applyContributionChange( firstName: parsed.data.firstName !== undefined ? parsed.data.firstName.trim() : undefined, lastName: parsed.data.lastName !== undefined ? parsed.data.lastName.trim() : undefined, birthName: parsed.data.birthName !== undefined ? toTrimmedStringOrNull(parsed.data.birthName) : undefined, + nickname: parsed.data.nickname !== undefined ? toTrimmedStringOrNull(parsed.data.nickname) : undefined, birthDate: nextDates.birthDate, deathDate: nextDates.deathDate, isDeceased: nextDates.deathDate ? false : parsed.data.isDeceased, @@ -832,6 +836,7 @@ async function snapshotEntity( firstName: true, lastName: true, birthName: true, + nickname: true, birthDate: true, deathDate: true, isDeceased: true, diff --git a/backend/src/routes/trees.ts b/backend/src/routes/trees.ts index de1805b..27dd338 100644 --- a/backend/src/routes/trees.ts +++ b/backend/src/routes/trees.ts @@ -87,6 +87,7 @@ const createPersonSchema = z.object({ firstName: z.string().min(1).max(120), lastName: z.string().min(1).max(120), birthName: z.string().max(120).optional().nullable(), + nickname: z.string().max(120).optional().nullable(), birthDate: z.string().regex(isoDateRegex).optional().nullable(), deathDate: z.string().regex(isoDateRegex).optional().nullable(), isDeceased: z.boolean().optional(), @@ -103,6 +104,7 @@ const patchPersonSchema = z firstName: z.string().min(1).max(120).optional(), lastName: z.string().min(1).max(120).optional(), birthName: z.string().max(120).optional().nullable(), + nickname: z.string().max(120).optional().nullable(), birthDate: z.string().regex(isoDateRegex).optional().nullable(), deathDate: z.string().regex(isoDateRegex).optional().nullable(), isDeceased: z.boolean().optional(), @@ -1310,6 +1312,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { firstName: payload.data.firstName.trim(), lastName: payload.data.lastName.trim(), birthName: toTrimmedStringOrNull(payload.data.birthName), + nickname: toTrimmedStringOrNull(payload.data.nickname), birthDate: toDateOrNull(payload.data.birthDate), deathDate: toDateOrNull(payload.data.deathDate), // Une date de décès dit déjà que la personne est décédée : le drapeau ne sert que sans date @@ -1368,6 +1371,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { firstName?: string lastName?: string birthName?: string | null + nickname?: string | null birthDate?: Date | null deathDate?: Date | null isDeceased?: boolean @@ -1392,6 +1396,10 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { data.birthName = toTrimmedStringOrNull(payload.data.birthName) } + if (payload.data.nickname !== undefined) { + data.nickname = toTrimmedStringOrNull(payload.data.nickname) + } + if (payload.data.birthDate !== undefined) { data.birthDate = toDateOrNull(payload.data.birthDate) } diff --git a/backend/src/utils/graph-payload.ts b/backend/src/utils/graph-payload.ts index 78a4a04..86c83bb 100644 --- a/backend/src/utils/graph-payload.ts +++ b/backend/src/utils/graph-payload.ts @@ -9,6 +9,7 @@ export type GraphPayload = { firstName: string lastName: string birthName: string | null + nickname: string | null birthDate: string | null deathDate: string | null isDeceased: boolean @@ -84,6 +85,7 @@ export function buildGraphPayload( firstName: person.firstName, lastName: person.lastName, birthName: person.birthName, + nickname: person.nickname, birthDate: formatDate(person.birthDate), deathDate: formatDate(person.deathDate), isDeceased: Boolean(person.isDeceased) && !person.deathDate, diff --git a/backend/test/api-routes.test.ts b/backend/test/api-routes.test.ts index 5f9b606..e1e58ab 100644 --- a/backend/test/api-routes.test.ts +++ b/backend/test/api-routes.test.ts @@ -985,3 +985,26 @@ describe('Relationship guardrails', () => { await app.close() }) }) + +describe('Surnom (nickname) en écriture directe', () => { + it('PATCH accepte le surnom, le coupe, et vide = null', async () => { + const app = createApp() + const token = await createUserToken(app) + const patch = (payload: object) => app.inject({ method: 'PATCH', url: '/trees/tree-1/persons/person-1', headers: { authorization: `Bearer ${token}` }, payload }) + + expect((await patch({ nickname: ' Loulou ' })).statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ nickname: 'Loulou' }) + + expect((await patch({ nickname: ' ' })).statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ nickname: null }) + await app.close() + }) + + it('refuse un surnom de plus de 120 caractères', async () => { + const app = createApp() + const token = await createUserToken(app) + const res = await app.inject({ method: 'PATCH', url: '/trees/tree-1/persons/person-1', headers: { authorization: `Bearer ${token}` }, payload: { nickname: 'x'.repeat(121) } }) + expect(res.statusCode).toBe(400) + await app.close() + }) +}) diff --git a/backend/test/e2e-journeys.test.ts b/backend/test/e2e-journeys.test.ts index f09bdb6..1a08cdf 100644 --- a/backend/test/e2e-journeys.test.ts +++ b/backend/test/e2e-journeys.test.ts @@ -734,3 +734,75 @@ describe('Journey 4 — Admin reviews a contribution session', () => { await app.close() }) }) + +describe('Surnom (nickname) proposé par un contributeur', () => { + it('soumis en attente, puis appliqué par le propriétaire avec la valeur nettoyée', async () => { + const app = createApp() + await app.ready() + + const contributorToken = (app as any).jwt.sign({ + kind: 'tree_access', + sub: 'tree:tree-1:contributor', + treeId: 'tree-1', + role: 'contributor', + accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime(), + }) + prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', deletedAt: null, settings: { contributorPolicy: 'pending' } }) + + const submitRes = await app.inject({ + method: 'POST', + url: '/trees/tree-1/contributions/sessions', + headers: { authorization: `Bearer ${contributorToken}` }, + payload: { + title: 'Surnom de Robert', + submittedByLabel: 'Jeanette Martin', + changes: [ + { + entityType: 'person', + action: 'update', + entityId: 'person-1', + before: { nickname: null }, + after: { nickname: ' Loulou ' }, + }, + ], + }, + }) + expect(submitRes.statusCode).toBe(201) + expect(submitRes.json().session.status).toBe('pending') + + const adminToken = (app as any).jwt.sign( + { kind: 'user', sub: 'user-1', jti: randomUUID(), userId: 'user-1', email: 'owner@example.com' }, + { expiresIn: '1h' }, + ) + prismaMock.contributionSession.findFirst.mockResolvedValue({ + id: 'session-n', + treeId: 'tree-1', + status: 'pending', + title: 'Surnom de Robert', + changes: [ + { + id: 'change-n', + entityType: 'person', + action: 'update', + entityId: 'person-1', + beforeJson: { nickname: null }, + afterJson: { nickname: ' Loulou ' }, + conflictState: 'none', + }, + ], + }) + prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', treeId: 'tree-1', firstName: 'Robert', lastName: 'Dupont', nickname: null, deletedAt: null }) + + const reviewRes = await app.inject({ + method: 'POST', + url: '/trees/tree-1/contributions/sessions/session-n/review', + headers: { authorization: `Bearer ${adminToken}` }, + payload: { decision: 'approved' }, + }) + expect(reviewRes.statusCode).toBe(200) + expect(reviewRes.json().approvedCount).toBe(1) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ nickname: 'Loulou' }) + + await app.close() + }) +}) diff --git a/backend/test/graph-payload.test.ts b/backend/test/graph-payload.test.ts index bb18e36..e534aec 100644 --- a/backend/test/graph-payload.test.ts +++ b/backend/test/graph-payload.test.ts @@ -13,6 +13,7 @@ describe('buildGraphPayload', () => { firstName: 'Zoe', lastName: 'Martin', birthName: 'Durand', + nickname: 'Zozo', birthDate: new Date('1994-06-02T00:00:00.000Z'), deathDate: null, sex: null, @@ -115,6 +116,7 @@ describe('buildGraphPayload', () => { expect(graph.persons[0].id).toBe('p-2') expect(graph.persons[0].birthDate).toBe('1994-06-02') expect(graph.persons[0].birthName).toBe('Durand') + expect(graph.persons[0].nickname).toBe('Zozo') expect(graph.persons[0].avatarUrlPath).toBe('/trees/t-1/persons/p-2/avatar') // L'identifiant Immich ne part jamais dans le graphe : il ne sert qu'aux administrateurs. // Seule l'origine du portrait (fichier préfixé « immich- ») est publiée. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 07f2d45..98d536d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -239,6 +239,7 @@ function buildContributionChangesFromDraft(draft = {}) { firstName: String(person.firstName).trim(), lastName: String(person.lastName).trim(), birthName: person.birthName ? String(person.birthName).trim() : null, + nickname: person.nickname ? String(person.nickname).trim() : null, birthDate: person.birthDate || null, deathDate: person.deathDate || null, isDeceased: !person.deathDate && Boolean(person.isDeceased), @@ -283,6 +284,7 @@ function buildContributionChangesFromDraft(draft = {}) { if (Object.prototype.hasOwnProperty.call(source, 'firstName')) after.firstName = source.firstName || '' if (Object.prototype.hasOwnProperty.call(source, 'lastName')) after.lastName = source.lastName || '' if (Object.prototype.hasOwnProperty.call(source, 'birthName')) after.birthName = (source.birthName || '').trim() || null + if (Object.prototype.hasOwnProperty.call(source, 'nickname')) after.nickname = (source.nickname || '').trim() || null if (Object.prototype.hasOwnProperty.call(source, 'birthYear')) after.birthDate = yearToIsoDate(source.birthYear) if (Object.prototype.hasOwnProperty.call(source, 'deathYear')) after.deathDate = yearToIsoDate(source.deathYear) if (Object.prototype.hasOwnProperty.call(source, 'birthPlace')) after.birthPlace = (source.birthPlace || '').trim() || null @@ -996,6 +998,7 @@ function App() { firstName: formData.firstName, lastName: formData.lastName || '', birthName: formData.birthName || null, + nickname: formData.nickname || null, birthDate: formData.birthDate || null, deathDate: formData.deathDate || null, birthYear, @@ -1075,6 +1078,7 @@ function App() { firstName: formData.firstName, lastName: formData.lastName, birthName: formData.birthName || null, + nickname: formData.nickname || null, birthDate: formData.birthDate || null, deathDate: formData.deathDate || null, isDeceased: Boolean(formData.isDeceased), @@ -1101,6 +1105,7 @@ function App() { firstName: formData.firstName, lastName: formData.lastName, birthName: formData.birthName, + nickname: formData.nickname, birthDate: formData.birthDate, deathDate: formData.deathDate, isDeceased: Boolean(formData.isDeceased), @@ -1230,6 +1235,7 @@ function App() { if (Object.prototype.hasOwnProperty.call(rawChanges, 'firstName')) payload.firstName = rawChanges.firstName || '' if (Object.prototype.hasOwnProperty.call(rawChanges, 'lastName')) payload.lastName = rawChanges.lastName || '' if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthName')) payload.birthName = (rawChanges.birthName || '').trim() || null + if (Object.prototype.hasOwnProperty.call(rawChanges, 'nickname')) payload.nickname = (rawChanges.nickname || '').trim() || null if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthYear')) payload.birthDate = yearToIsoDate(rawChanges.birthYear) if (Object.prototype.hasOwnProperty.call(rawChanges, 'deathYear')) payload.deathDate = yearToIsoDate(rawChanges.deathYear) if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthPlace')) payload.birthPlace = (rawChanges.birthPlace || '').trim() || null @@ -1504,6 +1510,7 @@ function App() { if (Object.prototype.hasOwnProperty.call(changes, 'firstName')) localPerson.firstName = changes.firstName || '' if (Object.prototype.hasOwnProperty.call(changes, 'lastName')) localPerson.lastName = changes.lastName || '' if (Object.prototype.hasOwnProperty.call(changes, 'birthName')) localPerson.birthName = changes.birthName || null + if (Object.prototype.hasOwnProperty.call(changes, 'nickname')) localPerson.nickname = changes.nickname || null if (Object.prototype.hasOwnProperty.call(changes, 'birthYear')) { const iso = yearToIsoDate(changes.birthYear) localPerson.birthDate = iso @@ -1540,6 +1547,7 @@ function App() { if (Object.prototype.hasOwnProperty.call(changes, 'firstName')) payload.firstName = changes.firstName || '' if (Object.prototype.hasOwnProperty.call(changes, 'lastName')) payload.lastName = changes.lastName || '' if (Object.prototype.hasOwnProperty.call(changes, 'birthName')) payload.birthName = (changes.birthName || '').trim() || null + if (Object.prototype.hasOwnProperty.call(changes, 'nickname')) payload.nickname = (changes.nickname || '').trim() || null if (Object.prototype.hasOwnProperty.call(changes, 'birthYear')) payload.birthDate = yearToIsoDate(changes.birthYear) if (Object.prototype.hasOwnProperty.call(changes, 'deathYear')) payload.deathDate = yearToIsoDate(changes.deathYear) if (Object.prototype.hasOwnProperty.call(changes, 'birthPlace')) payload.birthPlace = (changes.birthPlace || '').trim() || null diff --git a/frontend/src/components/AddPersonPanel.jsx b/frontend/src/components/AddPersonPanel.jsx index 288e5fa..4f65abb 100644 --- a/frontend/src/components/AddPersonPanel.jsx +++ b/frontend/src/components/AddPersonPanel.jsx @@ -34,6 +34,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on const [firstName, setFirstName] = useState('') const [lastName, setLastName] = useState('') const [birthName, setBirthName] = useState('') + const [nickname, setNickname] = useState('') const [birthDate, setBirthDate] = useState('') const [deathDate, setDeathDate] = useState('') // Décédé(e) sans date connue (issue #77) @@ -91,6 +92,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on setFirstName('') setLastName('') setBirthName('') + setNickname('') setBirthDate('') setDeathDate('') setIsDeceased(false) @@ -139,6 +141,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on firstName: firstName.trim(), lastName: lastName.trim(), birthName: birthName.trim() || null, + nickname: nickname.trim() || null, birthDate: birthDate || null, deathDate: deathDate || null, isDeceased: !deathDate && isDeceased, @@ -222,6 +225,19 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on /> +
{person.nickname}
} {!editMode && lifeLine &&{lifeLine}
}