Files
photos/db/queries.ts
T
pastilhasandClaude Opus 5 2dad1907f8 photos, extracted from the platform into its own repository
The fifth plugin out, and the cheapest so far — offscale's shape rather than
music's or vault's. Nothing stayed behind and nothing had to be redesigned,
for three structural reasons recorded in PLUGIN.md:

  • No reverse dependency. Nothing in src/ imported anything of photos'.
  • Configuration was already right. The URL and API key live in photos_config,
    a per-user registry encrypted at rest and driven from the app. Vault had to
    move VAULTWARDEN_URL out of .env and invent a settings panel; here the
    sidecar already read the database, and its own comment already said why
    nothing reads process.env.
  • The frontend was already panels. The screen was a WorkspaceView around
    exactly photos-nav and photos-view, so web/panels.ts and web/layout.ts are
    the screen's contents with the wrapper removed.

The router's prefix is now derived from mountPrefix rather than the literal
'/api/photos', which was correct only by coincidence — a third-party publisher
mounts at /api/p/<publisher>/photos and the literal would forward the wrong
path to the sidecar.

Palette debt paid down as the runbook requires: 6 classes (it says 47; someone
had already done most of it), all of them state colours wearing palette
numbers, now `success` and `warning` from the closed tone set so they follow
the theme.

Immich itself is still provisioned by the app store rather than by this plugin
— the `provisioned` half of its old catalogue entry has no home until Docker
provisioning for plugins exists. Point it at an instance you already run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:10:39 +00:00

178 lines
6.6 KiB
TypeScript

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<PhotosAccount[]> {
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<PhotosCredentials | null> {
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<PhotosCredentials | null> {
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<PhotosAccount> {
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<PhotosAccount | null> {
const set: Record<string, unknown> = { 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<PhotosAccount | null> {
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<boolean> {
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<void> {
await db
.update(photosConfig)
.set({ version, lastSeenAt: new Date() })
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id)));
}