import type { ProvisionableUser } from 'officerdb'; // Deciding what to do about each account, with no side effects. // // Separated from the doing on purpose. The execution talks to a live Immich and cannot be rehearsed; the // decision is where every mistake that matters lives, and it can be checked against the real addresses on // the real instance without creating anything. // // ── The outcomes, and why none of them is "delete" ── // // LINK an Officer user whose email already exists on Immich. Record the pairing; create nothing. // CREATE an Officer user with no Immich account. Make one. // LEAVE an Immich account with no Officer user. Do nothing at all, and say so. // // Deleting an Immich account because nobody holds a matching Officer login would destroy somebody's // photographs on the strength of an absence — and an absence is exactly what an incomplete list looks // like. So they are reported, never touched, and the report is the feature. // // ── Matching is on email, lowercased ── // // Immich identifies an account by email and stores it lowercased (`ZodEmail` with a transform on both the // create and update DTOs). Officer's `users.email` is the same key, so pairing is exact rather than // heuristic — a real simplification over the Jellyfin side, which has to match usernames through Jellyfin's // uppercased `NormalizedUsername` index and cope with two Officer names colliding into one. // // ── The admin's own account is not "not an Officer account" ── // // `listProvisionableUsers` excludes the owner deliberately: they hold the admin account this whole flow // authenticates as, and provisioning them would be a second admin nobody asked for. But that makes the // owner's own Immich account look like it belongs to nobody. It is identified here from the key's own // identity rather than matched by name, and reported as itself. The Jellyfin plugin shipped this bug and // had to fix it in `591c0da`; there is no reason to ship it twice. /** An Immich account as `GET /api/admin/users` reports it. Only the fields a decision needs. */ export type ImmichUser = { id: string; email: string; name: string }; /** An existing pairing, so a second run does not re-decide what is already recorded. */ export type ExistingLink = { userId: number; immichUserId: string }; export type ReconcilePlan = { /** Officer users whose Immich account already exists — record the pairing, create nothing. */ link: { user: ProvisionableUser; immich: ImmichUser }[]; /** Officer users with no Immich account. */ create: ProvisionableUser[]; /** Immich accounts with no Officer user. Reported, never touched. */ leave: ImmichUser[]; /** The account the API key itself belongs to — the owner. Neither provisioned nor unclaimed. */ self: ImmichUser | null; /** Already linked, and still valid on both sides. Nothing to do. */ unchanged: ExistingLink[]; /** * Links whose Immich account has vanished — deleted in Immich's own UI, most likely. * * Separated from `create` even though the remedy is the same, because they are different events: one is a * new person, the other is someone's account disappearing behind Officer's back. */ brokenLinks: ExistingLink[]; }; /** Immich's own rule, applied on this side so the two agree. */ export const normalize = (email: string): string => email.trim().toLowerCase(); /** * Work out what should happen. Nothing here talks to anything. * * `adminImmichUserId` is who the configured API key authenticates as, read from `/api/users/me` rather * than assumed — it is what keeps the owner out of `leave`. */ export function planReconcile(params: { users: ProvisionableUser[]; immichUsers: ImmichUser[]; existing: ExistingLink[]; adminImmichUserId: string | null; }): ReconcilePlan { const { users, immichUsers, existing, adminImmichUserId } = params; const immichByEmail = new Map(immichUsers.map((u) => [normalize(u.email), u])); const immichById = new Map(immichUsers.map((u) => [u.id, u])); const linkedUserIds = new Set(existing.map((e) => e.userId)); /** Immich accounts spoken for — pre-existing links, plus anything claimed during this pass. */ const claimed = new Set(existing.map((e) => e.immichUserId)); const plan: ReconcilePlan = { link: [], create: [], leave: [], self: adminImmichUserId ? (immichById.get(adminImmichUserId) ?? null) : null, unchanged: [], brokenLinks: [], }; for (const link of existing) { (immichById.has(link.immichUserId) ? plan.unchanged : plan.brokenLinks).push(link); } for (const user of users) { if (linkedUserIds.has(user.id)) continue; // already recorded — handled above // An Officer account with no email cannot be matched or created: Immich has no other identifier and // requires one on create. Skipped rather than invented from the username, which would claim an address // the person does not own. if (!user.email) continue; const match = immichByEmail.get(normalize(user.email)); if (match && !claimed.has(match.id)) { plan.link.push({ user, immich: match }); claimed.add(match.id); } else if (!match) { plan.create.push(user); } // A match already claimed falls through deliberately: it is neither this user's account to adopt nor // safe to duplicate, and `leave` below reports it for a human to settle. } for (const immich of immichUsers) { if (claimed.has(immich.id)) continue; if (immich.id === adminImmichUserId) continue; // the connection's own account, reported as `self` plan.leave.push(immich); } return plan; } /** One line per outcome, for the log and the panel. Says what will happen before it happens. */ export function describePlan(plan: ReconcilePlan): string[] { const lines: string[] = []; for (const { user, immich } of plan.link) lines.push(`link ${user.email} → existing Immich "${immich.email}"`); for (const user of plan.create) lines.push(`create ${user.email}`); for (const immich of plan.leave) lines.push(`leave "${immich.email}" alone — no Officer account`); for (const broken of plan.brokenLinks) lines.push(`link for user ${broken.userId} is broken — Immich account is gone`); if (plan.self) lines.push(`"${plan.self.email}" is the account Officer connects as`); if (plan.unchanged.length) lines.push(`${plan.unchanged.length} already linked`); return lines; }