import { eq, and, desc, isNotNull } from 'drizzle-orm'; import { db } from 'officerdb/db'; import { photosAccounts, photosConfig } from './schema'; import { encryptSecret, decryptSecret } from 'officerdb/crypto'; // Immich account registry for the officer-photos sidecar. Callers deal in PLAINTEXT — encryption to and from // at-rest ciphertext happens here. See ../crypto.ts and ../schema/photos.ts. // // Two return types, and the split is the safety property: // PhotosAccount — safe to serialize to the browser. Has NO api key field at all, not even a masked one. // PhotosCredentials — url + decrypted key, for the sidecar's own upstream calls. Never returned by a route. // `accountCols` is what enforces it: a bare `select()` would put the ciphertext column into every list // response the moment someone forgot to strip it. export type PhotosAccount = { id: number; label: string; url: string; version: string | null; isActive: boolean; lastSeenAt: Date | null; createdAt: Date; }; export type PhotosCredentials = { id: number; label: string; url: string; apiKey: string }; const accountCols = { id: photosConfig.id, label: photosConfig.label, url: photosConfig.url, version: photosConfig.version, isActive: photosConfig.isActive, lastSeenAt: photosConfig.lastSeenAt, createdAt: photosConfig.createdAt, }; /** Every account the owner has added, active first then newest. Never includes the API key. */ export async function listPhotosAccounts(userId: number): Promise { return db .select(accountCols) .from(photosConfig) .where(eq(photosConfig.userId, userId)) .orderBy(desc(photosConfig.isActive), desc(photosConfig.createdAt)); } /** The selected account with its key decrypted, or null when none is added. */ export async function getActivePhotosCredentials(userId: number): Promise { const [row] = await db .select() .from(photosConfig) .where(and(eq(photosConfig.userId, userId), eq(photosConfig.isActive, true))); if (!row) return null; return { id: row.id, label: row.label, url: row.url, apiKey: decryptSecret('photos', row.apiKey) }; } /** * The credentials a NON-OWNER reads the library with: the owner's selected instance, and that person's own * API key from their link row. * * `id` and `label` are the REGISTRY row's, so a probe is recorded against the instance it actually reached * and everything downstream treats this as "the connection in use" without caring whose key is inside it. * The only field that differs from the owner's is the one that must — the key. * * Null rather than a fallback in two cases, and both are deliberate: * * - **The instance must be the ACTIVE one.** Accounts are provisioned against one Immich, so a member * linked to an instance the owner has since switched away from is genuinely not provisioned on the one * in use. * - **The key must exist.** Minting needs a password, which is not always ours to set. * * There is deliberately no path from here to the OWNER's key. An Immich key created with `permissions: * ['all']` can read and delete the entire library, so a member resolving to it would not be reading their * own photographs — they would be administering everyone's. */ export async function getLinkedPhotosCredentials(userId: number): Promise { const [row] = await db .select({ id: photosConfig.id, label: photosConfig.label, url: photosConfig.url, apiKey: photosAccounts.apiKey, }) .from(photosAccounts) .innerJoin(photosConfig, eq(photosAccounts.configId, photosConfig.id)) .where( and(eq(photosAccounts.userId, userId), eq(photosConfig.isActive, true), isNotNull(photosAccounts.apiKey)), ); // The isNotNull above already excludes it; this narrows the type and keeps the guarantee local. if (!row?.apiKey) return null; return { id: row.id, label: row.label, url: row.url, apiKey: decryptSecret('photos', row.apiKey) }; } /** One account's credentials by id — for probing a specific account rather than the active one. */ export async function getPhotosCredentials(userId: number, id: number): Promise { const [row] = await db .select() .from(photosConfig) .where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id))); if (!row) return null; return { id: row.id, label: row.label, url: row.url, apiKey: decryptSecret('photos', row.apiKey) }; } type CreatePhotosAccountParams = { userId: number; label: string; url: string; apiKey: string; version: string | null; /** Select it. True for the first account, so the UI is never left with accounts added but none chosen. */ activate: boolean; }; /** Add an account. The key is encrypted before write; the returned row carries no key. */ export async function createPhotosAccount(params: CreatePhotosAccountParams): Promise { const { userId, label, url, apiKey, version, activate } = params; return db.transaction(async (tx) => { if (activate) { await tx .update(photosConfig) .set({ isActive: false, updatedAt: new Date() }) .where(and(eq(photosConfig.userId, userId), eq(photosConfig.isActive, true))); } const [row] = await tx .insert(photosConfig) .values({ userId, label, url, apiKey: encryptSecret('photos', apiKey), version, isActive: activate, lastSeenAt: version ? new Date() : null, }) .returning(accountCols); return row!; }); } type UpdatePhotosAccountParams = { label?: string; url?: string; apiKey?: string; version?: string | null }; /** Edit an account. Omitted fields are left alone; a supplied key is re-encrypted. */ export async function updatePhotosAccount( userId: number, id: number, params: UpdatePhotosAccountParams, ): Promise { const set: Record = { updatedAt: new Date() }; if (params.label !== undefined) set.label = params.label; if (params.url !== undefined) set.url = params.url; if (params.apiKey !== undefined) set.apiKey = encryptSecret('photos', params.apiKey); if (params.version !== undefined) set.version = params.version; const [row] = await db .update(photosConfig) .set(set) .where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id))) .returning(accountCols); return row ?? null; } /** Switch accounts. Clearing the others first keeps the one-active partial index satisfied. */ export async function setActivePhotosAccount(userId: number, id: number): Promise { return db.transaction(async (tx) => { await tx .update(photosConfig) .set({ isActive: false, updatedAt: new Date() }) .where(and(eq(photosConfig.userId, userId), eq(photosConfig.isActive, true))); const [row] = await tx .update(photosConfig) .set({ isActive: true, updatedAt: new Date() }) .where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id))) .returning(accountCols); return row ?? null; }); } /** * Remove an account. If it was the active one the newest survivor is promoted — otherwise removing the * account in use would leave the owner with accounts added but none selected, which reads as "not connected" * and is a confusing place to land. */ export async function deletePhotosAccount(userId: number, id: number): Promise { return db.transaction(async (tx) => { const [deleted] = await tx .delete(photosConfig) .where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id))) .returning({ id: photosConfig.id, wasActive: photosConfig.isActive }); if (!deleted) return false; if (deleted.wasActive) { const [next] = await tx .select({ id: photosConfig.id }) .from(photosConfig) .where(eq(photosConfig.userId, userId)) .orderBy(desc(photosConfig.createdAt)) .limit(1); if (next) { await tx .update(photosConfig) .set({ isActive: true, updatedAt: new Date() }) .where(eq(photosConfig.id, next.id)); } } return true; }); } /** Stamp a successful probe, so the UI can tell "never reached" from "was reachable, now isn't". */ export async function recordPhotosProbe(userId: number, id: number, version: string | null): Promise { await db .update(photosConfig) .set({ version, lastSeenAt: new Date() }) .where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id))); } // ── The Officer ↔ Immich account mapping ── /** * Links already recorded for one instance, for the reconcile to compare against. * * `hasKey` rather than the key itself: callers need to know whether this person can reach their library, * and none of them needs the credential to answer that. The ciphertext does not leave this file. */ export async function listPhotosAccountLinks(configId: number): Promise< { userId: number; immichUserId: string; immichEmail: string; adopted: boolean; hasKey: boolean }[] > { const rows = await db .select({ userId: photosAccounts.userId, immichUserId: photosAccounts.immichUserId, immichEmail: photosAccounts.immichEmail, adopted: photosAccounts.adopted, apiKey: photosAccounts.apiKey, }) .from(photosAccounts) .where(eq(photosAccounts.configId, configId)); return rows.map(({ apiKey, ...rest }) => ({ ...rest, hasKey: !!apiKey })); } /** * Record a pairing. * * `onConflictDoNothing` on BOTH unique indexes, so a re-run is silent rather than an error — a second * reconcile finding the same thing already true is the normal case, not a fault. */ export async function linkPhotosAccount(params: { userId: number; configId: number; immichUserId: string; immichEmail: string; adopted: boolean; }): Promise { await db .insert(photosAccounts) .values({ userId: params.userId, configId: params.configId, immichUserId: params.immichUserId, immichEmail: params.immichEmail, // Mirrors what Immich stores, so matching is a lookup rather than a convention every call site has // to remember. normalizedEmail: params.immichEmail.trim().toLowerCase(), adopted: params.adopted, }) .onConflictDoNothing(); } /** * Store the key minted for one person, encrypted. * * Separate from `linkPhotosAccount` rather than a parameter on it, for the reason the Jellyfin side learned * the hard way: linking is `onConflictDoNothing`, but a key is exactly the thing you re-run provisioning to * REPLACE. An insert that does nothing on conflict cannot express that. */ export async function setPhotosAccountKey(params: { userId: number; configId: number; apiKey: string; }): Promise { await db .update(photosAccounts) .set({ apiKey: encryptSecret('photos', params.apiKey) }) .where(and(eq(photosAccounts.userId, params.userId), eq(photosAccounts.configId, params.configId))); } /** Drop a pairing. Removes Officer's RECORD of the account, never the account itself. */ export async function unlinkPhotosAccount(userId: number, configId: number): Promise { await db .delete(photosAccounts) .where(and(eq(photosAccounts.userId, userId), eq(photosAccounts.configId, configId))); }