mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-09 23:50:58 +00:00
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:
1 parent
4bdf7aa1f3
commit
378071bf13
21 files changed
+1629
-5
No files matched your search
@@ -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=""
|
||||
@@ -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;
|
||||
@@ -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?
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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!)
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
|
||||
@@ -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); } }
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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
|
||||
}))
|
||||
|
||||
|
||||
Reference in new issue
Block a user