Files
photos/db/schema.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

50 lines
2.8 KiB
TypeScript

import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from 'officerdb/auth/schema';
// The Immich accounts behind /photos, for the officer-photos sidecar.
//
// This used to be IMMICH_URL + IMMICH_API_KEY in the platform-wide `.env`, which was wrong twice over: Bun
// auto-loads `.env` into EVERY process started in the platform directory, so `officer` itself held an Immich
// credential it has no code to use — and connecting a photo library was a shell task on the server rather
// than something the owner could do from the app.
//
// It is a REGISTRY, not a single row: the owner adds any number of accounts and switches between them, the
// same shape offscale_servers uses. Two accounts on the same instance is the normal case (one key per
// Immich user), which is why the uniqueness below is on the label and not on the URL.
//
// `api_key` is encrypted at rest via ../crypto.ts. An Immich key can read and delete the entire library, so a
// DB dump must not hand it over. Encryption is confined to queries/photos.ts; nothing outside that file sees
// ciphertext, and no route ever returns the key at all.
export const photosConfig = pgTable(
'photos_config',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** What the owner calls this account. The switcher shows nothing else, so it has to be theirs to set. */
label: text('label').notNull(),
// Normalized without a trailing slash before write, so `${url}/api/...` never doubles the separator.
url: text('url').notNull(),
apiKey: text('api_key').notNull(), // encrypted
/** Immich version seen at the last successful probe — shown in the UI, never used for behaviour. */
version: text('version'),
isActive: boolean('is_active').notNull().default(false),
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
// Labels are how the owner tells two accounts apart — duplicates would make the switcher useless. Not
// unique on url: several keys against one instance is the whole point.
uniqueIndex('uq_photos_config_user_label').on(t.userId, t.label),
// At most one active account per owner, enforced by the DB rather than by convention: a partial unique
// index over the active rows only. setActivePhotosAccount still clears the others in a transaction, but a
// bug there fails loudly here instead of silently leaving two accounts active and the UI picking one.
uniqueIndex('uq_photos_config_one_active')
.on(t.userId)
.where(sql`${t.isActive}`),
],
);