diff --git a/backend/src/routes/immich.ts b/backend/src/routes/immich.ts index 8e97219..9e11849 100644 --- a/backend/src/routes/immich.ts +++ b/backend/src/routes/immich.ts @@ -26,6 +26,7 @@ import { exceedsStorageQuota, getStorageRoot, prepareUploadedFile, requireAdminM // administrateurs, et ne parlent jamais à l'instance d'un autre arbre. const treeParamSchema = z.object({ id: z.string().min(1) }) +const treePersonParamsSchema = z.object({ id: z.string().min(1), personId: z.string().min(1) }) const assignSchema = z.object({ personId: z.string().min(1), @@ -324,6 +325,33 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { return reply.send(thumbnail) }) + // Personne Immich déjà associée à une fiche, pour la marquer dans le sélecteur. Réservé aux + // administrateurs : l'identifiant n'entre jamais dans le graphe public. + app.get('/trees/:id/immich/links/:personId', async (request, reply) => { + if (!request.actor || request.actor.kind !== 'user') { + return reply.code(401).send({ error: 'authentication_required' }) + } + + const params = treePersonParamsSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_params' }) + } + + if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) { + return reply.code(403).send({ error: 'forbidden' }) + } + + const person = await app.prisma.person.findFirst({ + where: { id: params.data.personId, treeId: params.data.id, deletedAt: null }, + select: { immichPersonId: true }, + }) + if (!person) { + return reply.code(404).send({ error: 'person_not_found' }) + } + + return reply.send({ immichPersonId: person.immichPersonId }) + }) + app.post('/trees/:id/immich/assign', async (request, reply) => { if (!request.actor || request.actor.kind !== 'user') { return reply.code(401).send({ error: 'authentication_required' }) diff --git a/backend/test/immich.test.ts b/backend/test/immich.test.ts index b4f5011..e999935 100644 --- a/backend/test/immich.test.ts +++ b/backend/test/immich.test.ts @@ -997,3 +997,37 @@ describe('Immich integration: durcissement des vignettes et des réponses', () = await app.close() }) }) + +describe('Immich integration: lien d’une fiche', () => { + it('tells an administrator which Immich person a file is linked to, and refuses others', async () => { + const app = createApp() + const token = await createUserToken(app) + prismaMock.person.findFirst.mockResolvedValue({ immichPersonId }) + + const linked = await app.inject({ + method: 'GET', + url: '/trees/tree-1/immich/links/person-1', + headers: { authorization: `Bearer ${token}` }, + }) + expect(linked.statusCode).toBe(200) + expect(linked.json()).toEqual({ immichPersonId }) + + const memberToken = await createUserToken(app, 'user-member') + prismaMock.treeMembership.findUnique.mockResolvedValueOnce({ treeId: 'tree-1', userId: 'user-member', role: 'member', tree: { deletedAt: null } }) + const member = await app.inject({ + method: 'GET', + url: '/trees/tree-1/immich/links/person-1', + headers: { authorization: `Bearer ${memberToken}` }, + }) + expect(member.statusCode).toBe(403) + + prismaMock.person.findFirst.mockResolvedValue(null) + const missing = await app.inject({ + method: 'GET', + url: '/trees/tree-1/immich/links/person-unknown', + headers: { authorization: `Bearer ${token}` }, + }) + expect(missing.statusCode).toBe(404) + await app.close() + }) +})