import { eq, and, desc } from 'drizzle-orm'; import { db } from 'officerdb/db'; import { jellyfinServers } from './schema'; import { encryptSecret, decryptSecret } from 'officerdb/crypto'; // Jellyfin server registry for the officer-jellyfin sidecar. Callers deal in PLAINTEXT — encryption to and // from at-rest ciphertext happens here. See ../crypto.ts and ../schema/jellyfin.ts. // // Two return types, and the split is the safety property: // JellyfinServer — safe to serialize to the browser. Has NO token field at all, not even masked. // JellyfinCredentials — url + decrypted token + the pinned Jellyfin user, for the sidecar's own calls. // `serverCols` 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 JellyfinServer = { id: number; label: string; url: string; jellyfinUserId: string; jellyfinUsername: string | null; serverName: string | null; version: string | null; isActive: boolean; lastSeenAt: Date | null; createdAt: Date; }; export type JellyfinCredentials = { id: number; label: string; url: string; accessToken: string; jellyfinUserId: string; deviceId: string; }; const serverCols = { id: jellyfinServers.id, label: jellyfinServers.label, url: jellyfinServers.url, jellyfinUserId: jellyfinServers.jellyfinUserId, jellyfinUsername: jellyfinServers.jellyfinUsername, serverName: jellyfinServers.serverName, version: jellyfinServers.version, isActive: jellyfinServers.isActive, lastSeenAt: jellyfinServers.lastSeenAt, createdAt: jellyfinServers.createdAt, }; const toCredentials = (row: typeof jellyfinServers.$inferSelect): JellyfinCredentials => ({ id: row.id, label: row.label, url: row.url, accessToken: decryptSecret('jellyfin', row.accessToken), jellyfinUserId: row.jellyfinUserId, deviceId: row.deviceId, }); /** Every server the owner has added, active first then newest. Never includes the token. */ export async function listJellyfinServers(userId: number): Promise { return db .select(serverCols) .from(jellyfinServers) .where(eq(jellyfinServers.userId, userId)) .orderBy(desc(jellyfinServers.isActive), desc(jellyfinServers.createdAt)); } /** The selected server with its token decrypted, or null when none is added. */ export async function getActiveJellyfinCredentials(userId: number): Promise { const [row] = await db .select() .from(jellyfinServers) .where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.isActive, true))); return row ? toCredentials(row) : null; } /** One server's credentials by id — for probing a specific server rather than the active one. */ export async function getJellyfinCredentials(userId: number, id: number): Promise { const [row] = await db .select() .from(jellyfinServers) .where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id))); return row ? toCredentials(row) : null; } type CreateJellyfinServerParams = { userId: number; label: string; url: string; accessToken: string; jellyfinUserId: string; jellyfinUsername: string | null; deviceId: string; serverName: string | null; version: string | null; /** Select it. True for the first server, so the UI is never left with servers added but none chosen. */ activate: boolean; }; /** Add a server. The token is encrypted before write; the returned row carries no token. */ export async function createJellyfinServer(params: CreateJellyfinServerParams): Promise { const { userId, activate, accessToken, ...rest } = params; return db.transaction(async (tx) => { if (activate) { await tx .update(jellyfinServers) .set({ isActive: false, updatedAt: new Date() }) .where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.isActive, true))); } const [row] = await tx .insert(jellyfinServers) .values({ userId, ...rest, accessToken: encryptSecret('jellyfin', accessToken), isActive: activate, lastSeenAt: rest.version ? new Date() : null, }) .returning(serverCols); return row!; }); } type UpdateJellyfinServerParams = { label?: string; url?: string; accessToken?: string; jellyfinUserId?: string; jellyfinUsername?: string | null; serverName?: string | null; version?: string | null; }; /** Edit a server in place. Omitting `accessToken` keeps the stored one — re-authenticating replaces it. */ export async function updateJellyfinServer( userId: number, id: number, params: UpdateJellyfinServerParams, ): Promise { const { accessToken, ...rest } = params; const [row] = await db .update(jellyfinServers) .set({ ...rest, ...(accessToken ? { accessToken: encryptSecret('jellyfin', accessToken) } : {}), updatedAt: new Date(), }) .where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id))) .returning(serverCols); return row ?? null; } /** Switch the selected server. Clearing the others and setting this one is one transaction, never two. */ export async function setActiveJellyfinServer(userId: number, id: number): Promise { return db.transaction(async (tx) => { await tx .update(jellyfinServers) .set({ isActive: false, updatedAt: new Date() }) .where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.isActive, true))); const [row] = await tx .update(jellyfinServers) .set({ isActive: true, updatedAt: new Date() }) .where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id))) .returning(serverCols); return row ?? null; }); } /** * Remove a server. If it was the selected one, the newest remaining server takes its place — leaving the * owner with servers registered but none active would show the setup form over a working registry. */ export async function deleteJellyfinServer(userId: number, id: number): Promise { return db.transaction(async (tx) => { const [gone] = await tx .delete(jellyfinServers) .where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id))) .returning({ id: jellyfinServers.id, wasActive: jellyfinServers.isActive }); if (!gone) return false; if (gone.wasActive) { const [next] = await tx .select({ id: jellyfinServers.id }) .from(jellyfinServers) .where(eq(jellyfinServers.userId, userId)) .orderBy(desc(jellyfinServers.createdAt)) .limit(1); if (next) { await tx .update(jellyfinServers) .set({ isActive: true, updatedAt: new Date() }) .where(eq(jellyfinServers.id, next.id)); } } return true; }); } /** Stamp a successful probe: what version answered, and when. Failures deliberately leave the row alone. */ export async function recordJellyfinProbe(userId: number, id: number, version: string | null): Promise { await db .update(jellyfinServers) .set({ version, lastSeenAt: new Date(), updatedAt: new Date() }) .where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id))); }