diff --git a/backend/src/routes/immich.ts b/backend/src/routes/immich.ts index e9f6b33..c0dc964 100644 --- a/backend/src/routes/immich.ts +++ b/backend/src/routes/immich.ts @@ -30,6 +30,9 @@ const treeParamSchema = z.object({ id: z.string().min(1) }) const assignSchema = z.object({ personId: z.string().min(1), immichPersonId: z.string().refine(isImmichPersonId, 'invalid_immich_person_id'), + // Remplacer un portrait déjà en place demande un accord explicite, quelle que soit son + // origine (posé à la main ou venu d'une association Immich précédente). + replaceAvatar: z.boolean().optional(), }) const unassignSchema = z.object({ @@ -385,6 +388,13 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { return reply.send({ ok: true, avatarUpdated: false, immichPersonId }) } + // Un portrait déjà en place ne se remplace jamais en silence : sans accord explicite, + // l'association s'arrête avant d'écrire quoi que ce soit. Le front transforme ce refus + // en demande de confirmation puis rejoue la requête avec replaceAvatar. + if (person.avatarPath && !payload.data.replaceAvatar) { + return reply.code(409).send({ error: 'avatar_exists' }) + } + // La vignette passe par le même traitement qu'un portrait envoyé : type lu dans les octets, // métadonnées retirées, quota de l'arbre respecté. let prepared: Awaited> diff --git a/backend/test/immich.test.ts b/backend/test/immich.test.ts index 1a75781..3ab3062 100644 --- a/backend/test/immich.test.ts +++ b/backend/test/immich.test.ts @@ -357,7 +357,8 @@ describe('Immich integration: assign', () => { const app = createApp() const token = await createUserToken(app) - // Portrait manuel en place : il doit être supprimé une fois la base à jour. + // Portrait manuel en place : l'administrateur a confirmé son remplacement, il doit + // être supprimé une fois la base à jour. const oldAvatarRelative = 'tree-1/person-1/avatar-old.jpg' await mkdir(path.dirname(await absStoragePath(oldAvatarRelative)), { recursive: true }) await writeFile(await absStoragePath(oldAvatarRelative), 'old avatar') @@ -369,7 +370,7 @@ describe('Immich integration: assign', () => { method: 'POST', url: '/trees/tree-1/immich/assign', headers: { authorization: `Bearer ${token}` }, - payload: { personId: 'person-1', immichPersonId }, + payload: { personId: 'person-1', immichPersonId, replaceAvatar: true }, }) expect(response.statusCode).toBe(200) @@ -399,10 +400,62 @@ describe('Immich integration: assign', () => { await app.close() }) + it('asks before replacing an existing portrait, whatever its origin', async () => { + const app = createApp() + const token = await createUserToken(app) + mockImmichPerson() + + // Portrait manuel en place : l'association ne le remplace pas sans accord explicite. + const manualAvatarRelative = 'tree-1/person-confirm/avatar-manuel.jpg' + await mkdir(path.dirname(await absStoragePath(manualAvatarRelative)), { recursive: true }) + await writeFile(await absStoragePath(manualAvatarRelative), 'portrait manuel') + + prismaMock.person.findFirst.mockResolvedValue({ id: 'person-confirm', avatarPath: manualAvatarRelative }) + + const manual = await app.inject({ + method: 'POST', + url: '/trees/tree-1/immich/assign', + headers: { authorization: `Bearer ${token}` }, + payload: { personId: 'person-confirm', immichPersonId }, + }) + + expect(manual.statusCode).toBe(409) + expect(manual.json()).toEqual({ error: 'avatar_exists' }) + + // Même garde pour un portrait venu d'une association Immich précédente. + prismaMock.person.findFirst.mockResolvedValue({ + id: 'person-confirm', + avatarPath: `tree-1/person-confirm/immich-${randomUUID()}.jpg`, + immichPersonId: otherImmichId, + }) + + const fromImmich = await app.inject({ + method: 'POST', + url: '/trees/tree-1/immich/assign', + headers: { authorization: `Bearer ${token}` }, + payload: { personId: 'person-confirm', immichPersonId }, + }) + + expect(fromImmich.statusCode).toBe(409) + expect(fromImmich.json()).toEqual({ error: 'avatar_exists' }) + + // Base, journal d'audit et fichiers : rien n'a bougé. + expect(prismaMock.person.update).not.toHaveBeenCalled() + expect(prismaMock.auditLog.create).not.toHaveBeenCalled() + expect(prismaMock.$transaction).not.toHaveBeenCalled() + await expect(fsExists(await absStoragePath(manualAvatarRelative))).resolves.toBe(true) + + const files = await fsListDir(path.join(storageRoot, 'tree-1', 'person-confirm')) + expect(files.filter((name) => name.startsWith('immich-'))).toEqual([]) + await app.close() + }) + it('assigns without touching the avatar when Immich has no thumbnail', async () => { const app = createApp() const token = await createUserToken(app) + // Portrait manuel conservé : sans vignette Immich, l'association ne le remplace pas, + // aucune confirmation n'est donc nécessaire. prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: 'tree-1/person-1/avatar-keep.jpg' }) mockImmichPerson({ thumbnailPath: '' }) @@ -601,7 +654,7 @@ describe('Immich integration: assign', () => { method: 'POST', url: '/trees/tree-1/immich/assign', headers: { authorization: `Bearer ${token}` }, - payload: { personId: 'person-tx', immichPersonId }, + payload: { personId: 'person-tx', immichPersonId, replaceAvatar: true }, }) expect(response.statusCode).toBe(500) diff --git a/frontend/src/api/immichApi.js b/frontend/src/api/immichApi.js index bea85a4..7253554 100644 --- a/frontend/src/api/immichApi.js +++ b/frontend/src/api/immichApi.js @@ -70,10 +70,12 @@ export async function fetchImmichThumbnail(treeId, immichPersonId, token) { return URL.createObjectURL(await response.blob()) } -export async function assignImmichPerson(treeId, personId, immichPersonId, token) { +export async function assignImmichPerson(treeId, personId, immichPersonId, token, { replaceAvatar = false } = {}) { return apiRequest(`/trees/${encodeURIComponent(treeId)}/immich/assign`, withAuth({ method: 'POST', - body: JSON.stringify({ personId, immichPersonId }), + // replaceAvatar : accord explicite pour remplacer un portrait déjà en place — le serveur + // refuse sinon (409 avatar_exists), quelle que soit l'origine du portrait actuel. + body: JSON.stringify({ personId, immichPersonId, ...(replaceAvatar ? { replaceAvatar: true } : {}) }), }, token)) } diff --git a/frontend/src/components/ImmichFacePanel.css b/frontend/src/components/ImmichFacePanel.css index b5fa855..9706ac1 100644 --- a/frontend/src/components/ImmichFacePanel.css +++ b/frontend/src/components/ImmichFacePanel.css @@ -77,5 +77,16 @@ .immich-empty { font: 500 13px var(--pz-sans); color: var(--pz-faint); text-align: center; padding: 12px 0; } .immich-error { margin: 0; } + +/* Demande de confirmation avant de remplacer un portrait déjà en place. */ +.immich-confirm { + margin: 0; + padding: 12px 14px; + border: 1px solid var(--pz-danger); + border-radius: var(--pz-r-m); + background: var(--pz-danger-bg); + color: var(--pz-danger); + font: 500 13px/1.5 var(--pz-sans); +} .immich-spin { animation: immich-rotate 900ms linear infinite; } @keyframes immich-rotate { to { transform: rotate(360deg); } } diff --git a/frontend/src/components/ImmichFacePanel.jsx b/frontend/src/components/ImmichFacePanel.jsx index 93a1813..48b413f 100644 --- a/frontend/src/components/ImmichFacePanel.jsx +++ b/frontend/src/components/ImmichFacePanel.jsx @@ -25,6 +25,7 @@ const ImmichFacePanel = ({ person, treeId, token, onClose, onImmichChange }) => const [selected, setSelected] = useState(null) const [thumbnailUrl, setThumbnailUrl] = useState(null) const [actionLoading, setActionLoading] = useState(false) + const [confirmReplace, setConfirmReplace] = useState(false) const [error, setError] = useState('') const thumbnailRef = useRef(null) @@ -82,6 +83,7 @@ const ImmichFacePanel = ({ person, treeId, token, onClose, onImmichChange }) => const handleSelect = useCallback((immichPerson) => { setSelected(immichPerson) setError('') + setConfirmReplace(false) if (immichPerson?.hasThumbnail && immichPerson?.id) { loadThumbnail(immichPerson.id) } else { @@ -89,16 +91,22 @@ const ImmichFacePanel = ({ person, treeId, token, onClose, onImmichChange }) => } }, [loadThumbnail, releaseThumbnail]) - const handleAssign = useCallback(async () => { + const handleAssign = useCallback(async (replaceAvatar = false) => { if (!selected || actionLoading) return setActionLoading(true) setError('') try { - await assignImmichPerson(treeId, person.id, selected.id, token) + await assignImmichPerson(treeId, person.id, selected.id, token, { replaceAvatar }) onImmichChange?.(person.id) onClose?.() } catch (err) { - setError(getImmichErrorMessage(err, t)) + // Un portrait est déjà en place : le serveur refuse le remplacement en silence + // (409 avatar_exists) ; il revient à l'administrateur de le confirmer. + if (!replaceAvatar && err?.payload?.error === 'avatar_exists') { + setConfirmReplace(true) + } else { + setError(getImmichErrorMessage(err, t)) + } } finally { setActionLoading(false) } @@ -210,12 +218,32 @@ const ImmichFacePanel = ({ person, treeId, token, onClose, onImmichChange }) => {error &&
{error}
} + {confirmReplace && ( +

+ {t('Un portrait est déjà en place. Associer cette personne Immich remplacera le portrait actuel et supprimera son fichier.')} +

+ )} +
- - + {confirmReplace ? ( + <> + + + + ) : ( + <> + + + + )}
)} diff --git a/frontend/src/i18n/en/fiche.js b/frontend/src/i18n/en/fiche.js index fc08ad5..8236c81 100644 --- a/frontend/src/i18n/en/fiche.js +++ b/frontend/src/i18n/en/fiche.js @@ -285,4 +285,8 @@ export default { 'Sans nom': 'Unnamed', 'Aucune sélection': 'No selection', 'Associer et prendre le portrait': 'Link and use as portrait', + 'Un portrait est déjà en place. Associer cette personne Immich remplacera le portrait actuel et supprimera son fichier.': + 'A portrait is already in place. Linking this Immich person will replace the current portrait and delete its file.', + 'Garder le portrait actuel': 'Keep the current portrait', + 'Remplacer le portrait et associer': 'Replace the portrait and link', }