diff --git a/backend/src/routes/contributions.ts b/backend/src/routes/contributions.ts index 928c2b3..b79eab4 100644 --- a/backend/src/routes/contributions.ts +++ b/backend/src/routes/contributions.ts @@ -77,6 +77,7 @@ const personCreateSchema = z.object({ deathDate: z.string().regex(isoDateRegex).optional().nullable(), isDeceased: z.boolean().optional(), birthPlace: z.string().max(200).optional().nullable(), + deathPlace: z.string().max(200).optional().nullable(), profession: z.string().max(200).optional().nullable(), region: z.string().max(120).optional().nullable(), nationality: z.string().max(120).optional().nullable(), @@ -94,6 +95,7 @@ const personUpdateSchema = z deathDate: z.string().regex(isoDateRegex).optional().nullable(), isDeceased: z.boolean().optional(), birthPlace: z.string().max(200).optional().nullable(), + deathPlace: z.string().max(200).optional().nullable(), profession: z.string().max(200).optional().nullable(), region: z.string().max(120).optional().nullable(), nationality: z.string().max(120).optional().nullable(), @@ -386,6 +388,7 @@ async function applyContributionChange( deathDate: toDateOrNull(parsed.data.deathDate), isDeceased: !parsed.data.deathDate && Boolean(parsed.data.isDeceased), birthPlace: toTrimmedStringOrNull(parsed.data.birthPlace), + deathPlace: toTrimmedStringOrNull(parsed.data.deathPlace), profession: toTrimmedStringOrNull(parsed.data.profession), region: toTrimmedStringOrNull(parsed.data.region), nationality: toTrimmedStringOrNull(parsed.data.nationality), @@ -454,6 +457,7 @@ async function applyContributionChange( deathDate: nextDates.deathDate, isDeceased: nextDates.deathDate ? false : parsed.data.isDeceased, birthPlace: parsed.data.birthPlace !== undefined ? toTrimmedStringOrNull(parsed.data.birthPlace) : undefined, + deathPlace: parsed.data.deathPlace !== undefined ? toTrimmedStringOrNull(parsed.data.deathPlace) : undefined, profession: parsed.data.profession !== undefined ? toTrimmedStringOrNull(parsed.data.profession) : undefined, region: parsed.data.region !== undefined ? toTrimmedStringOrNull(parsed.data.region) : undefined, nationality: parsed.data.nationality !== undefined ? toTrimmedStringOrNull(parsed.data.nationality) : undefined, @@ -852,6 +856,7 @@ async function snapshotEntity( deathDate: true, isDeceased: true, birthPlace: true, + deathPlace: true, profession: true, region: true, nationality: true, diff --git a/backend/src/routes/media.ts b/backend/src/routes/media.ts index 3aa4ec1..9d1bf4e 100644 --- a/backend/src/routes/media.ts +++ b/backend/src/routes/media.ts @@ -97,6 +97,13 @@ const reorderMediaSchema = z.object({ orderedMediaIds: z.array(z.string().min(1)).min(1), }) +const MAX_CAPTION_LENGTH = 280 +const MAX_CITATION_LENGTH = 2000 +const updateMediaSchema = z.object({ + caption: z.string().trim().min(1).max(MAX_CITATION_LENGTH), + lifeEventId: z.string().min(1).max(64).nullable().optional(), +}) + function hasAdminRight(role: MembershipRole): boolean { return role === 'owner' || role === 'admin' } @@ -822,6 +829,113 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return reply.send({ ok: true }) }) + // Modifier un souvenir : titre (caption) et moment de vie. Pour une citation, caption est aussi le texte + // du fichier citation.txt, réécrit ici pour que la lecture et le téléchargement restent identiques. + app.patch('/trees/:id/persons/:personId/media/:mediaId', async (request, reply) => { + if (!request.actor || request.actor.kind !== 'user') { + return reply.code(401).send({ error: 'authentication_required' }) + } + + const params = z + .object({ + id: z.string().min(1), + personId: z.string().min(1), + mediaId: z.string().min(1), + }) + .safeParse(request.params) + + if (!params.success) { + return reply.code(400).send({ error: 'invalid_params' }) + } + + const membership = await requireAdminMembership(app, request.actor.userId, params.data.id) + if (!membership) { + return reply.code(403).send({ error: 'forbidden' }) + } + + const payload = updateMediaSchema.safeParse(request.body) + if (!payload.success) { + return reply.code(400).send({ error: 'invalid_payload' }) + } + + const existingMedia = await app.prisma.mediaItem.findFirst({ + where: { id: params.data.mediaId, treeId: params.data.id, personId: params.data.personId, deletedAt: null }, + select: { id: true, type: true, filePath: true }, + }) + if (!existingMedia) { + return reply.code(404).send({ error: 'media_not_found' }) + } + + const isCitation = existingMedia.type === 'citation' + if (!isCitation && payload.data.caption.length > MAX_CAPTION_LENGTH) { + return reply.code(400).send({ error: 'caption_too_long' }) + } + + const lifeEventId = payload.data.lifeEventId + if (lifeEventId) { + const event = await app.prisma.lifeEvent.findFirst({ + where: { id: lifeEventId, treeId: params.data.id, deletedAt: null }, + select: { personId: true, unionId: true, status: true }, + }) + let ownsEvent = event?.personId === params.data.personId + if (event && !ownsEvent && event.unionId) { + const union = await app.prisma.union.findFirst({ + where: { id: event.unionId, treeId: params.data.id }, + select: { partner1PersonId: true, partner2PersonId: true }, + }) + ownsEvent = union?.partner1PersonId === params.data.personId || union?.partner2PersonId === params.data.personId + } + if (!event || !ownsEvent || event.status !== 'approved') { + return reply.code(400).send({ error: 'invalid_life_event_reference' }) + } + } + + const data: { caption: string; lifeEventId?: string | null; sizeBytes?: number } = { + caption: payload.data.caption, + } + if (lifeEventId !== undefined) data.lifeEventId = lifeEventId + + if (isCitation) { + const buffer = Buffer.from(payload.data.caption, 'utf8') + const storageRoot = getStorageRoot() + const absolutePath = path.resolve(storageRoot, existingMedia.filePath) + if (!absolutePath.startsWith(storageRoot + path.sep)) { + return reply.code(400).send({ error: 'invalid_media_path' }) + } + await writeFile(absolutePath, buffer) + data.sizeBytes = buffer.length + } + + await app.prisma.mediaItem.updateMany({ + where: { id: params.data.mediaId, treeId: params.data.id, personId: params.data.personId, deletedAt: null }, + data, + }) + + await app.prisma.auditLog.create({ + data: { + treeId: params.data.id, + actorType: 'user', + actorId: request.actor.userId, + action: 'media_updated', + entityType: 'media_item', + entityId: params.data.mediaId, + payloadJson: { + personId: params.data.personId, + lifeEventChanged: lifeEventId !== undefined, + }, + }, + }) + + return reply.send({ + ok: true, + media: { + id: params.data.mediaId, + caption: payload.data.caption, + lifeEventId, + }, + }) + }) + app.delete('/trees/:id/persons/:personId/media/:mediaId', async (request, reply) => { if (!request.actor || request.actor.kind !== 'user') { return reply.code(401).send({ error: 'authentication_required' }) diff --git a/backend/src/routes/trees.ts b/backend/src/routes/trees.ts index c95ab95..a549262 100644 --- a/backend/src/routes/trees.ts +++ b/backend/src/routes/trees.ts @@ -93,6 +93,7 @@ const createPersonSchema = z.object({ deathDate: z.string().regex(isoDateRegex).optional().nullable(), isDeceased: z.boolean().optional(), birthPlace: z.string().max(200).optional().nullable(), + deathPlace: z.string().max(200).optional().nullable(), profession: z.string().max(200).optional().nullable(), region: z.string().max(120).optional().nullable(), nationality: z.string().max(120).optional().nullable(), @@ -110,6 +111,7 @@ const patchPersonSchema = z deathDate: z.string().regex(isoDateRegex).optional().nullable(), isDeceased: z.boolean().optional(), birthPlace: z.string().max(200).optional().nullable(), + deathPlace: z.string().max(200).optional().nullable(), profession: z.string().max(200).optional().nullable(), region: z.string().max(120).optional().nullable(), nationality: z.string().max(120).optional().nullable(), @@ -1319,6 +1321,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { // Une date de décès dit déjà que la personne est décédée : le drapeau ne sert que sans date isDeceased: !payload.data.deathDate && Boolean(payload.data.isDeceased), birthPlace: toTrimmedStringOrNull(payload.data.birthPlace), + deathPlace: toTrimmedStringOrNull(payload.data.deathPlace), profession: toTrimmedStringOrNull(payload.data.profession), region: toTrimmedStringOrNull(payload.data.region), nationality: toTrimmedStringOrNull(payload.data.nationality), @@ -1388,6 +1391,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { deathDate?: Date | null isDeceased?: boolean birthPlace?: string | null + deathPlace?: string | null profession?: string | null region?: string | null nationality?: string | null @@ -1432,6 +1436,10 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { data.birthPlace = toTrimmedStringOrNull(payload.data.birthPlace) } + if (payload.data.deathPlace !== undefined) { + data.deathPlace = toTrimmedStringOrNull(payload.data.deathPlace) + } + if (payload.data.profession !== undefined) { data.profession = toTrimmedStringOrNull(payload.data.profession) } diff --git a/backend/src/utils/graph-payload.ts b/backend/src/utils/graph-payload.ts index 5ae6e46..a789959 100644 --- a/backend/src/utils/graph-payload.ts +++ b/backend/src/utils/graph-payload.ts @@ -15,6 +15,7 @@ export type GraphPayload = { deathDate: string | null isDeceased: boolean birthPlace: string | null + deathPlace: string | null profession: string | null region: string | null nationality: string | null @@ -92,6 +93,7 @@ export function buildGraphPayload( deathDate: formatDate(person.deathDate), isDeceased: Boolean(person.isDeceased) && !person.deathDate, birthPlace: person.birthPlace, + deathPlace: person.deathPlace, profession: person.profession, region: person.region, nationality: person.nationality, diff --git a/backend/test/api-routes.test.ts b/backend/test/api-routes.test.ts index e1e58ab..8fa3317 100644 --- a/backend/test/api-routes.test.ts +++ b/backend/test/api-routes.test.ts @@ -1008,3 +1008,29 @@ describe('Surnom (nickname) en écriture directe', () => { await app.close() }) }) + +describe('Lieu de décès et filiation adoptive (écriture)', () => { + it('PATCH accepte le lieu de décès, 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({ deathPlace: ' Nantes, France ' })).statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ deathPlace: 'Nantes, France' }) + + expect((await patch({ deathPlace: ' ' })).statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ deathPlace: null }) + await app.close() + }) + + it('PATCH d’un lien accepte le type de filiation (naissance ou adoption)', async () => { + const app = createApp() + const token = await createUserToken(app) + prismaMock.parentChildLink.findFirst.mockResolvedValue({ id: 'link-1', parentPersonId: 'person-2', childPersonId: 'person-1', viaUnionId: null, parentageType: 'biologique' }) + prismaMock.parentChildLink.updateMany.mockResolvedValueOnce({ count: 1 }) + const res = await app.inject({ method: 'PATCH', url: '/trees/tree-1/links/link-1', headers: { authorization: `Bearer ${token}` }, payload: { parentageType: 'adoption' } }) + expect(res.statusCode).toBe(200) + expect(prismaMock.parentChildLink.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ parentageType: 'adoption' }) + await app.close() + }) +}) diff --git a/backend/test/media-update.test.ts b/backend/test/media-update.test.ts new file mode 100644 index 0000000..9cd7d04 --- /dev/null +++ b/backend/test/media-update.test.ts @@ -0,0 +1,279 @@ +import { randomUUID } from 'node:crypto' +import type { FastifyInstance } from 'fastify' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { prismaMock } = vi.hoisted(() => ({ + prismaMock: { + user: { + findUnique: vi.fn(), + count: vi.fn().mockResolvedValue(1), + create: vi.fn(), + update: vi.fn(), + }, + tree: { + findFirst: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + updateMany: vi.fn(), + }, + treeMembership: { + count: vi.fn(async () => 0), + findMany: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + }, + treeSettings: { + create: vi.fn(), + findUnique: vi.fn(), + upsert: vi.fn(), + }, + treeAccessPasswords: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + treeSharePassword: { + findUnique: vi.fn(), + upsert: vi.fn(), + deleteMany: vi.fn(), + }, + userTreeAccess: { + findMany: vi.fn(), + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + person: { + findMany: vi.fn(), + findFirst: vi.fn(), + create: vi.fn(), + count: vi.fn(), + updateMany: vi.fn(), + }, + union: { + findMany: vi.fn(), + findFirst: vi.fn(), + create: vi.fn(), + count: vi.fn(), + updateMany: vi.fn(), + }, + parentChildLink: { + findMany: vi.fn(), + findFirst: vi.fn(), + create: vi.fn(), + count: vi.fn(), + updateMany: vi.fn(), + }, + mediaItem: { + count: vi.fn(), aggregate: vi.fn().mockResolvedValue({ _sum: { sizeBytes: 0 } }), + create: vi.fn(), + findMany: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, + lifeEvent: { + findFirst: vi.fn(), + findMany: vi.fn().mockResolvedValue([]), + createMany: vi.fn().mockResolvedValue({ count: 0 }), + updateMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + annotation: { + findMany: vi.fn(), + }, + contributionSession: { + create: vi.fn(), + findMany: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), updateMany: vi.fn().mockResolvedValue({ count: 1 }), + count: vi.fn(), + }, + contributionChange: { + create: vi.fn(), + update: vi.fn(), + }, + auditLog: { + create: vi.fn(), + findMany: vi.fn(), + }, + $transaction: vi.fn(), + $disconnect: vi.fn(), + }, +})) + +vi.mock('../src/lib/prisma.js', () => ({ + prisma: prismaMock, +})) + +let createApp: () => FastifyInstance + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, writeFile: vi.fn(async () => undefined), mkdir: vi.fn(async () => undefined) } +}) + +function applyTestEnv(): void { + process.env.NODE_ENV = 'test' + process.env.PORT = '4000' + process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy' + process.env.JWT_SECRET = 'test-secret-with-sufficient-length' + process.env.JWT_EXPIRES_IN = '7d' + process.env.JWT_TREE_ACCESS_EXPIRES_IN = '24h' + process.env.CORS_ORIGIN = 'http://localhost:5173' +} + +async function createUserToken(app: FastifyInstance, userId = 'user-1'): Promise { + await app.ready() + return (app as any).jwt.sign( + { kind: 'user', sub: userId, jti: randomUUID(), userId, email: 'user@example.com' }, + { expiresIn: '1h' }, + ) +} + +const URL_MEDIA = '/trees/tree-1/persons/person-1/media/media-1' + +beforeAll(async () => { + applyTestEnv() + const module = await import('../src/app.js') + createApp = module.createApp +}) + +beforeEach(() => { + vi.clearAllMocks() + prismaMock.user.findUnique.mockResolvedValue(null) + prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: null }) + prismaMock.treeMembership.findUnique.mockResolvedValue({ + treeId: 'tree-1', + userId: 'user-1', + role: 'owner', + tree: { deletedAt: null, rootPersonId: null }, + }) + prismaMock.mediaItem.findFirst.mockResolvedValue({ id: 'media-1', type: 'photo', filePath: 'tree-1/person-1/media-1.jpg' }) + prismaMock.mediaItem.updateMany.mockResolvedValue({ count: 1 }) + prismaMock.lifeEvent.findFirst.mockResolvedValue({ personId: 'person-1', unionId: null, status: 'approved' }) + prismaMock.auditLog.create.mockResolvedValue({ id: 'audit-1' }) +}) + +describe('PATCH media (modifier un souvenir)', () => { + it('refuses a visitor', async () => { + prismaMock.treeMembership.findUnique.mockResolvedValueOnce({ treeId: 'tree-1', userId: 'user-1', role: 'visitor', tree: { deletedAt: null } }) + const app = createApp() + const token = await createUserToken(app) + const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Titre' } }) + expect(response.statusCode).toBe(403) + expect(prismaMock.mediaItem.updateMany).not.toHaveBeenCalled() + await app.close() + }) + + it('refuses a contributor', async () => { + prismaMock.treeMembership.findUnique.mockResolvedValueOnce({ treeId: 'tree-1', userId: 'user-1', role: 'contributor', tree: { deletedAt: null } }) + const app = createApp() + const token = await createUserToken(app) + const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Titre' } }) + expect(response.statusCode).toBe(403) + expect(prismaMock.mediaItem.updateMany).not.toHaveBeenCalled() + await app.close() + }) + + it('accepts an admin and updates title and life event', async () => { + const app = createApp() + const token = await createUserToken(app) + const response = await app.inject({ + method: 'PATCH', + url: URL_MEDIA, + headers: { authorization: `Bearer ${token}` }, + payload: { caption: 'Mariage à Caen', lifeEventId: 'event-1' }, + }) + expect(response.statusCode).toBe(200) + expect(prismaMock.mediaItem.updateMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ id: 'media-1', treeId: 'tree-1', personId: 'person-1' }), + data: { caption: 'Mariage à Caen', lifeEventId: 'event-1' }, + })) + expect(response.json().media).toEqual({ id: 'media-1', caption: 'Mariage à Caen', lifeEventId: 'event-1' }) + await app.close() + }) + + it('clears the life event with null and keeps it when the key is absent', async () => { + const app = createApp() + const token = await createUserToken(app) + await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo', lifeEventId: null } }) + expect(prismaMock.mediaItem.updateMany.mock.calls.at(-1)?.[0].data).toEqual({ caption: 'Photo', lifeEventId: null }) + await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo' } }) + expect(prismaMock.mediaItem.updateMany.mock.calls.at(-1)?.[0].data).toEqual({ caption: 'Photo' }) + await app.close() + }) + + it('rejects an empty title and a photo title above 280 characters', async () => { + const app = createApp() + const token = await createUserToken(app) + const empty = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: ' ' } }) + expect(empty.statusCode).toBe(400) + const long = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'a'.repeat(281) } }) + expect(long.statusCode).toBe(400) + expect(long.json().error).toBe('caption_too_long') + expect(prismaMock.mediaItem.updateMany).not.toHaveBeenCalled() + await app.close() + }) + + it('refuses a life event from another tree or another person', async () => { + prismaMock.lifeEvent.findFirst.mockResolvedValueOnce(null) + const app = createApp() + const token = await createUserToken(app) + const otherTree = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo', lifeEventId: 'event-other-tree' } }) + expect(otherTree.statusCode).toBe(400) + expect(otherTree.json().error).toBe('invalid_life_event_reference') + + prismaMock.lifeEvent.findFirst.mockResolvedValueOnce({ personId: 'person-2', unionId: null, status: 'approved' }) + const otherPerson = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo', lifeEventId: 'event-2' } }) + expect(otherPerson.statusCode).toBe(400) + expect(prismaMock.mediaItem.updateMany).not.toHaveBeenCalled() + await app.close() + }) + + it('accepts a life event of a union the person belongs to', async () => { + prismaMock.lifeEvent.findFirst.mockResolvedValueOnce({ personId: null, unionId: 'union-1', status: 'approved' }) + prismaMock.union.findFirst.mockResolvedValueOnce({ partner1PersonId: 'person-1', partner2PersonId: 'person-9' }) + const app = createApp() + const token = await createUserToken(app) + const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo', lifeEventId: 'event-union' } }) + expect(response.statusCode).toBe(200) + await app.close() + }) + + it('returns 404 for a media that does not belong to the person', async () => { + prismaMock.mediaItem.findFirst.mockResolvedValueOnce(null) + const app = createApp() + const token = await createUserToken(app) + const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo' } }) + expect(response.statusCode).toBe(404) + await app.close() + }) + + it('rewrites the citation file with the same text as caption', async () => { + prismaMock.mediaItem.findFirst.mockResolvedValueOnce({ id: 'media-1', type: 'citation', filePath: 'tree-1/person-1/media-1.txt' }) + const { writeFile } = await import('node:fs/promises') + const app = createApp() + const token = await createUserToken(app) + const text = 'Il aimait la mer, « toujours »' + const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: text } }) + expect(response.statusCode).toBe(200) + const writes = (writeFile as unknown as ReturnType).mock.calls + expect(writes.length).toBe(1) + expect(String(writes[0][0])).toMatch(/media-1\.txt$/) + expect(Buffer.from(writes[0][1] as Buffer).toString('utf8')).toBe(text) + expect(prismaMock.mediaItem.updateMany.mock.calls.at(-1)?.[0].data).toEqual({ + caption: text, + sizeBytes: Buffer.byteLength(text, 'utf8'), + }) + await app.close() + }) + + it('accepts a citation longer than 280 characters', async () => { + prismaMock.mediaItem.findFirst.mockResolvedValueOnce({ id: 'media-1', type: 'citation', filePath: 'tree-1/person-1/media-1.txt' }) + const app = createApp() + const token = await createUserToken(app) + const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'b'.repeat(500) } }) + expect(response.statusCode).toBe(200) + await app.close() + }) +}) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 472b546..e6ba106 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -26,6 +26,7 @@ import { ExpandableSearch } from './components/search' import { t, dateLocale } from './i18n/index.js' import { createParentChildLink, + updateParentChildLink, deleteParentChildLink, deletePerson, deleteUnion, @@ -258,9 +259,9 @@ function buildContributionChangesFromDraft(draft = {}) { const relatedId = String(rel.personId) if (rel.type === 'child') { linkedParentIds.add(relatedId) - changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: relatedId, childPersonId: ref, parentageType: 'biologique' } }) + changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: relatedId, childPersonId: ref, parentageType: rel.adopted ? 'adoption' : 'biologique' } }) } else if (rel.type === 'parent') { - changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: ref, childPersonId: relatedId, parentageType: 'biologique' } }) + changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: ref, childPersonId: relatedId, parentageType: rel.adopted ? 'adoption' : 'biologique' } }) } else if (rel.type === 'spouse') { changes.push({ entityType: 'union', action: 'create', after: { partner1PersonId: relatedId, partner2PersonId: ref, unionType: 'mariage' } }) } @@ -277,6 +278,16 @@ function buildContributionChangesFromDraft(draft = {}) { } } + // Lien avec ses parents basculé (naissance / adoption) sur un lien existant + const parentageChanges = draft.parentageChanges && typeof draft.parentageChanges === 'object' ? draft.parentageChanges : {} + for (const [childId, parentageType] of Object.entries(parentageChanges)) { + for (const filiation of filiations) { + if (String(filiation.childId) !== String(childId) || String(filiation.id).startsWith('local-')) continue + if ((filiation.parentageType || 'biologique') === parentageType) continue + changes.push({ entityType: 'parent_child_link', action: 'update', entityId: String(filiation.id), after: { parentageType } }) + } + } + const modifiedPersons = draft.modifiedPersons && typeof draft.modifiedPersons === 'object' ? draft.modifiedPersons : {} @@ -292,6 +303,7 @@ function buildContributionChangesFromDraft(draft = {}) { 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 + if (Object.prototype.hasOwnProperty.call(source, 'deathPlace')) after.deathPlace = (source.deathPlace || '').trim() || null if (Object.prototype.hasOwnProperty.call(source, 'profession')) after.profession = (source.profession || '').trim() || null if (Object.prototype.hasOwnProperty.call(source, 'region')) after.region = (source.region || '').trim() || null if (Object.prototype.hasOwnProperty.call(source, 'nationality')) after.nationality = (source.nationality || '').trim() || null @@ -1041,7 +1053,7 @@ function App() { childId: rel.personId, parentId: personId, unionId: null, - parentageType: 'biologique', + parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: filiations.length + 1, }) } else if (rel.type === 'child') { @@ -1050,7 +1062,7 @@ function App() { childId: personId, parentId: rel.personId, unionId: null, - parentageType: 'biologique', + parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: filiations.length + 1, }) } @@ -1090,7 +1102,7 @@ function App() { birthDate: formData.birthDate || null, deathDate: formData.deathDate || null, isDeceased: Boolean(formData.isDeceased), - relations: (formData.relations || []).map((rel) => ({ type: rel.type, personId: rel.personId })), + relations: (formData.relations || []).map((rel) => ({ type: rel.type, personId: rel.personId, adopted: Boolean(rel.adopted) })), siblingParentLinks, }, ] @@ -1128,13 +1140,13 @@ function App() { linkedParentIds.add(String(rel.personId)) await createParentChildLink( tree.treeContext.treeId, - { parentPersonId: rel.personId, childPersonId: newPerson.id, parentageType: 'biologique', displayOrder: 1 }, + { parentPersonId: rel.personId, childPersonId: newPerson.id, parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: 1 }, auth.userAuth.token ) } else if (rel.type === 'parent') { await createParentChildLink( tree.treeContext.treeId, - { parentPersonId: newPerson.id, childPersonId: rel.personId, parentageType: 'biologique', displayOrder: 1 }, + { parentPersonId: newPerson.id, childPersonId: rel.personId, parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: 1 }, auth.userAuth.token ) } else if (rel.type === 'spouse') { @@ -1247,6 +1259,7 @@ function App() { 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 + if (Object.prototype.hasOwnProperty.call(rawChanges, 'deathPlace')) payload.deathPlace = (rawChanges.deathPlace || '').trim() || null if (Object.prototype.hasOwnProperty.call(rawChanges, 'profession')) payload.profession = (rawChanges.profession || '').trim() || null if (Object.prototype.hasOwnProperty.call(rawChanges, 'region')) payload.region = (rawChanges.region || '').trim() || null if (Object.prototype.hasOwnProperty.call(rawChanges, 'nationality')) payload.nationality = (rawChanges.nationality || '').trim() || null @@ -1530,6 +1543,7 @@ function App() { localPerson.deathYear = iso ? Number(iso.slice(0, 4)) : null } if (Object.prototype.hasOwnProperty.call(changes, 'birthPlace')) localPerson.birthPlace = changes.birthPlace || null + if (Object.prototype.hasOwnProperty.call(changes, 'deathPlace')) localPerson.deathPlace = changes.deathPlace || null if (Object.prototype.hasOwnProperty.call(changes, 'profession')) localPerson.profession = changes.profession || null if (Object.prototype.hasOwnProperty.call(changes, 'region')) localPerson.region = changes.region || null if (Object.prototype.hasOwnProperty.call(changes, 'nationality')) localPerson.nationality = changes.nationality || null @@ -1559,6 +1573,7 @@ function App() { 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 + if (Object.prototype.hasOwnProperty.call(changes, 'deathPlace')) payload.deathPlace = (changes.deathPlace || '').trim() || null if (Object.prototype.hasOwnProperty.call(changes, 'profession')) payload.profession = (changes.profession || '').trim() || null if (Object.prototype.hasOwnProperty.call(changes, 'region')) payload.region = (changes.region || '').trim() || null if (Object.prototype.hasOwnProperty.call(changes, 'nationality')) payload.nationality = (changes.nationality || '').trim() || null @@ -1833,6 +1848,48 @@ function App() { } }, [auth.userAuth.token, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection, isDemoMode, makeLocalId]) // eslint-disable-line react-hooks/exhaustive-deps + // Choix en attente d'un contributeur (brouillon), affiché sur la fiche avant l'envoi + const [pendingParentage, setPendingParentage] = useState({}) + useEffect(() => { if (!editMode.isActive) setPendingParentage({}) }, [editMode.isActive]) // eslint-disable-line react-hooks/exhaustive-deps + + // Lien avec ses parents : naissance ou adoption, pour tous les liens parent de la personne + const handleSetParentage = useCallback(async (childPersonId, parentageType) => { + const childLinks = filiations.filter((candidate) => String(candidate.childId) === String(childPersonId)) + if (childLinks.length === 0) return + + if (isDemoMode) { + for (const link of childLinks) link.parentageType = parentageType + tree.bumpGraphRevision() + setRelationFeedback({ loading: false, success: t('Lien modifié (démo locale)'), error: '' }) + return + } + + if (!tree.canEditCurrentTree) { + const draft = editMode.restoreDraft() + const parentageChanges = { ...(draft.parentageChanges || {}), [String(childPersonId)]: parentageType } + editMode.updateDraft({ parentageChanges }) + setPendingParentage((prev) => ({ ...prev, [String(childPersonId)]: parentageType })) + setRelationFeedback({ loading: false, success: t('Modification ajoutée à la contribution'), error: '' }) + return + } + + const accessToken = tree.treeContext.accessToken || auth.userAuth.token + if (!accessToken || !tree.treeContext.treeId) return + + setRelationFeedback({ loading: true, success: '', error: '' }) + try { + for (const link of childLinks) { + if (String(link.id).startsWith('local-') || (link.parentageType || 'biologique') === parentageType) continue + await updateParentChildLink(tree.treeContext.treeId, link.id, { parentageType }, accessToken) + } + await refreshTreeAndKeepSelection(childPersonId, { relayout: true }) + setRelationFeedback({ loading: false, success: t('Lien modifié'), error: '' }) + } catch (err) { + setRelationFeedback({ loading: false, success: '', error: getAccountErrorMessage(err) }) + console.error('Failed to update parentage:', err) + } + }, [auth.userAuth.token, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection, isDemoMode]) // eslint-disable-line react-hooks/exhaustive-deps + // Suppression d'une relation (mode édition) const handleRemoveRelation = useCallback(async (sourcePersonId, targetPersonId, relationType) => { if (isDemoMode) { @@ -3065,6 +3122,8 @@ function App() { onDelete={handlePersonDelete} onAddRelation={handleAddRelation} onRemoveRelation={handleRemoveRelation} + onSetParentage={handleSetParentage} + pendingParentage={pendingParentage[String(tree.selectedPerson.id)] ?? null} relationActionLoading={relationFeedback.loading} relationActionSuccess={relationFeedback.success} relationActionError={relationFeedback.error} @@ -3074,11 +3133,23 @@ function App() { onMomentSaved={media.refreshMomentMedia} /> )} + {/* Crayon de modification masqué en démo : rien n'y est enregistré */} {tree.selectedMedia && ( tree.setSelectedMedia(null)} + canEdit={Boolean(tree.canEditCurrentTree && editMode.isActive && !isDemoMode)} + onLoadLifeEvents={() => media.loadPersonLifeEvents(tree.selectedMedia.person.id)} + onSave={async (payload) => { + const { person, media: current } = tree.selectedMedia + await media.handleUpdatePersonMedia(person.id, current.id, payload) + // Le rafraîchissement de l'arbre referme la visionneuse : on la rouvre sur le souvenir modifié + tree.setSelectedMedia({ + person, + media: { ...current, label: payload.caption, lifeEventId: payload.lifeEventId }, + }) + }} /> )} ({ ...rel, adopted: adopted && (rel.type === 'child' || rel.type === 'parent') })), }) } @@ -341,6 +344,13 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on )} + {(selectedRelationType === 'child' || selectedRelationType === 'parent') && ( + + )} + {relations.length > 0 && (
{relations.map((rel) => ( diff --git a/frontend/src/components/ContributionPanel.jsx b/frontend/src/components/ContributionPanel.jsx index d311dec..409762b 100644 --- a/frontend/src/components/ContributionPanel.jsx +++ b/frontend/src/components/ContributionPanel.jsx @@ -76,6 +76,7 @@ const PERSON_FIELD_LABELS = { nickname: t('Surnom'), birthDate: t('Date de naissance'), deathDate: t('Date de décès'), + deathPlace: t('Lieu de décès'), birthPlace: t('Lieu de naissance'), profession: t('Métier'), nationality: t('Nationalité'), diff --git a/frontend/src/components/MediaViewer.css b/frontend/src/components/MediaViewer.css index 4b29722..785ef68 100644 --- a/frontend/src/components/MediaViewer.css +++ b/frontend/src/components/MediaViewer.css @@ -450,3 +450,52 @@ .mv-title { font-size: 22px; } .media-citation-text { font-size: 22px; } } + +/* Modifier un souvenir : crayon discret, visible au survol sur ordinateur et au focus clavier, + toujours visible sur écran tactile (pointeur grossier) */ +.mv-title-row { display: flex; align-items: flex-start; gap: 6px; min-width: 0; } +.mv-title-row .mv-title { min-width: 0; } +.mv-edit-btn { + flex: none; + width: 26px; + height: 26px; + display: inline-grid; + place-items: center; + margin-top: 2px; + padding: 0; + border: 1px solid var(--pz-a100); + border-radius: 999px; + background: transparent; + color: var(--pz-a700); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease, background-color 0.15s ease; +} +.mv-caption:hover .mv-edit-btn, +.mv-caption:focus-within .mv-edit-btn, +.mv-edit-btn:focus-visible { opacity: 1; } +.mv-edit-btn:hover { background: var(--pz-a100); } +.mv-edit-btn:focus-visible { outline: 2px solid var(--pz-a700); outline-offset: 2px; } +@media (hover: none) { + .mv-edit-btn { opacity: 1; } +} + +.mv-edit-form { display: grid; gap: 12px; } +.mv-edit-field { display: grid; gap: 4px; } +.mv-edit-label { font: 400 12.5px var(--pz-sans); color: var(--pz-muted); } +.mv-edit-title, +.mv-edit-quote, +.mv-edit-select { + width: 100%; + box-sizing: border-box; + padding: 8px 12px; + border: 1px solid var(--pz-a100); + border-radius: 10px; + background: #fff; + color: var(--pz-ink); + font: 400 16px/1.3 var(--pz-sans); +} +.mv-edit-title { font-family: var(--pz-serif); font-size: 22px; } +.mv-edit-quote { font-family: var(--pz-serif); font-size: 18px; resize: vertical; } +.mv-edit-error { margin: 0; font: 400 13px var(--pz-sans); color: var(--pz-a700); } +.mv-edit-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; } diff --git a/frontend/src/components/MediaViewer.jsx b/frontend/src/components/MediaViewer.jsx index 1c6ab9f..28d064c 100644 --- a/frontend/src/components/MediaViewer.jsx +++ b/frontend/src/components/MediaViewer.jsx @@ -1,5 +1,5 @@ import { useEffect, useId, useMemo, useState } from 'react' -import { X } from 'lucide-react' +import { Pencil, X } from 'lucide-react' import { buildOpenStreetMapEmbedUrl, buildOpenStreetMapUrl, @@ -10,11 +10,26 @@ import { import './MediaViewer.css' import { t } from '../i18n/index.js' import { firstNameLabel } from '../utils/personName.js' +import { eventTitle } from '../utils/lifeTimeline.js' +import { getAccountErrorMessage } from '../utils/errorMessages.js' const HTTP_URL_RE = /^https?:\/\/\S+$/i +const MAX_TITLE_LENGTH = 280 +const MAX_CITATION_LENGTH = 2000 -const MediaViewer = ({ media, person, onClose }) => { +// Moment de vie qui porte ce souvenir (mediaIds des événements de la route /life) +const findLinkedEventId = (events, mediaId) => { + const id = String(mediaId) + return events.find((event) => (event.mediaIds || []).map(String).includes(id))?.id ?? null +} + +const MediaViewer = ({ media, person, onClose, canEdit = false, onLoadLifeEvents, onSave }) => { const [mapState, setMapState] = useState({ loading: false, error: '', data: null }) + const [editing, setEditing] = useState(false) + const [lifeEvents, setLifeEvents] = useState([]) + const [draft, setDraft] = useState({ title: '', citationText: '', lifeEventId: null }) + const [saving, setSaving] = useState(false) + const [saveError, setSaveError] = useState('') const svgUid = useId().replace(/:/g, '') const mapGridId = `media-map-grid-${svgUid}` const mapGlowId = `media-map-glow-${svgUid}` @@ -23,6 +38,109 @@ const MediaViewer = ({ media, person, onClose }) => { if (event.target === event.currentTarget) onClose() } + const isCitation = media?.type === 'citation' + + const startEditing = async () => { + setSaveError('') + const linkedInitially = 'lifeEventId' in (media || {}) ? media.lifeEventId ?? null : null + setDraft({ title: media?.label || '', citationText: media?.label || '', lifeEventId: linkedInitially }) + setEditing(true) + if (!onLoadLifeEvents) return + try { + const data = await onLoadLifeEvents() + const events = Array.isArray(data?.events) ? data.events : [] + setLifeEvents(events) + if (!('lifeEventId' in (media || {}))) { + setDraft((current) => ({ ...current, lifeEventId: findLinkedEventId(events, media.id) })) + } + } catch { + setSaveError(t('Impossible de charger les moments de vie.')) + } + } + + const cancelEditing = () => { + setEditing(false) + setSaveError('') + } + + const handleSubmit = async (event) => { + event.preventDefault() + const caption = (isCitation ? draft.citationText : draft.title).trim() + if (!caption) { + setSaveError(isCitation ? t('Saisissez le texte de la citation.') : t('Donnez un titre au souvenir.')) + return + } + if (caption.length > (isCitation ? MAX_CITATION_LENGTH : MAX_TITLE_LENGTH)) { + setSaveError(isCitation ? t('Le texte de la citation est trop long.') : t('Le titre est trop long.')) + return + } + setSaving(true) + setSaveError('') + try { + await onSave({ caption, lifeEventId: draft.lifeEventId ?? null }) + setEditing(false) + } catch (error) { + setSaveError(getAccountErrorMessage(error)) + } finally { + setSaving(false) + } + } + + const renderEditForm = () => ( +
+ {isCitation ? ( +