Intégration Immich : associer un visage à une personne de l'arbre

- Colonne persons.immichPersonId (schéma + migration 20261012_001) et
  exposition dans le graphe pour l'afficher côté fiche.
- Client Immich (src/lib/immich.ts) sur le contrat OpenAPI v3.1.0 :
  ping, /api/people paginé, vignette par personne ; erreurs distinctes
  entre injoignable, clé refusée et introuvable, identifiants validés
  comme UUID v4.
- Routes /trees/:id/immich/* (status, people, thumbnail, assign,
  unassign), réservées aux administrateurs de l'arbre ; l'adresse et la
  clé d'Immich ne quittent jamais le backend.
- La vignette passe par le pipeline des portraits (type lu dans les
  octets, métadonnées retirées, quota de l'arbre) : le fichier est écrit
  avant la transaction (personne + journal immich_assign) et l'ancien
  portrait est supprimé après ; un échec du nettoyage est journalisé
  sans faire échouer la requête. Le désassignement retire le portrait
  seulement s'il vient d'Immich (préfixe immich-) et préserve un
  portrait envoyé à la main.
- Côté front : bouton « Depuis Immich » sur la fiche en édition, panneau
  de sélection avec aperçu et retrait de l'association, traductions
  anglaises, messages d'erreur distincts.
- Tests : 16 tests backend (succès, 400/401/403/404/413/502/503, quota,
  échec de transaction, préservation du portrait manuel, serveur
  désactivé) et le mapping immichPersonId côté front.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mistral Vibe committed 2026-10-12 11:53:12 +02:00
1 parent 4bdf7aa1f3
commit 378071bf13
21 files changed
+1629 -5

No files matched your search

+5
View File
@@ -6,3 +6,8 @@ JWT_EXPIRES_IN="7d"
JWT_TREE_ACCESS_EXPIRES_IN="24h"
CORS_ORIGIN="http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176"
MEDIA_STORAGE_PATH="./storage/media"
# Reconnaissance des visages via une instance Immich (https://immich.app). Désactivé par défaut.
# IMMICH_API_URL : adresse de l'API, sans slash final. IMMICH_API_KEY : clé créée dans Immich.
IMMICH_ENABLED=false
IMMICH_API_URL="http://immich.example.org:2283"
IMMICH_API_KEY=""
+6
View File
@@ -23,3 +23,9 @@ CORS_ORIGIN="https://arbre.example.org"
# Dossier des médias envoyés. Un chemin relatif part du dossier backend/.
MEDIA_STORAGE_PATH="/var/lib/parenthese/media"
# Reconnaissance des visages via une instance Immich (https://immich.app). Désactivé par défaut.
# IMMICH_API_URL : adresse de l'API, sans slash final. IMMICH_API_KEY : clé créée dans Immich.
IMMICH_ENABLED=false
IMMICH_API_URL="http://immich.example.org:2283"
IMMICH_API_KEY=""
@@ -0,0 +1,2 @@
-- Association d'une personne à une personne Immich (reconnaissance des visages).
ALTER TABLE "persons" ADD COLUMN "immichPersonId" TEXT;
+2
View File
@@ -208,6 +208,8 @@ model Person {
notes String?
createdBy String?
updatedBy String?
// Personne Immich associée (reconnaissance des visages) : identifiant côté Immich, ou null.
immichPersonId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
+2
View File
@@ -10,6 +10,7 @@ import { isUserTokenRevoked } from './lib/token-revocation.js'
import { authRoutes } from './routes/auth.js'
import { contributionRoutes } from './routes/contributions.js'
import { graphRoutes } from './routes/graph.js'
import { immichRoutes } from './routes/immich.js'
import { mediaRoutes } from './routes/media.js'
import { publicRoutes } from './routes/public.js'
import { annotationRoutes } from './routes/annotations.js'
@@ -147,6 +148,7 @@ export function createApp() {
app.register(annotationRoutes)
app.register(gedcomRoutes)
app.register(visitRoutes)
app.register(immichRoutes)
app.setErrorHandler((error: FastifyError | Error, request, reply) => {
if (error instanceof Prisma.PrismaClientInitializationError) {
+4
View File
@@ -50,6 +50,10 @@ const envSchema = z.object({
// Place disque par arbre, et part réservée aux envois de la famille en attente de relecture
MEDIA_QUOTA_PER_TREE_MB: z.coerce.number().int().positive().default(5120),
MEDIA_PENDING_QUOTA_PER_TREE_MB: z.coerce.number().int().positive().default(500),
// Reconnaissance des visages via Immich : désactivé par défaut, adresse et clé de l'instance.
IMMICH_ENABLED: z.preprocess((val) => String(val).toLowerCase() === 'true', z.boolean()).default(false),
IMMICH_API_URL: z.string().url().optional(),
IMMICH_API_KEY: z.string().min(1).optional(),
})
export const env = envSchema.parse(process.env)
+153
View File
@@ -0,0 +1,153 @@
import { env } from '../config/env.js'
// Client Immich : la reconnaissance des visages lit les personnes et leurs vignettes.
// Contrat vérifié sur la spécification OpenAPI officielle d'Immich v3.1.0 :
// GET /api/server/ping -> 200 {"res":"pong"}
// GET /api/people?page=N&size=M -> 200 { people: PersonResponseDto[], total, hasNextPage, hidden } (size <= 1000)
// GET /api/people/{id} -> 200 PersonResponseDto | 404 (id : UUID v4)
// GET /api/people/{id}/thumbnail -> 200 image binaire | 404
// Authentification : en-tête x-api-key ; clé refusée -> 401.
// Instance injoignable : réseau coupé, délai dépassé, réponse illisible.
export class ImmichUnavailableError extends Error {
constructor(message = 'Immich est injoignable') {
super(message)
this.name = 'ImmichUnavailableError'
}
}
// Clé d'API refusée par Immich (401/403).
export class ImmichAuthError extends Error {
constructor(message = 'Clé d’API Immich refusée') {
super(message)
this.name = 'ImmichAuthError'
}
}
// Personne ou vignette inexistante côté Immich (404).
export class ImmichNotFoundError extends Error {
constructor(message = 'Personne Immich introuvable') {
super(message)
this.name = 'ImmichNotFoundError'
}
}
const IMMICH_PEOPLE_PAGE_SIZE = 1000
const IMMICH_TIMEOUT_MS = 10_000
// Identifiants de personnes Immich : UUID v4 (spécification OpenAPI).
const IMMICH_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
export function isImmichPersonId(value: string): boolean {
return IMMICH_ID_RE.test(value)
}
export type ImmichPersonSummary = {
id: string
name: string
hasThumbnail: boolean
}
type ImmichPeoplePage = {
people?: unknown
hasNextPage?: unknown
}
export class ImmichClient {
private readonly apiUrl: string
private readonly apiKey: string
constructor(apiUrl: string, apiKey: string) {
this.apiUrl = apiUrl.replace(/\/$/, '')
this.apiKey = apiKey
}
private buildUrl(path: string): string {
return new URL(path, `${this.apiUrl}/`).toString()
}
private async fetch(path: string): Promise<Response> {
const url = this.buildUrl(path)
let response: Response
try {
response = await fetch(url, {
headers: { 'x-api-key': this.apiKey, Accept: 'application/json' },
signal: AbortSignal.timeout(IMMICH_TIMEOUT_MS),
})
} catch (error) {
// L'URL ne contient aucun secret ; la clé voyage dans l'en-tête, jamais dans les journaux.
console.warn(`Immich injoignable : ${url} (${(error as Error).message})`)
throw new ImmichUnavailableError()
}
if (response.ok) {
return response
}
// Journal avec le statut et l'URL (sans secret), puis remontée d'une erreur distincte par cause.
console.warn(`Immich a répondu ${response.status} sur ${url}`)
if (response.status === 401 || response.status === 403) {
throw new ImmichAuthError()
}
if (response.status === 404) {
throw new ImmichNotFoundError()
}
throw new ImmichUnavailableError(`Immich a répondu ${response.status}`)
}
async ping(): Promise<boolean> {
try {
const response = await this.fetch('/api/server/ping')
const payload = (await response.json()) as { res?: unknown }
return payload?.res === 'pong'
} catch {
return false
}
}
// Toutes les personnes (sans les masquées), en suivant la pagination d'Immich.
async listPeople(): Promise<ImmichPersonSummary[]> {
const people: ImmichPersonSummary[] = []
let page = 1
// Garde-fou : une instance qui répondrait toujours hasNextPage ne doit pas nous faire boucler.
for (let guard = 0; guard < 1000; guard += 1, page += 1) {
const response = await this.fetch(`/api/people?page=${page}&size=${IMMICH_PEOPLE_PAGE_SIZE}`)
const payload = (await response.json()) as ImmichPeoplePage
if (!Array.isArray(payload.people)) {
throw new ImmichUnavailableError('Réponse Immich inattendue sur /api/people')
}
for (const person of payload.people as Array<{ id?: unknown; name?: unknown; thumbnailPath?: unknown }>) {
if (typeof person?.id !== 'string' || typeof person?.name !== 'string') {
throw new ImmichUnavailableError('Réponse Immich inattendue sur /api/people')
}
people.push({ id: person.id, name: person.name, hasThumbnail: typeof person.thumbnailPath === 'string' && person.thumbnailPath.length > 0 })
}
if (payload.hasNextPage !== true || (payload.people as unknown[]).length === 0) {
break
}
}
return people
}
// Vignette d'une personne, en octets bruts (JPEG côté Immich). Absente -> ImmichNotFoundError.
async getPersonThumbnail(personId: string): Promise<Buffer> {
const response = await this.fetch(`/api/people/${encodeURIComponent(personId)}/thumbnail`)
return Buffer.from(await response.arrayBuffer())
}
}
export function isImmichConfigured(): boolean {
return env.IMMICH_ENABLED && Boolean(env.IMMICH_API_URL && env.IMMICH_API_KEY)
}
export function getImmichClient(): ImmichClient | null {
if (!isImmichConfigured()) {
return null
}
return new ImmichClient(env.IMMICH_API_URL!, env.IMMICH_API_KEY!)
}
+360
View File
@@ -0,0 +1,360 @@
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import path from 'node:path'
import { randomUUID } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
import { z } from 'zod'
import {
getImmichClient,
isImmichConfigured,
isImmichPersonId,
ImmichAuthError,
ImmichNotFoundError,
ImmichUnavailableError,
} from '../lib/immich.js'
import { InvalidMediaError, removeStoredFile, sniffMimeType } from '../lib/media-files.js'
import { exceedsStorageQuota, getStorageRoot, prepareUploadedFile, requireAdminMembership } from './media.js'
// Reconnaissance des visages : associer une personne de l'arbre à une personne Immich, dont la
// vignette devient son portrait. Toutes les routes sont rattachées à un arbre et réservées à
// ses administrateurs : la liste des visages d'Immich ne se montre pas au premier compte venu.
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'),
})
const unassignSchema = z.object({
personId: z.string().min(1),
})
const thumbnailParamSchema = z.object({
id: z.string().min(1),
immichPersonId: z.string().refine(isImmichPersonId, 'invalid_immich_person_id'),
})
const MAX_THUMBNAIL_BYTES = 5 * 1024 * 1024
function replyImmichError(request: FastifyRequest, reply: FastifyReply, error: unknown): FastifyReply {
if (error instanceof ImmichNotFoundError) {
return reply.code(404).send({ error: 'immich_person_not_found' })
}
if (error instanceof ImmichAuthError) {
return reply.code(502).send({ error: 'immich_auth_failed' })
}
if (error instanceof ImmichUnavailableError) {
return reply.code(503).send({ error: 'immich_unreachable' })
}
request.log.error({ err: error }, 'Erreur inattendue pendant un appel à Immich')
return reply.code(500).send({ error: 'immich_api_error' })
}
function requireConfiguredClient() {
return getImmichClient()
}
// Portraits venus d'Immich : même nommage que les envois manuels (« avatar-<uuid> »), avec le
// préfixe « immich- » qui dit au désassignement que le fichier nous appartient. Un portrait
// envoyé à la main n'est jamais touché par le désassignement.
function buildAvatarRelativePath(treeId: string, personId: string, extension: string): string {
return path.posix.join(treeId, personId, `immich-${randomUUID()}${extension}`)
}
export const immichRoutes: FastifyPluginAsync = async (app) => {
app.get('/trees/:id/immich/status', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
const params = treeParamSchema.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' })
}
if (!isImmichConfigured()) {
return reply.send({ available: false, reason: 'not_configured' })
}
const client = requireConfiguredClient()!
const available = await client.ping()
return reply.send({ available, reason: available ? null : 'unreachable' })
})
app.get('/trees/:id/immich/people', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
const params = treeParamSchema.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 client = requireConfiguredClient()
if (!client) {
return reply.code(503).send({ error: 'immich_not_configured' })
}
try {
const people = await client.listPeople()
return reply.send({ people })
} catch (error) {
return replyImmichError(request, reply, error)
}
})
// Vignette d'une personne Immich, montrée dans le sélecteur. Passée par l'API : l'adresse
// d'Immich et sa clé ne quittent jamais le serveur.
app.get('/trees/:id/immich/thumbnail/:immichPersonId', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
const params = thumbnailParamSchema.safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_immich_person_id' })
}
if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) {
return reply.code(403).send({ error: 'forbidden' })
}
const client = requireConfiguredClient()
if (!client) {
return reply.code(503).send({ error: 'immich_not_configured' })
}
let thumbnail: Buffer
try {
thumbnail = await client.getPersonThumbnail(params.data.immichPersonId)
} catch (error) {
return replyImmichError(request, reply, error)
}
if (!thumbnail.length || thumbnail.length > MAX_THUMBNAIL_BYTES) {
request.log.warn({ size: thumbnail.length }, 'Vignette Immich vide ou trop grande')
return reply.code(404).send({ error: 'thumbnail_not_found' })
}
// Le type se lit dans les octets, pas dans ce qu'Immich déclare : la vignette finit dans une balise <img>.
const mimeType = sniffMimeType(thumbnail, 'photo') ?? 'image/jpeg'
reply.header('Content-Type', mimeType)
reply.header('Cache-Control', 'private, max-age=3600')
return reply.send(thumbnail)
})
app.post('/trees/:id/immich/assign', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
const params = treeParamSchema.safeParse(request.params)
const payload = assignSchema.safeParse(request.body)
if (!params.success || !payload.success) {
return reply.code(400).send({ error: payload.success ? 'invalid_params' : 'invalid_immich_person_id' })
}
if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) {
return reply.code(403).send({ error: 'forbidden' })
}
const client = requireConfiguredClient()
if (!client) {
return reply.code(503).send({ error: 'immich_not_configured' })
}
const { personId, immichPersonId } = payload.data
const treeId = params.data.id
const person = await app.prisma.person.findFirst({
where: { id: personId, treeId, deletedAt: null },
select: { id: true, avatarPath: true },
})
if (!person) {
return reply.code(404).send({ error: 'person_not_found' })
}
// Vignette de la personne Immich : absente -> on associe sans toucher au portrait existant.
let thumbnail: Buffer | null = null
try {
thumbnail = await client.getPersonThumbnail(immichPersonId)
} catch (error) {
if (!(error instanceof ImmichNotFoundError)) {
return replyImmichError(request, reply, error)
}
// Vignette absente : l'association se fait sans portrait.
}
if (!thumbnail) {
await app.prisma.$transaction([
app.prisma.person.update({
where: { id: personId },
data: { immichPersonId, updatedBy: request.actor.userId },
}),
app.prisma.auditLog.create({
data: {
treeId,
actorType: 'user',
actorId: request.actor.userId,
action: 'immich_assign',
entityType: 'person',
entityId: personId,
payloadJson: { immichPersonId, avatarUpdated: false },
},
}),
])
return reply.send({ ok: true, avatarUpdated: false, immichPersonId })
}
// 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<ReturnType<typeof prepareUploadedFile>>
try {
prepared = await prepareUploadedFile(thumbnail, 'photo', `immich-${immichPersonId}.jpg`)
} catch (error) {
if (error instanceof InvalidMediaError) {
return reply.code(400).send({ error: 'invalid_avatar_image' })
}
throw error
}
if (await exceedsStorageQuota(app, treeId, prepared.buffer.length, false)) {
return reply.code(413).send({ error: 'storage_quota_exceeded' })
}
const relativePath = buildAvatarRelativePath(treeId, personId, prepared.extension)
const storageRoot = getStorageRoot()
const absolutePath = path.resolve(storageRoot, relativePath)
// Chemin hors du stockage : refusé, jamais écrit.
if (!absolutePath.startsWith(storageRoot + path.sep)) {
return reply.code(400).send({ error: 'invalid_media_path' })
}
// Fichier écrit avant la base : si la transaction échoue, on l'efface et rien n'a changé.
await mkdir(path.dirname(absolutePath), { recursive: true })
await writeFile(absolutePath, prepared.buffer)
try {
await app.prisma.$transaction([
app.prisma.person.update({
where: { id: personId },
data: {
avatarPath: relativePath,
avatarMimeType: prepared.mimeType,
immichPersonId,
updatedBy: request.actor.userId,
},
}),
app.prisma.auditLog.create({
data: {
treeId,
actorType: 'user',
actorId: request.actor.userId,
action: 'immich_assign',
entityType: 'person',
entityId: personId,
payloadJson: { immichPersonId, avatarUpdated: true, avatarPath: relativePath },
},
}),
])
} catch (error) {
await removeStoredFile(storageRoot, relativePath).catch(() => {})
request.log.error({ err: error }, 'Échec de l’association Immich : fichier de portrait retiré, base inchangée')
throw error
}
// L'ancien portrait ne reste pas sur le disque. Un échec du nettoyage n'invalide pas
// l'association, qui a déjà réussi : il est journalisé, rien de plus.
if (person.avatarPath && person.avatarPath !== relativePath) {
await removeStoredFile(storageRoot, person.avatarPath).catch((cleanupError) => {
request.log.error(
{ err: cleanupError, oldAvatar: person.avatarPath },
'Ancien portrait non supprimé après une association Immich',
)
})
}
return reply.send({ ok: true, avatarUpdated: true, immichPersonId })
})
app.post('/trees/:id/immich/unassign', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
const params = treeParamSchema.safeParse(request.params)
const payload = unassignSchema.safeParse(request.body)
if (!params.success || !payload.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 { personId } = payload.data
const treeId = params.data.id
const person = await app.prisma.person.findFirst({
where: { id: personId, treeId, deletedAt: null },
select: { id: true, avatarPath: true, immichPersonId: true },
})
if (!person) {
return reply.code(404).send({ error: 'person_not_found' })
}
// Le portrait vient d'Immich uniquement si son fichier porte notre préfixe. Un portrait
// envoyé à la main reste en place : seule l'association est retirée.
const avatarFromImmich = person.avatarPath !== null && path.basename(person.avatarPath).startsWith('immich-')
const updateData: {
immichPersonId: null
updatedBy: string
avatarPath?: null
avatarMimeType?: null
} = { immichPersonId: null, updatedBy: request.actor.userId }
if (avatarFromImmich) {
updateData.avatarPath = null
updateData.avatarMimeType = null
}
// Base d'abord, fichier ensuite : le portrait disparaît seulement une fois la base à jour.
await app.prisma.$transaction([
app.prisma.person.update({ where: { id: personId }, data: updateData }),
app.prisma.auditLog.create({
data: {
treeId,
actorType: 'user',
actorId: request.actor.userId,
action: 'immich_unassign',
entityType: 'person',
entityId: personId,
payloadJson: { previousImmichPersonId: person.immichPersonId, avatarRemoved: avatarFromImmich },
},
}),
])
if (avatarFromImmich) {
await removeStoredFile(getStorageRoot(), person.avatarPath).catch((cleanupError) => {
request.log.error(
{ err: cleanupError, avatarPath: person.avatarPath },
'Portrait Immich non supprimé après un désassignement (base à jour)',
)
})
}
return reply.send({ ok: true, avatarRemoved: avatarFromImmich })
})
}
+5 -4
View File
@@ -92,7 +92,8 @@ function hasAdminRight(role: MembershipRole): boolean {
return role === 'owner' || role === 'admin'
}
function getStorageRoot(): string {
// Racine du stockage des médias : partagée avec les portraits venus d'Immich (routes/immich.ts).
export function getStorageRoot(): string {
return path.resolve(env.MEDIA_STORAGE_PATH)
}
@@ -116,7 +117,7 @@ function normalizeOptionalText(value: string | null | undefined): string | null
// Fichier décodé, reconnu à ses octets et nettoyé (photos sans métadonnées, vidéos sans position).
// Le type déclaré par le navigateur n'est plus qu'un indice pour les conteneurs ambigus.
async function prepareUploadedFile(
export async function prepareUploadedFile(
buffer: Buffer,
type: BinaryMediaType,
fileName: string,
@@ -167,7 +168,7 @@ async function treeDiskUsage(treeId: string): Promise<number> {
}
// Place disque d'un arbre : total, et part des envois en attente de relecture (limite l'envoi en boucle)
async function exceedsStorageQuota(app: FastifyRequest['server'], treeId: string, incomingBytes: number, isPending: boolean): Promise<boolean> {
export async function exceedsStorageQuota(app: FastifyRequest['server'], treeId: string, incomingBytes: number, isPending: boolean): Promise<boolean> {
const MB = 1024 * 1024
if ((await treeDiskUsage(treeId)) + incomingBytes > env.MEDIA_QUOTA_PER_TREE_MB * MB) {
return true
@@ -187,7 +188,7 @@ async function exceedsStorageQuota(app: FastifyRequest['server'], treeId: string
// Seules les routes d'envoi acceptent un corps de 12 Mo (fichier en base64), le reste de l'API est à 1 Mo (app.ts)
const UPLOAD_RATE_LIMIT = { bodyLimit: UPLOAD_BODY_LIMIT, config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }
async function requireAdminMembership(app: FastifyRequest['server'], userId: string, treeId: string) {
export async function requireAdminMembership(app: FastifyRequest['server'], userId: string, treeId: string) {
const membership = await app.prisma.treeMembership.findUnique({
where: {
treeId_userId: {
+2
View File
@@ -17,6 +17,7 @@ export type GraphPayload = {
avatarUrlPath: string | null
sex: string | null
notes: string | null
immichPersonId: string | null
}>
unions: Array<{
id: string
@@ -90,6 +91,7 @@ export function buildGraphPayload(
avatarUrlPath: person.avatarPath ? `/trees/${treeId}/persons/${person.id}/avatar` : null,
sex: person.sex,
notes: person.notes,
immichPersonId: person.immichPersonId,
})),
unions: unions.map((union) => ({
id: union.id,
+99
View File
@@ -0,0 +1,99 @@
import { randomUUID } from 'node:crypto'
import type { FastifyInstance } from 'fastify'
import { beforeAll, describe, expect, it, vi } from 'vitest'
// Immich désactivé : aucune requête vers l'instance ne doit partir, chaque route répond « non configuré ».
// Fichier à part : la configuration est lue au démarrage de l'application, impossible à changer à chaud.
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
user: {
count: vi.fn().mockResolvedValue(1),
},
person: {
findFirst: vi.fn(),
update: vi.fn(),
},
treeMembership: {
findUnique: vi.fn(),
},
auditLog: {
create: vi.fn(),
},
$transaction: vi.fn(),
$disconnect: vi.fn(),
},
}))
vi.mock('../src/lib/prisma.js', () => ({
prisma: prismaMock,
}))
let createApp: () => FastifyInstance
beforeAll(async () => {
delete process.env.IMMICH_ENABLED
delete process.env.IMMICH_API_URL
delete process.env.IMMICH_API_KEY
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'
const module = await import('../src/app.js')
createApp = module.createApp
})
describe('Immich integration: disabled server', () => {
it('reports not configured and never calls Immich', async () => {
const app = createApp()
await app.ready()
const token = (app as any).jwt.sign(
{ kind: 'user', sub: 'user-1', jti: randomUUID(), userId: 'user-1', email: 'user@example.com' },
{ expiresIn: '1h' },
)
prismaMock.treeMembership.findUnique.mockResolvedValue({
treeId: 'tree-1',
userId: 'user-1',
role: 'owner',
tree: { deletedAt: null },
})
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const status = await app.inject({
method: 'GET',
url: '/trees/tree-1/immich/status',
headers: { authorization: `Bearer ${token}` },
})
expect(status.statusCode).toBe(200)
expect(status.json()).toEqual({ available: false, reason: 'not_configured' })
const people = await app.inject({
method: 'GET',
url: '/trees/tree-1/immich/people',
headers: { authorization: `Bearer ${token}` },
})
expect(people.statusCode).toBe(503)
expect(people.json()).toEqual({ error: 'immich_not_configured' })
const assign = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/assign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-1', immichPersonId: '123e4567-e89b-42d3-a456-426614174000' },
})
expect(assign.statusCode).toBe(503)
expect(assign.json()).toEqual({ error: 'immich_not_configured' })
expect(fetchMock).not.toHaveBeenCalled()
vi.unstubAllGlobals()
await app.close()
})
})
+571
View File
@@ -0,0 +1,571 @@
import { randomUUID } from 'node:crypto'
import { mkdtemp, mkdir, rm, writeFile, stat, readdir } from 'node:fs/promises'
import path from 'node:path'
import os from 'node:os'
import sharp from 'sharp'
import type { FastifyInstance } from 'fastify'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
user: {
count: vi.fn(),
},
person: {
findFirst: vi.fn(),
update: vi.fn(),
},
treeMembership: {
findUnique: vi.fn(),
},
mediaItem: {
aggregate: vi.fn(),
},
auditLog: {
create: vi.fn(),
},
$transaction: vi.fn(),
$disconnect: vi.fn(),
},
}))
vi.mock('../src/lib/prisma.js', () => ({
prisma: prismaMock,
}))
let createApp: () => FastifyInstance
let storageRoot: string
let jpegBytes: Buffer
const immichPersonId = '123e4567-e89b-42d3-a456-426614174000'
const otherImmichId = '123e4567-e89b-42d3-a456-426614174111'
const fetchMock = vi.fn()
async function jsonResponse(payload: unknown, status = 200): Promise<Response> {
return new Response(JSON.stringify(payload), { status, headers: { 'content-type': 'application/json' } })
}
async function createUserToken(app: FastifyInstance, userId = 'user-1'): Promise<string> {
await app.ready()
return (app as any).jwt.sign(
{ kind: 'user', sub: userId, jti: randomUUID(), userId, email: 'user@example.com' },
{ expiresIn: '1h' },
)
}
async function absStoragePath(relativePath: string): Promise<string> {
return path.resolve(storageRoot, relativePath)
}
async function fsExists(absolutePath: string): Promise<boolean> {
try {
await stat(absolutePath)
return true
} catch {
return false
}
}
async function fsListDir(absolutePath: string): Promise<string[]> {
try {
return await readdir(absolutePath)
} catch {
return []
}
}
beforeAll(async () => {
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'
process.env.IMMICH_ENABLED = 'true'
process.env.IMMICH_API_URL = 'http://immich.test'
process.env.IMMICH_API_KEY = 'test-key'
storageRoot = await mkdtemp(path.join(os.tmpdir(), 'parenthese-immich-'))
process.env.MEDIA_STORAGE_PATH = storageRoot
// Quota serré pour le tester : le disque est mesuré en vrai (taille du dossier de l'arbre).
process.env.MEDIA_QUOTA_PER_TREE_MB = '1'
jpegBytes = await sharp({ create: { width: 8, height: 8, channels: 3, background: '#aabbcc' } }).jpeg().toBuffer()
const module = await import('../src/app.js')
createApp = module.createApp
vi.stubGlobal('fetch', fetchMock)
})
afterAll(async () => {
await rm(storageRoot, { recursive: true, force: true })
vi.unstubAllGlobals()
})
beforeEach(async () => {
vi.clearAllMocks()
prismaMock.user.count.mockResolvedValue(1)
// La transaction de l'API prend un tableau d'opérations ; les autres tests du dépôt utilisent la forme callback.
prismaMock.$transaction.mockImplementation(async (input: unknown) => {
if (Array.isArray(input)) {
return Promise.all(input)
}
return (input as (tx: typeof prismaMock) => Promise<unknown>)(prismaMock)
})
prismaMock.treeMembership.findUnique.mockResolvedValue({
treeId: 'tree-1',
userId: 'user-1',
role: 'owner',
tree: { deletedAt: null },
})
prismaMock.person.findFirst.mockResolvedValue(null)
prismaMock.person.update.mockResolvedValue({ id: 'person-1' })
prismaMock.auditLog.create.mockResolvedValue({ id: 'audit-1' })
prismaMock.mediaItem.aggregate.mockResolvedValue({ _sum: { sizeBytes: 0 } })
fetchMock.mockReset()
fetchMock.mockResolvedValue(await jsonResponse({ res: 'pong' }))
})
describe('Immich integration: status and people', () => {
it('reports Immich available when ping answers pong', async () => {
const app = createApp()
const token = await createUserToken(app)
fetchMock.mockResolvedValue(await jsonResponse({ res: 'pong' }))
const response = await app.inject({
method: 'GET',
url: '/trees/tree-1/immich/status',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ available: true, reason: null })
await app.close()
})
it('reports Immich unreachable when ping fails', async () => {
const app = createApp()
const token = await createUserToken(app)
fetchMock.mockRejectedValue(new Error('network down'))
const response = await app.inject({
method: 'GET',
url: '/trees/tree-1/immich/status',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ available: false, reason: 'unreachable' })
await app.close()
})
it('rejects unauthenticated and non-admin requests on status', async () => {
const app = createApp()
const anonymous = await app.inject({ method: 'GET', url: '/trees/tree-1/immich/status' })
expect(anonymous.statusCode).toBe(401)
const token = await createUserToken(app)
prismaMock.treeMembership.findUnique.mockResolvedValue({
treeId: 'tree-1',
userId: 'user-1',
role: 'member',
tree: { deletedAt: null },
})
const member = await app.inject({
method: 'GET',
url: '/trees/tree-1/immich/status',
headers: { authorization: `Bearer ${token}` },
})
expect(member.statusCode).toBe(403)
await app.close()
})
it('lists people across Immich pages', async () => {
const app = createApp()
const token = await createUserToken(app)
fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/api/people?')) {
const page = url.includes('page=2') ? 2 : 1
if (page === 1) {
return jsonResponse({
people: [{ id: immichPersonId, name: 'Jean Dupont', thumbnailPath: '/thumb/a.jpg', isHidden: false, birthDate: null }],
total: 2,
hasNextPage: true,
hidden: 0,
})
}
return jsonResponse({
people: [{ id: otherImmichId, name: 'Marie Martin', thumbnailPath: '', isHidden: false, birthDate: null }],
total: 2,
hasNextPage: false,
hidden: 0,
})
}
return jsonResponse({ res: 'pong' })
})
const response = await app.inject({
method: 'GET',
url: '/trees/tree-1/immich/people',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(response.json().people).toEqual([
{ id: immichPersonId, name: 'Jean Dupont', hasThumbnail: true },
{ id: otherImmichId, name: 'Marie Martin', hasThumbnail: false },
])
await app.close()
})
it('maps an Immich auth failure to 502 and a malformed response to 503', async () => {
const app = createApp()
const token = await createUserToken(app)
fetchMock.mockResolvedValue(new Response(JSON.stringify({ message: 'Invalid API key' }), { status: 401 }))
const authFailure = await app.inject({
method: 'GET',
url: '/trees/tree-1/immich/people',
headers: { authorization: `Bearer ${token}` },
})
expect(authFailure.statusCode).toBe(502)
expect(authFailure.json()).toEqual({ error: 'immich_auth_failed' })
fetchMock.mockResolvedValue(await jsonResponse({ total: 0, hasNextPage: false }))
const malformed = await app.inject({
method: 'GET',
url: '/trees/tree-1/immich/people',
headers: { authorization: `Bearer ${token}` },
})
expect(malformed.statusCode).toBe(503)
expect(malformed.json()).toEqual({ error: 'immich_unreachable' })
await app.close()
})
})
describe('Immich integration: thumbnail proxy', () => {
it('proxies a thumbnail with its sniffed content type', async () => {
const app = createApp()
const token = await createUserToken(app)
fetchMock.mockResolvedValue(new Response(new Uint8Array(jpegBytes), { status: 200 }))
const response = await app.inject({
method: 'GET',
url: `/trees/tree-1/immich/thumbnail/${immichPersonId}`,
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(response.headers['content-type']).toContain('image/jpeg')
expect(Buffer.from(response.rawPayload).equals(jpegBytes)).toBe(true)
await app.close()
})
it('returns 404 when the thumbnail is missing and 400 for a malformed identifier', async () => {
const app = createApp()
const token = await createUserToken(app)
fetchMock.mockResolvedValue(new Response('', { status: 404 }))
const missing = await app.inject({
method: 'GET',
url: `/trees/tree-1/immich/thumbnail/${immichPersonId}`,
headers: { authorization: `Bearer ${token}` },
})
expect(missing.statusCode).toBe(404)
expect(missing.json()).toEqual({ error: 'immich_person_not_found' })
const invalid = await app.inject({
method: 'GET',
url: '/trees/tree-1/immich/thumbnail/not-a-uuid',
headers: { authorization: `Bearer ${token}` },
})
expect(invalid.statusCode).toBe(400)
await app.close()
})
})
describe('Immich integration: assign', () => {
it('downloads the thumbnail, replaces the avatar and audits the change', async () => {
const app = createApp()
const token = await createUserToken(app)
// Portrait manuel en place : 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')
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: oldAvatarRelative })
fetchMock.mockResolvedValue(new Response(new Uint8Array(jpegBytes), { status: 200 }))
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/assign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-1', immichPersonId },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ ok: true, avatarUpdated: true, immichPersonId })
const updateData = prismaMock.person.update.mock.calls[0][0].data
expect(updateData.immichPersonId).toBe(immichPersonId)
expect(updateData.avatarMimeType).toBe('image/jpeg')
expect(typeof updateData.avatarPath).toBe('string')
// Le nouveau portrait existe sur le disque, l'ancien a été retiré.
const newAvatar = await absStoragePath(updateData.avatarPath)
await expect(fsExists(newAvatar)).resolves.toBe(true)
await expect(fsExists(await absStoragePath(oldAvatarRelative))).resolves.toBe(false)
expect(prismaMock.auditLog.create).toHaveBeenCalledWith({
data: {
treeId: 'tree-1',
actorType: 'user',
actorId: 'user-1',
action: 'immich_assign',
entityType: 'person',
entityId: 'person-1',
payloadJson: { immichPersonId, avatarUpdated: true, avatarPath: updateData.avatarPath },
},
})
await app.close()
})
it('assigns without touching the avatar when Immich has no thumbnail', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: 'tree-1/person-1/avatar-keep.jpg' })
fetchMock.mockResolvedValue(new Response('', { status: 404 }))
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/assign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-1', immichPersonId },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ ok: true, avatarUpdated: false, immichPersonId })
const updateData = prismaMock.person.update.mock.calls[0][0].data
expect(updateData.immichPersonId).toBe(immichPersonId)
expect(updateData).not.toHaveProperty('avatarPath')
expect(updateData).not.toHaveProperty('avatarMimeType')
await app.close()
})
it('rejects invalid identifiers, unauthenticated, non-admin and unknown person', async () => {
const app = createApp()
const token = await createUserToken(app)
const invalidId = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/assign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-1', immichPersonId: '../etc/passwd' },
})
expect(invalidId.statusCode).toBe(400)
expect(invalidId.json()).toEqual({ error: 'invalid_immich_person_id' })
const anonymous = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/assign',
payload: { personId: 'person-1', immichPersonId },
})
expect(anonymous.statusCode).toBe(401)
prismaMock.treeMembership.findUnique.mockResolvedValue({
treeId: 'tree-1',
userId: 'user-1',
role: 'member',
tree: { deletedAt: null },
})
const member = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/assign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-1', immichPersonId },
})
expect(member.statusCode).toBe(403)
prismaMock.treeMembership.findUnique.mockResolvedValue({
treeId: 'tree-1',
userId: 'user-1',
role: 'owner',
tree: { deletedAt: null },
})
prismaMock.person.findFirst.mockResolvedValue(null)
const unknown = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/assign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-x', immichPersonId },
})
expect(unknown.statusCode).toBe(404)
expect(unknown.json()).toEqual({ error: 'person_not_found' })
await app.close()
})
it('refuses the assignment when the tree quota is exceeded', async () => {
const app = createApp()
const token = await createUserToken(app)
// L'arbre est déjà plein : 1 Mo sur le disque, un quota de 1 Mo.
await mkdir(path.join(storageRoot, 'tree-quota'), { recursive: true })
await writeFile(path.join(storageRoot, 'tree-quota', 'big.bin'), Buffer.alloc(1024 * 1024))
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-quota', avatarPath: null })
fetchMock.mockResolvedValue(new Response(new Uint8Array(jpegBytes), { status: 200 }))
const response = await app.inject({
method: 'POST',
url: '/trees/tree-quota/immich/assign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-quota', immichPersonId },
})
expect(response.statusCode).toBe(413)
expect(response.json()).toEqual({ error: 'storage_quota_exceeded' })
await app.close()
})
it('removes the new file and keeps the old avatar when the database update fails', async () => {
const app = createApp()
const token = await createUserToken(app)
const oldAvatarRelative = 'tree-1/person-tx/avatar-kept.jpg'
await mkdir(path.dirname(await absStoragePath(oldAvatarRelative)), { recursive: true })
await writeFile(await absStoragePath(oldAvatarRelative), 'old avatar')
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-tx', avatarPath: oldAvatarRelative })
fetchMock.mockResolvedValue(new Response(new Uint8Array(jpegBytes), { status: 200 }))
prismaMock.$transaction.mockRejectedValue(new Error('db down'))
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/assign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-tx', immichPersonId },
})
expect(response.statusCode).toBe(500)
// La base n'a pas changé : l'ancien portrait est là, le nouveau a été nettoyé.
await expect(fsExists(await absStoragePath(oldAvatarRelative))).resolves.toBe(true)
const writtenFiles = await fsListDir(path.join(storageRoot, 'tree-1', 'person-tx'))
expect(writtenFiles.filter((name) => name.startsWith('immich-'))).toEqual([])
await app.close()
})
})
describe('Immich integration: unassign', () => {
it('clears an Immich avatar after the database update', async () => {
const app = createApp()
const token = await createUserToken(app)
const immichAvatarRelative = `tree-1/person-1/immich-${randomUUID()}.jpg`
await mkdir(path.dirname(await absStoragePath(immichAvatarRelative)), { recursive: true })
await writeFile(await absStoragePath(immichAvatarRelative), 'immich avatar')
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: immichAvatarRelative, immichPersonId })
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/unassign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-1' },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ ok: true, avatarRemoved: true })
const updateData = prismaMock.person.update.mock.calls[0][0].data
expect(updateData).toMatchObject({ immichPersonId: null, avatarPath: null, avatarMimeType: null })
expect(prismaMock.auditLog.create).toHaveBeenCalledWith({
data: {
treeId: 'tree-1',
actorType: 'user',
actorId: 'user-1',
action: 'immich_unassign',
entityType: 'person',
entityId: 'person-1',
payloadJson: { previousImmichPersonId: immichPersonId, avatarRemoved: true },
},
})
await expect(fsExists(await absStoragePath(immichAvatarRelative))).resolves.toBe(false)
await app.close()
})
it('preserves a manually uploaded avatar when unassigning', async () => {
const app = createApp()
const token = await createUserToken(app)
const manualAvatarRelative = `tree-1/person-1/avatar-${randomUUID()}.jpg`
await mkdir(path.dirname(await absStoragePath(manualAvatarRelative)), { recursive: true })
await writeFile(await absStoragePath(manualAvatarRelative), 'manual avatar')
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: manualAvatarRelative, immichPersonId })
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/unassign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-1' },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ ok: true, avatarRemoved: false })
const updateData = prismaMock.person.update.mock.calls[0][0].data
expect(updateData.immichPersonId).toBeNull()
expect(updateData).not.toHaveProperty('avatarPath')
expect(updateData).not.toHaveProperty('avatarMimeType')
await expect(fsExists(await absStoragePath(manualAvatarRelative))).resolves.toBe(true)
await app.close()
})
it('rejects unknown persons and non-admin members', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.person.findFirst.mockResolvedValue(null)
const unknown = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/unassign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-x' },
})
expect(unknown.statusCode).toBe(404)
prismaMock.treeMembership.findUnique.mockResolvedValue({
treeId: 'tree-1',
userId: 'user-1',
role: 'member',
tree: { deletedAt: null },
})
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: null, immichPersonId })
const member = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/unassign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-1' },
})
expect(member.statusCode).toBe(403)
await app.close()
})
})
+8
View File
@@ -1503,6 +1503,11 @@ function App() {
}
}, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection])
// Visage venu d'Immich : recharger l'arbre pour montrer le nouveau portrait (ou le portrait restant)
const handleImmichChange = useCallback(async (personId) => {
await refreshTreeAndKeepSelection(personId, { relayout: false, avatarCacheBust: Date.now() })
}, [refreshTreeAndKeepSelection])
const handlePersonAvatarDelete = useCallback(async (personId) => {
const accessToken = tree.treeContext.accessToken || auth.userAuth.token
if (!accessToken || !tree.treeContext.treeId) return
@@ -2698,6 +2703,9 @@ function App() {
onManageMedia={media.handleOpenMediaManager}
onAvatarUpload={tree.canEditCurrentTree ? handlePersonAvatarUpload : undefined}
onAvatarDelete={tree.canEditCurrentTree ? handlePersonAvatarDelete : undefined}
treeId={tree.treeContext.treeId || null}
immichToken={tree.treeContext.accessToken || auth.userAuth.token}
onImmichChange={tree.canEditCurrentTree ? handleImmichChange : undefined}
avatarActionLoading={avatarActionState.loading}
avatarActionError={avatarActionState.error}
isAdmin={userRole.isAdmin}
+57
View File
@@ -0,0 +1,57 @@
import { API_BASE_URL, apiRequest } from './client.js'
// Reconnaissance des visages : l'API du backend parle à Immich et ne lui expose ni son
// adresse ni sa clé. Les erreurs distinguent non configuré / injoignable / clé refusée.
function withAuth(options = {}, token) {
if (!token) return options
return { ...options, headers: { ...(options.headers || {}), Authorization: `Bearer ${token}` } }
}
export function getImmichErrorMessage(error, t) {
const code = error?.payload?.error || ''
if (code === 'immich_not_configured') return t('Immich n’est pas configuré sur le serveur.')
if (code === 'immich_unreachable') return t('Immich est injoignable.')
if (code === 'immich_auth_failed') return t('La clé d’accès à Immich a été refusée.')
if (code === 'immich_person_not_found') return t('Personne introuvable dans Immich.')
if (code === 'invalid_immich_person_id') return t('Identifiant Immich invalide.')
if (code === 'invalid_avatar_image') return t('La vignette Immich n’est pas une image valide.')
if (code === 'storage_quota_exceeded') return t('Espace disque de l’arbre dépassé.')
if (code === 'forbidden') return t('Seuls les administrateurs de l’arbre peuvent associer un visage Immich.')
if (error?.status === 0 || error?.message === 'Failed to fetch') return t('Immich est injoignable.')
return error?.message || t('Erreur inattendue.')
}
export async function checkImmichStatus(treeId, token) {
return apiRequest(`/trees/${encodeURIComponent(treeId)}/immich/status`, withAuth({}, token))
}
export async function listImmichPeople(treeId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/immich/people`, withAuth({}, token))
return Array.isArray(data?.people) ? data.people : []
}
export async function fetchImmichThumbnail(treeId, immichPersonId, token) {
const path = `/trees/${encodeURIComponent(treeId)}/immich/thumbnail/${encodeURIComponent(immichPersonId)}`
const response = await fetch(`${API_BASE_URL}${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!response.ok) {
throw new Error(`immich_thumbnail_failed_${response.status}`)
}
return URL.createObjectURL(await response.blob())
}
export async function assignImmichPerson(treeId, personId, immichPersonId, token) {
return apiRequest(`/trees/${encodeURIComponent(treeId)}/immich/assign`, withAuth({
method: 'POST',
body: JSON.stringify({ personId, immichPersonId }),
}, token))
}
export async function unassignImmichPerson(treeId, personId, token) {
return apiRequest(`/trees/${encodeURIComponent(treeId)}/immich/unassign`, withAuth({
method: 'POST',
body: JSON.stringify({ personId }),
}, token))
}
@@ -0,0 +1,81 @@
/* Association d'un visage Immich : sélecteur de personne avec aperçu du portrait. La fenêtre vient de pz.css */
.immich-modal-overlay { z-index: 2200; }
.immich-modal-card { max-width: 520px; }
.immich-body { display: grid; gap: 16px; }
.immich-current {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 12px 14px;
border: 1px solid var(--pz-a300);
border-radius: var(--pz-r-m);
background: var(--pz-a50);
font: 500 13px var(--pz-sans);
color: var(--pz-ink-2);
}
.immich-search {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border: 1px solid var(--pz-line);
border-radius: var(--pz-r-m);
background: var(--pz-surface);
color: var(--pz-faint);
}
.immich-search input {
flex: 1;
border: none;
background: transparent;
font: 500 13.5px var(--pz-sans);
color: var(--pz-ink);
outline: none;
}
.immich-list {
display: grid;
gap: 6px;
max-height: 220px;
overflow-y: auto;
border: 1px solid var(--pz-line);
border-radius: var(--pz-r-m);
padding: 6px;
}
.immich-person {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 10px;
border: 1px solid transparent;
border-radius: calc(var(--pz-r-m) - 3px);
background: var(--pz-surface);
color: var(--pz-ink-2);
font: 500 13px var(--pz-sans);
text-align: left;
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease;
}
.immich-person.is-active { border-color: var(--pz-a500); background: var(--pz-a50); color: var(--pz-a700); }
@media (hover: hover) and (pointer: fine) { .immich-person:hover:not(.is-active) { background: var(--pz-a50); } }
.immich-preview {
display: grid;
justify-items: center;
gap: 8px;
padding: 12px;
border: 1px dashed var(--pz-line);
border-radius: var(--pz-r-m);
}
.immich-preview img { width: 96px; height: 96px; object-fit: cover; border-radius: 50%; }
.immich-preview-empty { width: 96px; height: 96px; display: grid; place-items: center; border-radius: 50%; background: var(--pz-surface); color: var(--pz-faint); }
.immich-preview-name { font: 500 13px var(--pz-sans); color: var(--pz-faint); }
.immich-empty { font: 500 13px var(--pz-sans); color: var(--pz-faint); text-align: center; padding: 12px 0; }
.immich-error { margin: 0; }
.immich-spin { animation: immich-rotate 900ms linear infinite; }
@keyframes immich-rotate { to { transform: rotate(360deg); } }
+220
View File
@@ -0,0 +1,220 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { ImagePlus, Loader2, Search, Unlink, UserRound, X } from 'lucide-react'
import {
assignImmichPerson,
checkImmichStatus,
fetchImmichThumbnail,
getImmichErrorMessage,
listImmichPeople,
unassignImmichPerson,
} from '../api/immichApi.js'
import { t } from '../i18n/index.js'
import PzBusy from './PzBusy.jsx'
import './ImmichFacePanel.css'
// Associer une personne de l'arbre à une personne Immich : sa vignette devient le portrait.
// Réservé aux administrateurs de l'arbre (l'API vérifie le rôle côté serveur).
const ImmichFacePanel = ({ person, treeId, token, onClose, onImmichChange }) => {
const [available, setAvailable] = useState(null) // null : vérification en cours
const [unavailableReason, setUnavailableReason] = useState('')
const [people, setPeople] = useState([])
const [loadingPeople, setLoadingPeople] = useState(false)
const [search, setSearch] = useState('')
const [selected, setSelected] = useState(null)
const [thumbnailUrl, setThumbnailUrl] = useState(null)
const [actionLoading, setActionLoading] = useState(false)
const [error, setError] = useState('')
const thumbnailRef = useRef(null)
const releaseThumbnail = useCallback(() => {
if (thumbnailRef.current) {
URL.revokeObjectURL(thumbnailRef.current)
thumbnailRef.current = null
}
setThumbnailUrl(null)
}, [])
useEffect(() => {
let cancelled = false
releaseThumbnail()
const load = async () => {
try {
const status = await checkImmichStatus(treeId, token)
if (cancelled) return
setAvailable(Boolean(status?.available))
setUnavailableReason(status?.available ? '' : (status?.reason || 'unreachable'))
if (!status?.available) return
setLoadingPeople(true)
const immichPeople = await listImmichPeople(treeId, token)
if (cancelled) return
setPeople(immichPeople)
} catch (err) {
if (cancelled) return
setAvailable(false)
setUnavailableReason('')
setError(getImmichErrorMessage(err, t))
} finally {
if (!cancelled) setLoadingPeople(false)
}
}
load()
return () => { cancelled = true }
}, [treeId, token, releaseThumbnail])
useEffect(() => releaseThumbnail, [releaseThumbnail])
const loadThumbnail = useCallback(async (immichPersonId) => {
releaseThumbnail()
try {
const url = await fetchImmichThumbnail(treeId, immichPersonId, token)
thumbnailRef.current = url
setThumbnailUrl(url)
} catch {
setThumbnailUrl(null)
}
}, [treeId, token, releaseThumbnail])
const handleSelect = useCallback((immichPerson) => {
setSelected(immichPerson)
setError('')
if (immichPerson?.hasThumbnail && immichPerson?.id) {
loadThumbnail(immichPerson.id)
} else {
releaseThumbnail()
}
}, [loadThumbnail, releaseThumbnail])
const handleAssign = useCallback(async () => {
if (!selected || actionLoading) return
setActionLoading(true)
setError('')
try {
await assignImmichPerson(treeId, person.id, selected.id, token)
onImmichChange?.(person.id)
onClose?.()
} catch (err) {
setError(getImmichErrorMessage(err, t))
} finally {
setActionLoading(false)
}
}, [selected, actionLoading, treeId, person, token, onImmichChange, onClose])
const handleUnassign = useCallback(async () => {
if (!person.immichPersonId || actionLoading) return
setActionLoading(true)
setError('')
try {
await unassignImmichPerson(treeId, person.id, token)
onImmichChange?.(person.id)
onClose?.()
} catch (err) {
setError(getImmichErrorMessage(err, t))
} finally {
setActionLoading(false)
}
}, [person, actionLoading, treeId, token, onImmichChange, onClose])
const filteredPeople = search.trim()
? people.filter((immichPerson) => immichPerson.name?.toLowerCase().includes(search.trim().toLowerCase()))
: people
const handleOverlayClick = (event) => {
if (event.target === event.currentTarget) {
onClose()
}
}
return (
<div className="pz-overlay immich-modal-overlay" onClick={handleOverlayClick}>
<div className="pz-modal immich-modal-card" role="dialog" aria-modal="true" aria-labelledby="immichFaceTitle">
<div className="pz-modal-head">
<p className="pz-eyebrow">{t('Reconnaissance des visages')}</p>
<h1 id="immichFaceTitle" className="pz-title">{person.firstName} <em>{person.lastName}</em></h1>
</div>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon pz-modal-close" onClick={onClose} aria-label={t('Fermer')}>
<X size={18} aria-hidden="true" />
</button>
{available === null || loadingPeople ? (
<div className="immich-body"><PzBusy /></div>
) : available === false ? (
<div className="immich-body">
<p className="immich-empty">
{unavailableReason === 'not_configured'
? t('Immich n’est pas configuré sur le serveur.')
: t('Immich est injoignable.')}
</p>
</div>
) : (
<div className="immich-body">
{person.immichPersonId && (
<div className="immich-current">
<p>{t('{name} est déjà associée à une personne Immich.', { name: person.firstName || person.lastName })}</p>
<button type="button" className="pz-btn pz-btn--danger-ghost" onClick={handleUnassign} disabled={actionLoading}>
<Unlink size={16} aria-hidden="true" /> {t('Retirer l’association')}
</button>
</div>
)}
<div className="pz-field">
<span className="pz-label" id="immichSearchLabel">{t('Personne à associer dans Immich')}</span>
<div className="immich-search">
<Search size={16} aria-hidden="true" />
<input
type="search"
aria-labelledby="immichSearchLabel"
placeholder={t('Rechercher par nom')}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
{filteredPeople.length === 0 ? (
<p className="immich-empty">{people.length === 0 ? t('Aucune personne dans Immich.') : t('Aucune personne trouvée.')}</p>
) : (
<div className="immich-list" role="listbox" aria-label={t('Personnes Immich')}>
{filteredPeople.map((immichPerson) => (
<button
key={immichPerson.id}
type="button"
role="option"
aria-selected={selected?.id === immichPerson.id}
className={`immich-person ${selected?.id === immichPerson.id ? 'is-active' : ''}`}
onClick={() => handleSelect(immichPerson)}
>
<UserRound size={16} aria-hidden="true" />
<span>{immichPerson.name || t('Sans nom')}</span>
</button>
))}
</div>
)}
<div className="immich-preview">
{thumbnailUrl
? <img src={thumbnailUrl} alt={selected?.name || ''} />
: <div className="immich-preview-empty"><ImagePlus size={22} aria-hidden="true" /></div>}
<div className="immich-preview-name">{selected?.name || t('Aucune sélection')}</div>
</div>
{error && <div className="pz-error immich-error">{error}</div>}
<div className="pz-modal-actions">
<button type="button" className="pz-btn pz-btn--ghost" onClick={onClose}>{t('Annuler')}</button>
<button type="button" className="pz-btn pz-btn--primary" onClick={handleAssign} disabled={!selected || actionLoading}>
{actionLoading ? <Loader2 size={16} className="immich-spin" aria-hidden="true" /> : null}
{t('Associer et prendre le portrait')}
</button>
</div>
</div>
)}
</div>
</div>
)
}
export default ImmichFacePanel
+22 -1
View File
@@ -1,7 +1,8 @@
import { useState, useEffect, useRef, forwardRef } from 'react'
import { Save, Trash2, Plus, X, Search, Check, Play } from 'lucide-react'
import { Save, Trash2, Plus, X, Search, Check, Play, Sparkles } from 'lucide-react'
import { getParents, getChildren, getPersonUnions, getPersonMedias, filiations, persons, unions } from '../data/mockData'
import GeoMediaThumbnail from './GeoMediaThumbnail'
import ImmichFacePanel from './ImmichFacePanel.jsx'
import './PersonCard.css'
import PzBusy from './PzBusy.jsx'
import { t, dateLocale } from '../i18n/index.js'
@@ -35,6 +36,9 @@ const PersonCard = ({
onManageMedia,
onAvatarUpload,
onAvatarDelete,
treeId = null,
immichToken = null,
onImmichChange,
avatarActionLoading = false,
avatarActionError = '',
isAdmin = false,
@@ -55,6 +59,7 @@ const PersonCard = ({
const [saving, setSaving] = useState(false)
const [saveSuccess, setSaveSuccess] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [showImmichPanel, setShowImmichPanel] = useState(false)
// Inline relation search state
const [activeRelationSearch, setActiveRelationSearch] = useState(null) // 'parent' | 'spouse' | 'child' | null
@@ -356,6 +361,11 @@ const PersonCard = ({
{t('Retirer')}
</button>
)}
{treeId && onImmichChange && (
<button type="button" className="pc-photo-btn" onClick={() => setShowImmichPanel(true)} disabled={avatarActionLoading}>
<Sparkles size={14} aria-hidden="true" /> {person.immichPersonId ? t('Visage Immich') : t('Depuis Immich')}
</button>
)}
</div>
)}
</div>
@@ -675,6 +685,17 @@ const PersonCard = ({
)}
</footer>
)}
{/* ── Association d'un visage Immich (modale) ── */}
{showImmichPanel && treeId && (
<ImmichFacePanel
person={person}
treeId={treeId}
token={immichToken}
onClose={() => setShowImmichPanel(false)}
onImmichChange={onImmichChange}
/>
)}
</div>
)
}
+1
View File
@@ -115,6 +115,7 @@ function mapRemotePersonToLocal(person, current = null) {
profession: person?.profession || null,
nationality: person?.nationality || null,
photo: current?.photo || null,
immichPersonId: person?.immichPersonId ?? null,
}
}
+5
View File
@@ -15,6 +15,7 @@ describe('loadGraphData avatar mapping', () => {
deathDate: null,
notes: null,
avatarUrlPath: '/trees/t-1/persons/p-1/avatar',
immichPersonId: '123e4567-e89b-42d3-a456-426614174000',
},
{
id: 'p-2',
@@ -24,6 +25,7 @@ describe('loadGraphData avatar mapping', () => {
deathDate: null,
notes: null,
avatarUrlPath: null,
immichPersonId: null,
},
],
unions: [],
@@ -48,5 +50,8 @@ describe('loadGraphData avatar mapping', () => {
expect(getPersonById('p-1')?.photo).toBe('/api/trees/t-1/persons/p-1/avatar?token=abc')
expect(getPersonById('p-2')?.photo).toBeNull()
expect(getPersonMedias('p-2')[0]?.source).toBe('Album familial')
// Association Immich portée par la personne (null quand il n'y en a pas)
expect(getPersonById('p-1')?.immichPersonId).toBe('123e4567-e89b-42d3-a456-426614174000')
expect(getPersonById('p-2')?.immichPersonId).toBeNull()
})
})
+23
View File
@@ -259,4 +259,27 @@ export default {
'En rédaction': 'In the newsroom',
'Interview archive radio': 'Archived radio interview',
'Inconnu': 'Unknown',
// Visages venus d'Immich (PersonCard, ImmichFacePanel, immichApi)
'Depuis Immich': 'From Immich',
'Visage Immich': 'Immich face',
'Reconnaissance des visages': 'Face recognition',
'Immich n’est pas configuré sur le serveur.': 'Immich is not configured on the server.',
'Immich est injoignable.': 'Immich cannot be reached.',
'La clé d’accès à Immich a été refusée.': 'The Immich access key was rejected.',
'Personne introuvable dans Immich.': 'Person not found in Immich.',
'Identifiant Immich invalide.': 'Invalid Immich identifier.',
'La vignette Immich n’est pas une image valide.': 'The Immich thumbnail is not a valid image.',
'Espace disque de l’arbre dépassé.': 'Tree storage quota exceeded.',
'Seuls les administrateurs de l’arbre peuvent associer un visage Immich.': 'Only tree administrators can link an Immich face.',
'Erreur inattendue.': 'Unexpected error.',
'{name} est déjà associée à une personne Immich.': '{name} is already linked to an Immich person.',
'Retirer l’association': 'Remove the link',
'Personne à associer dans Immich': 'Immich person to link',
'Rechercher par nom': 'Search by name',
'Aucune personne dans Immich.': 'No people in Immich.',
'Aucune personne trouvée.': 'No people found.',
'Personnes Immich': 'Immich people',
'Sans nom': 'Unnamed',
'Aucune sélection': 'No selection',
'Associer et prendre le portrait': 'Link and use as portrait',
}
+1
View File
@@ -57,6 +57,7 @@ export function adaptGraphForGalaxy(apiGraph, _treeId) {
birthDate: p.birthDate,
deathDate: p.deathDate,
photo: p.avatarUrlPath ? `/api${p.avatarUrlPath}` : null,
immichPersonId: p.immichPersonId || null,
frameType: 'round', // Default
}))