From b09f9156622d830ed0c5b59493ef98668e6d5e5a Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Sun, 18 Oct 2026 22:36:21 +0200 Subject: [PATCH] feat(souvenir): modifier le titre, le moment et la citation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un administrateur, en mode modification, peut modifier un souvenir depuis la visionneuse : crayon à droite du titre (visible au survol sur ordinateur, toujours visible sur écran tactile), formulaire Titre et Moment de sa vie, texte de la citation réécrit dans caption et dans le fichier citation.txt. Route PATCH /trees/:id/persons/:personId/media/:mediaId, réservée aux administrateurs. Pas de migration : la date vient du moment de vie rattaché. La légende séparée n'existe pas en base et n'est pas ajoutée. Co-Authored-By: Claude Haiku 5.5 --- backend/src/routes/media.ts | 114 ++++++++++ backend/test/media-update.test.ts | 279 ++++++++++++++++++++++++ frontend/src/App.jsx | 12 + frontend/src/api/treeApi.js | 16 ++ frontend/src/components/MediaViewer.css | 49 +++++ frontend/src/components/MediaViewer.jsx | 142 +++++++++++- frontend/src/hooks/useMediaManager.js | 16 +- frontend/src/i18n/en/index.js | 2 + frontend/src/i18n/en/souvenir.js | 11 + 9 files changed, 635 insertions(+), 6 deletions(-) create mode 100644 backend/test/media-update.test.ts create mode 100644 frontend/src/i18n/en/souvenir.js diff --git a/backend/src/routes/media.ts b/backend/src/routes/media.ts index 0d917b4..927ecae 100644 --- a/backend/src/routes/media.ts +++ b/backend/src/routes/media.ts @@ -94,6 +94,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' } @@ -753,6 +760,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/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 b4a9e04..a9e88ce 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3046,11 +3046,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 }, + }) + }} /> )} { +// 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 ? ( +