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 6fb5c91..0023e65 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3047,11 +3047,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 ? ( +