import type { ProvisionableUser } from 'officerdb'; import { listJellyfinAccounts } from '../db'; import { listJellyfinUsers } from './provision'; import { normalize, type JellyfinUser } from './reconcile'; import { getProvisionableUsers, hasProvisionableUsers } from './user-list'; import type { UpstreamTarget } from './upstream'; // Who exists on each side, and which pairs are the same person. // // Read-only, and deliberately no part of provisioning: this answers "what is true now", where // `planReconcile` answers "what would happen if we ran". They share `normalize` so the screen cannot // disagree with the matching rule about whether `Diogo` and `diogo` are one person — a settings page that // showed them as two would be lying about what the next connection will do. // // The join is by the LINK ROW where one exists, and by the folded name where one does not. That ordering // matters: a recorded pairing is a fact, and a name that happens to match is only a candidate. export type AccountPair = { officer: { id: number; username: string | null; name: string | null; role: string } | null; jellyfin: { id: string; name: string } | null; /** A row in `jellyfin_accounts` says these two are the same person. */ linked: boolean; /** The Jellyfin account existed before Officer knew about it. Never deleted on deprovision. */ adopted: boolean; /** Names match but nothing is recorded — what connecting would adopt. */ matchable: boolean; }; export type AccountView = | { known: false; reason: string } | { known: true; pairs: AccountPair[]; counts: { linked: number; officerOnly: number; jellyfinOnly: number } }; const officerOf = (u: ProvisionableUser) => ({ id: u.id, username: u.username, name: u.name, role: u.role }); /** * Build the view for one server. * * `known: false` when the platform has not sent the account list. That is not the same as "no users" and * the screen must not render it as an empty table — it means this plugin is not entitled to the list or * the push has not arrived, and showing "0 Officer accounts" would read as a fact about the server. */ export async function buildAccountView(cfg: UpstreamTarget, serverId: number): Promise { if (!hasProvisionableUsers()) { return { known: false, reason: 'the platform has not sent the account list yet' }; } const officer = getProvisionableUsers(); const jellyfin: JellyfinUser[] = await listJellyfinUsers(cfg); const links = await listJellyfinAccounts(serverId); const linkByUserId = new Map(links.map((l) => [l.userId, l])); const jellyfinById = new Map(jellyfin.map((j) => [j.Id, j])); const jellyfinByName = new Map(jellyfin.map((j) => [normalize(j.Name), j])); const claimed = new Set(); const pairs: AccountPair[] = []; for (const user of officer) { const link = linkByUserId.get(user.id); const byName = user.username ? jellyfinByName.get(normalize(user.username)) : undefined; if (link) { // The linked account can be GONE — deleted in Jellyfin's own UI. Reported as an Officer account with // no Jellyfin side rather than quietly re-matching it by name, because those are different events // and only one of them is somebody's account disappearing. const live = jellyfinById.get(link.jellyfinUserId); if (live) claimed.add(live.Id); pairs.push({ officer: officerOf(user), jellyfin: live ? { id: live.Id, name: live.Name } : null, linked: true, adopted: link.adopted, matchable: false, }); continue; } if (byName && !claimed.has(byName.Id)) { claimed.add(byName.Id); pairs.push({ officer: officerOf(user), jellyfin: { id: byName.Id, name: byName.Name }, linked: false, adopted: false, matchable: true, }); continue; } pairs.push({ officer: officerOf(user), jellyfin: null, linked: false, adopted: false, matchable: false }); } for (const j of jellyfin) { if (claimed.has(j.Id)) continue; pairs.push({ officer: null, jellyfin: { id: j.Id, name: j.Name }, linked: false, adopted: false, matchable: false, }); } return { known: true, pairs, counts: { linked: pairs.filter((p) => p.linked).length, officerOnly: pairs.filter((p) => p.officer && !p.jellyfin).length, jellyfinOnly: pairs.filter((p) => !p.officer).length, }, }; }