import { eq, and, desc } from 'drizzle-orm'; import { db } from 'officerdb/db'; import { 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) }; } /** 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))); }