diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 4b603559..0e6df484 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -88,7 +88,9 @@ module.exports = { args: 'run src/servers/sidecar/invoiceshelf/index.ts', watch: false, }, - // The photo library. Wraps a self-hosted Immich and holds its API key; the platform sees none of it. + // The photo library. Wraps a self-hosted Immich. The instance and its key are set by the owner from + // /photos/settings and stored encrypted in `photos_config` — read here, never from the environment, + // because Bun auto-loads `.env` into every process in this directory and `officer` would hold it too. { name: 'officer-photos', script: 'bun', diff --git a/src/databases/officer_db/migrations/0000_crazy_elektra.sql b/src/databases/officer_db/migrations/0000_new_princess_powerful.sql similarity index 65% rename from src/databases/officer_db/migrations/0000_crazy_elektra.sql rename to src/databases/officer_db/migrations/0000_new_princess_powerful.sql index a7e1eca8..8161eaf3 100644 --- a/src/databases/officer_db/migrations/0000_crazy_elektra.sql +++ b/src/databases/officer_db/migrations/0000_new_princess_powerful.sql @@ -37,33 +37,11 @@ CREATE TABLE "users" ( CONSTRAINT "users_username_unique" UNIQUE("username") ); --> statement-breakpoint -CREATE TABLE "dock_configs" ( - "user_id" integer PRIMARY KEY NOT NULL, - "paths" jsonb DEFAULT '[]'::jsonb NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "user_integrations" ( - "id" serial PRIMARY KEY NOT NULL, - "user_id" integer NOT NULL, - "provider" text NOT NULL, - "server_integration_id" integer, - "config" jsonb DEFAULT '{}'::jsonb NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "uq_user_integrations_user_provider" UNIQUE("user_id","provider") -); ---> statement-breakpoint -CREATE TABLE "user_settings" ( - "user_id" integer PRIMARY KEY NOT NULL, - "settings" jsonb DEFAULT '{}'::jsonb NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "user_state" ( - "user_id" integer PRIMARY KEY NOT NULL, - "state" jsonb DEFAULT '{}'::jsonb NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL +CREATE TABLE "chat_session_events" ( + "id" bigserial PRIMARY KEY NOT NULL, + "session_id" text NOT NULL, + "event" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL ); --> statement-breakpoint CREATE TABLE "dashboard_defaults" ( @@ -89,19 +67,6 @@ CREATE TABLE "dashboards" ( CONSTRAINT "uq_dashboards_user_id" UNIQUE("user_id","id") ); --> statement-breakpoint -CREATE TABLE "projects" ( - "id" serial PRIMARY KEY NOT NULL, - "user_id" integer NOT NULL, - "slug" text NOT NULL, - "meta" jsonb DEFAULT '{}'::jsonb NOT NULL, - "layout" jsonb DEFAULT '[]'::jsonb NOT NULL, - "terminals" jsonb DEFAULT '[]'::jsonb NOT NULL, - "host_terminals" jsonb DEFAULT '{}'::jsonb NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "uq_projects_user_slug" UNIQUE("user_id","slug") -); ---> statement-breakpoint CREATE TABLE "screens" ( "id" serial PRIMARY KEY NOT NULL, "user_id" integer NOT NULL, @@ -113,59 +78,6 @@ CREATE TABLE "screens" ( CONSTRAINT "uq_screens_user_name" UNIQUE("user_id","name") ); --> statement-breakpoint -CREATE TABLE "queue_jobs" ( - "id" text PRIMARY KEY NOT NULL, - "user_id" integer NOT NULL, - "lane" text NOT NULL, - "type" text NOT NULL, - "status" text DEFAULT 'queued' NOT NULL, - "current_step" integer DEFAULT 0 NOT NULL, - "steps" jsonb DEFAULT '[]'::jsonb NOT NULL, - "meta" jsonb, - "error" text, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "started_at" timestamp with time zone, - "completed_at" timestamp with time zone -); ---> statement-breakpoint -CREATE TABLE "task_logs" ( - "id" serial PRIMARY KEY NOT NULL, - "user_id" integer NOT NULL, - "task_name" text NOT NULL, - "task_dir_name" text NOT NULL, - "entry_name" text NOT NULL, - "entry_type" text NOT NULL, - "provider" text NOT NULL, - "model" text NOT NULL, - "is_error" boolean DEFAULT false NOT NULL, - "messages" jsonb DEFAULT '[]'::jsonb NOT NULL, - "started_at" timestamp with time zone NOT NULL, - "completed_at" timestamp with time zone -); ---> statement-breakpoint -CREATE TABLE "terminal_containers" ( - "user_id" integer PRIMARY KEY NOT NULL, - "docker_id" text NOT NULL, - "port" integer NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "server_config" ( - "key" text PRIMARY KEY NOT NULL, - "value" jsonb NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "server_integrations" ( - "id" serial PRIMARY KEY NOT NULL, - "provider" text NOT NULL, - "enabled" boolean DEFAULT true NOT NULL, - "config" jsonb DEFAULT '{}'::jsonb NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "server_integrations_provider_unique" UNIQUE("provider") -); ---> statement-breakpoint CREATE TABLE "email_accounts" ( "id" serial PRIMARY KEY NOT NULL, "user_id" integer NOT NULL, @@ -185,30 +97,18 @@ CREATE TABLE "email_accounts" ( CONSTRAINT "uq_email_accounts_user_email" UNIQUE("user_id","email") ); --> statement-breakpoint -CREATE TABLE "pipeline_jobs" ( - "id" text PRIMARY KEY NOT NULL, +CREATE TABLE "headscale_servers" ( + "id" serial PRIMARY KEY NOT NULL, "user_id" integer NOT NULL, - "task_dir_name" text NOT NULL, - "task_name" text NOT NULL, - "mode" text DEFAULT 'pipeline' NOT NULL, - "status" text DEFAULT 'pending' NOT NULL, - "inputs" jsonb DEFAULT '{}'::jsonb NOT NULL, - "cwd" text, - "config" jsonb NOT NULL, - "progress" jsonb, - "total_cost" jsonb, - "error" text, - "exit_code" integer, + "name" text NOT NULL, + "url" text NOT NULL, + "api_key" text NOT NULL, + "version" text, + "is_active" boolean DEFAULT false NOT NULL, + "last_seen_at" timestamp with time zone, "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "started_at" timestamp with time zone, - "completed_at" timestamp with time zone -); ---> statement-breakpoint -CREATE TABLE "chat_session_events" ( - "id" bigserial PRIMARY KEY NOT NULL, - "session_id" text NOT NULL, - "event" jsonb NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "uq_headscale_servers_user_url" UNIQUE("user_id","url") ); --> statement-breakpoint CREATE TABLE "music_favorites" ( @@ -251,6 +151,173 @@ CREATE TABLE "music_playlists" ( CONSTRAINT "uq_music_playlists_user_name" UNIQUE("user_id","name") ); --> statement-breakpoint +CREATE TABLE "push_devices" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "token" text NOT NULL, + "platform" text NOT NULL, + "environment" text DEFAULT 'production' NOT NULL, + "bundle_id" text NOT NULL, + "app_slug" text NOT NULL, + "failure_count" integer DEFAULT 0 NOT NULL, + "last_seen_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "uq_push_devices_token_bundle" UNIQUE("token","bundle_id"), + CONSTRAINT "ck_push_devices_platform" CHECK ("push_devices"."platform" IN ('ios', 'android')), + CONSTRAINT "ck_push_devices_environment" CHECK ("push_devices"."environment" IN ('production', 'sandbox')) +); +--> statement-breakpoint +CREATE TABLE "queue_jobs" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "lane" text NOT NULL, + "type" text NOT NULL, + "status" text DEFAULT 'queued' NOT NULL, + "current_step" integer DEFAULT 0 NOT NULL, + "steps" jsonb DEFAULT '[]'::jsonb NOT NULL, + "meta" jsonb, + "error" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "started_at" timestamp with time zone, + "completed_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "task_logs" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "task_name" text NOT NULL, + "task_dir_name" text NOT NULL, + "entry_name" text NOT NULL, + "entry_type" text NOT NULL, + "provider" text NOT NULL, + "model" text NOT NULL, + "is_error" boolean DEFAULT false NOT NULL, + "messages" jsonb DEFAULT '[]'::jsonb NOT NULL, + "started_at" timestamp with time zone NOT NULL, + "completed_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "terminal_containers" ( + "user_id" integer PRIMARY KEY NOT NULL, + "docker_id" text NOT NULL, + "port" integer NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "photos_config" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "url" text NOT NULL, + "api_key" text NOT NULL, + "version" text, + "last_seen_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "pipeline_jobs" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "task_dir_name" text NOT NULL, + "task_name" text NOT NULL, + "mode" text DEFAULT 'pipeline' NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "inputs" jsonb DEFAULT '{}'::jsonb NOT NULL, + "cwd" text, + "config" jsonb NOT NULL, + "progress" jsonb, + "total_cost" jsonb, + "error" text, + "exit_code" integer, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "started_at" timestamp with time zone, + "completed_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "server_config" ( + "key" text PRIMARY KEY NOT NULL, + "value" jsonb NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "server_integrations" ( + "id" serial PRIMARY KEY NOT NULL, + "provider" text NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "config" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "server_integrations_provider_unique" UNIQUE("provider"), + CONSTRAINT "ck_server_integrations_provider" CHECK ("server_integrations"."provider" IN ('google', 'apify')) +); +--> statement-breakpoint +CREATE TABLE "soulseek_browse_dirs" ( + "id" serial PRIMARY KEY NOT NULL, + "snapshot_id" integer NOT NULL, + "name" text NOT NULL, + "parent_path" text, + "depth" integer DEFAULT 1 NOT NULL, + "label" text DEFAULT '' NOT NULL, + "child_count" integer DEFAULT 0 NOT NULL, + "file_count" integer DEFAULT 0 NOT NULL, + "total_size" bigint DEFAULT 0 NOT NULL, + "subtree_file_count" integer DEFAULT 0 NOT NULL, + "subtree_size" bigint DEFAULT 0 NOT NULL, + "files" jsonb DEFAULT '[]'::jsonb NOT NULL +); +--> statement-breakpoint +CREATE TABLE "soulseek_browse_snapshots" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "username" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "error" text, + "directory_count" integer DEFAULT 0 NOT NULL, + "file_count" integer DEFAULT 0 NOT NULL, + "total_size" bigint DEFAULT 0 NOT NULL, + "started_at" timestamp with time zone DEFAULT now() NOT NULL, + "completed_at" timestamp with time zone, + CONSTRAINT "uq_soulseek_browse_snapshots_user_username" UNIQUE("user_id","username") +); +--> statement-breakpoint +CREATE TABLE "soulseek_favorites" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "username" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "uq_soulseek_favorites_user_username" UNIQUE("user_id","username") +); +--> statement-breakpoint +CREATE TABLE "dock_configs" ( + "user_id" integer PRIMARY KEY NOT NULL, + "paths" jsonb DEFAULT '[]'::jsonb NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user_integrations" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "provider" text NOT NULL, + "server_integration_id" integer, + "config" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "uq_user_integrations_user_provider" UNIQUE("user_id","provider"), + CONSTRAINT "ck_user_integrations_provider" CHECK ("user_integrations"."provider" IN ('google', 'browser-relay')) +); +--> statement-breakpoint +CREATE TABLE "user_settings" ( + "user_id" integer PRIMARY KEY NOT NULL, + "settings" jsonb DEFAULT '{}'::jsonb NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user_state" ( + "user_id" integer PRIMARY KEY NOT NULL, + "state" jsonb DEFAULT '{}'::jsonb NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint CREATE TABLE "vault_tokens" ( "user_id" integer PRIMARY KEY NOT NULL, "access_token" text NOT NULL, @@ -267,34 +334,93 @@ CREATE TABLE "vault_unlock_keys" ( "updated_at" timestamp with time zone DEFAULT now() NOT NULL ); --> statement-breakpoint +CREATE TABLE "wallet_chain_cache" ( + "wallet_id" integer PRIMARY KEY NOT NULL, + "snapshot" jsonb, + "synced_at" timestamp with time zone, + "last_error" text, + "last_error_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "wallet_frozen_utxos" ( + "id" serial PRIMARY KEY NOT NULL, + "wallet_id" integer NOT NULL, + "outpoint" text NOT NULL, + "reason" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "uq_wallet_frozen_utxos_wallet_outpoint" UNIQUE("wallet_id","outpoint") +); +--> statement-breakpoint +CREATE TABLE "wallet_labels" ( + "id" serial PRIMARY KEY NOT NULL, + "wallet_id" integer NOT NULL, + "kind" text NOT NULL, + "ref" text NOT NULL, + "label" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "uq_wallet_labels_wallet_kind_ref" UNIQUE("wallet_id","kind","ref") +); +--> statement-breakpoint +CREATE TABLE "wallet_wallets" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "name" text NOT NULL, + "kind" text NOT NULL, + "network" text DEFAULT 'bitcoin' NOT NULL, + "config" text, + "seed_envelope" text, + "fingerprint" text, + "xpubs" jsonb, + "default_bip" integer DEFAULT 84 NOT NULL, + "is_active" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "uq_wallet_wallets_user_name" UNIQUE("user_id","name") +); +--> statement-breakpoint ALTER TABLE "passkey_challenges" ADD CONSTRAINT "passkey_challenges_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "passkeys" ADD CONSTRAINT "passkeys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "dashboard_defaults" ADD CONSTRAINT "dashboard_defaults_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "dashboards" ADD CONSTRAINT "dashboards_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "screens" ADD CONSTRAINT "screens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "email_accounts" ADD CONSTRAINT "email_accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "headscale_servers" ADD CONSTRAINT "headscale_servers_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "music_favorites" ADD CONSTRAINT "music_favorites_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "music_now_playing" ADD CONSTRAINT "music_now_playing_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "music_playlist_items" ADD CONSTRAINT "music_playlist_items_playlist_id_music_playlists_id_fk" FOREIGN KEY ("playlist_id") REFERENCES "public"."music_playlists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "music_playlists" ADD CONSTRAINT "music_playlists_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "push_devices" ADD CONSTRAINT "push_devices_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "queue_jobs" ADD CONSTRAINT "queue_jobs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "task_logs" ADD CONSTRAINT "task_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "terminal_containers" ADD CONSTRAINT "terminal_containers_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "photos_config" ADD CONSTRAINT "photos_config_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "pipeline_jobs" ADD CONSTRAINT "pipeline_jobs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "soulseek_browse_dirs" ADD CONSTRAINT "soulseek_browse_dirs_snapshot_id_fk" FOREIGN KEY ("snapshot_id") REFERENCES "public"."soulseek_browse_snapshots"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "soulseek_browse_snapshots" ADD CONSTRAINT "soulseek_browse_snapshots_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "soulseek_favorites" ADD CONSTRAINT "soulseek_favorites_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "dock_configs" ADD CONSTRAINT "dock_configs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "user_integrations" ADD CONSTRAINT "user_integrations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "user_integrations" ADD CONSTRAINT "user_integrations_server_integration_id_server_integrations_id_fk" FOREIGN KEY ("server_integration_id") REFERENCES "public"."server_integrations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint ALTER TABLE "user_settings" ADD CONSTRAINT "user_settings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "user_state" ADD CONSTRAINT "user_state_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "dashboard_defaults" ADD CONSTRAINT "dashboard_defaults_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "dashboards" ADD CONSTRAINT "dashboards_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "projects" ADD CONSTRAINT "projects_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "screens" ADD CONSTRAINT "screens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "queue_jobs" ADD CONSTRAINT "queue_jobs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "task_logs" ADD CONSTRAINT "task_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "terminal_containers" ADD CONSTRAINT "terminal_containers_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "email_accounts" ADD CONSTRAINT "email_accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "pipeline_jobs" ADD CONSTRAINT "pipeline_jobs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "music_favorites" ADD CONSTRAINT "music_favorites_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "music_now_playing" ADD CONSTRAINT "music_now_playing_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "music_playlist_items" ADD CONSTRAINT "music_playlist_items_playlist_id_music_playlists_id_fk" FOREIGN KEY ("playlist_id") REFERENCES "public"."music_playlists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "music_playlists" ADD CONSTRAINT "music_playlists_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "vault_tokens" ADD CONSTRAINT "vault_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "vault_unlock_keys" ADD CONSTRAINT "vault_unlock_keys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "wallet_chain_cache" ADD CONSTRAINT "wallet_chain_cache_wallet_id_wallet_wallets_id_fk" FOREIGN KEY ("wallet_id") REFERENCES "public"."wallet_wallets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "wallet_frozen_utxos" ADD CONSTRAINT "wallet_frozen_utxos_wallet_id_wallet_wallets_id_fk" FOREIGN KEY ("wallet_id") REFERENCES "public"."wallet_wallets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "wallet_labels" ADD CONSTRAINT "wallet_labels_wallet_id_wallet_wallets_id_fk" FOREIGN KEY ("wallet_id") REFERENCES "public"."wallet_wallets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "wallet_wallets" ADD CONSTRAINT "wallet_wallets_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint CREATE INDEX "idx_token_blacklist_expires" ON "token_blacklist" USING btree ("expires_at");--> statement-breakpoint +CREATE INDEX "idx_chat_session_events_session_id" ON "chat_session_events" USING btree ("session_id","id");--> statement-breakpoint +CREATE UNIQUE INDEX "uq_headscale_servers_one_active" ON "headscale_servers" USING btree ("user_id") WHERE "headscale_servers"."is_active";--> statement-breakpoint +CREATE INDEX "idx_music_favorites_user_kind" ON "music_favorites" USING btree ("user_id","kind");--> statement-breakpoint +CREATE INDEX "idx_music_playlist_items_playlist" ON "music_playlist_items" USING btree ("playlist_id","position");--> statement-breakpoint +CREATE INDEX "idx_push_devices_user" ON "push_devices" USING btree ("user_id");--> statement-breakpoint CREATE INDEX "idx_queue_jobs_status_lane" ON "queue_jobs" USING btree ("status","lane");--> statement-breakpoint CREATE INDEX "idx_queue_jobs_user" ON "queue_jobs" USING btree ("user_id");--> statement-breakpoint CREATE INDEX "idx_task_logs_user_started" ON "task_logs" USING btree ("user_id","started_at");--> statement-breakpoint +CREATE UNIQUE INDEX "photos_config_user_idx" ON "photos_config" USING btree ("user_id");--> statement-breakpoint CREATE INDEX "idx_pipeline_jobs_user_created" ON "pipeline_jobs" USING btree ("user_id","created_at");--> statement-breakpoint CREATE INDEX "idx_pipeline_jobs_status" ON "pipeline_jobs" USING btree ("status");--> statement-breakpoint -CREATE INDEX "idx_chat_session_events_session_id" ON "chat_session_events" USING btree ("session_id","id");--> statement-breakpoint -CREATE INDEX "idx_music_favorites_user_kind" ON "music_favorites" USING btree ("user_id","kind");--> statement-breakpoint -CREATE INDEX "idx_music_playlist_items_playlist" ON "music_playlist_items" USING btree ("playlist_id","position"); \ No newline at end of file +CREATE INDEX "idx_soulseek_browse_dirs_snapshot_name" ON "soulseek_browse_dirs" USING btree ("snapshot_id","name");--> statement-breakpoint +CREATE INDEX "idx_soulseek_browse_dirs_snapshot_parent" ON "soulseek_browse_dirs" USING btree ("snapshot_id","parent_path","name");--> statement-breakpoint +CREATE UNIQUE INDEX "uq_wallet_wallets_one_active" ON "wallet_wallets" USING btree ("user_id") WHERE "wallet_wallets"."is_active"; \ No newline at end of file diff --git a/src/databases/officer_db/migrations/meta/0000_snapshot.json b/src/databases/officer_db/migrations/meta/0000_snapshot.json index ed03434e..6aa8f68a 100644 --- a/src/databases/officer_db/migrations/meta/0000_snapshot.json +++ b/src/databases/officer_db/migrations/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "4bd8f78b-a206-4605-a676-6bb120a1c8ec", + "id": "5de8ee68-0cd3-4e94-a2f7-7b110a88821f", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -271,87 +271,27 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.dock_configs": { - "name": "dock_configs", - "schema": "", - "columns": { - "user_id": { - "name": "user_id", - "type": "integer", - "primaryKey": true, - "notNull": true - }, - "paths": { - "name": "paths", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'[]'::jsonb" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "dock_configs_user_id_users_id_fk": { - "name": "dock_configs_user_id_users_id_fk", - "tableFrom": "dock_configs", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user_integrations": { - "name": "user_integrations", + "public.chat_session_events": { + "name": "chat_session_events", "schema": "", "columns": { "id": { "name": "id", - "type": "serial", + "type": "bigserial", "primaryKey": true, "notNull": true }, - "user_id": { - "name": "user_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", + "session_id": { + "name": "session_id", "type": "text", "primaryKey": false, "notNull": true }, - "server_integration_id": { - "name": "server_integration_id", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "config": { - "name": "config", + "event": { + "name": "event", "type": "jsonb", "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" + "notNull": true }, "created_at": { "name": "created_at", @@ -359,147 +299,32 @@ "primaryKey": false, "notNull": true, "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" } }, - "indexes": {}, - "foreignKeys": { - "user_integrations_user_id_users_id_fk": { - "name": "user_integrations_user_id_users_id_fk", - "tableFrom": "user_integrations", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "user_integrations_server_integration_id_server_integrations_id_fk": { - "name": "user_integrations_server_integration_id_server_integrations_id_fk", - "tableFrom": "user_integrations", - "tableTo": "server_integrations", - "columnsFrom": [ - "server_integration_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "uq_user_integrations_user_provider": { - "name": "uq_user_integrations_user_provider", - "nullsNotDistinct": false, + "indexes": { + "idx_chat_session_events_session_id": { + "name": "idx_chat_session_events_session_id", "columns": [ - "user_id", - "provider" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user_settings": { - "name": "user_settings", - "schema": "", - "columns": { - "user_id": { - "name": "user_id", - "type": "integer", - "primaryKey": true, - "notNull": true - }, - "settings": { - "name": "settings", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "user_settings_user_id_users_id_fk": { - "name": "user_settings_user_id_users_id_fk", - "tableFrom": "user_settings", - "tableTo": "users", - "columnsFrom": [ - "user_id" + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user_state": { - "name": "user_state", - "schema": "", - "columns": { - "user_id": { - "name": "user_id", - "type": "integer", - "primaryKey": true, - "notNull": true - }, - "state": { - "name": "state", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "user_state_user_id_users_id_fk": { - "name": "user_state_user_id_users_id_fk", - "tableFrom": "user_state", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} } }, + "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, @@ -677,102 +502,6 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.projects": { - "name": "projects", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "slug": { - "name": "slug", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "meta": { - "name": "meta", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "layout": { - "name": "layout", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'[]'::jsonb" - }, - "terminals": { - "name": "terminals", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'[]'::jsonb" - }, - "host_terminals": { - "name": "host_terminals", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "projects_user_id_users_id_fk": { - "name": "projects_user_id_users_id_fk", - "tableFrom": "projects", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "uq_projects_user_slug": { - "name": "uq_projects_user_slug", - "nullsNotDistinct": false, - "columns": [ - "user_id", - "slug" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, "public.screens": { "name": "screens", "schema": "", @@ -855,414 +584,6 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.queue_jobs": { - "name": "queue_jobs", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "lane": { - "name": "lane", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'queued'" - }, - "current_step": { - "name": "current_step", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "steps": { - "name": "steps", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'[]'::jsonb" - }, - "meta": { - "name": "meta", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "started_at": { - "name": "started_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "completed_at": { - "name": "completed_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "idx_queue_jobs_status_lane": { - "name": "idx_queue_jobs_status_lane", - "columns": [ - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "lane", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_queue_jobs_user": { - "name": "idx_queue_jobs_user", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "queue_jobs_user_id_users_id_fk": { - "name": "queue_jobs_user_id_users_id_fk", - "tableFrom": "queue_jobs", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.task_logs": { - "name": "task_logs", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "task_name": { - "name": "task_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "task_dir_name": { - "name": "task_dir_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "entry_name": { - "name": "entry_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "entry_type": { - "name": "entry_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "model": { - "name": "model", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "is_error": { - "name": "is_error", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "messages": { - "name": "messages", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'[]'::jsonb" - }, - "started_at": { - "name": "started_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "completed_at": { - "name": "completed_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "idx_task_logs_user_started": { - "name": "idx_task_logs_user_started", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "started_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "task_logs_user_id_users_id_fk": { - "name": "task_logs_user_id_users_id_fk", - "tableFrom": "task_logs", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.terminal_containers": { - "name": "terminal_containers", - "schema": "", - "columns": { - "user_id": { - "name": "user_id", - "type": "integer", - "primaryKey": true, - "notNull": true - }, - "docker_id": { - "name": "docker_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "port": { - "name": "port", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "terminal_containers_user_id_users_id_fk": { - "name": "terminal_containers_user_id_users_id_fk", - "tableFrom": "terminal_containers", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.server_config": { - "name": "server_config", - "schema": "", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "value": { - "name": "value", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.server_integrations": { - "name": "server_integrations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "enabled": { - "name": "enabled", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "config": { - "name": "config", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "server_integrations_provider_unique": { - "name": "server_integrations_provider_unique", - "nullsNotDistinct": false, - "columns": [ - "provider" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, "public.email_accounts": { "name": "email_accounts", "schema": "", @@ -1396,13 +717,13 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.pipeline_jobs": { - "name": "pipeline_jobs", + "public.headscale_servers": { + "name": "headscale_servers", "schema": "", "columns": { "id": { "name": "id", - "type": "text", + "type": "serial", "primaryKey": true, "notNull": true }, @@ -1412,72 +733,40 @@ "primaryKey": false, "notNull": true }, - "task_dir_name": { - "name": "task_dir_name", + "name": { + "name": "name", "type": "text", "primaryKey": false, "notNull": true }, - "task_name": { - "name": "task_name", + "url": { + "name": "url", "type": "text", "primaryKey": false, "notNull": true }, - "mode": { - "name": "mode", + "api_key": { + "name": "api_key", "type": "text", "primaryKey": false, - "notNull": true, - "default": "'pipeline'" - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "inputs": { - "name": "inputs", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "cwd": { - "name": "cwd", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "config": { - "name": "config", - "type": "jsonb", - "primaryKey": false, "notNull": true }, - "progress": { - "name": "progress", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "total_cost": { - "name": "total_cost", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "error": { - "name": "error", + "version": { + "name": "version", "type": "text", "primaryKey": false, "notNull": false }, - "exit_code": { - "name": "exit_code", - "type": "integer", + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, @@ -1488,61 +777,36 @@ "notNull": true, "default": "now()" }, - "started_at": { - "name": "started_at", + "updated_at": { + "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false - }, - "completed_at": { - "name": "completed_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false + "notNull": true, + "default": "now()" } }, "indexes": { - "idx_pipeline_jobs_user_created": { - "name": "idx_pipeline_jobs_user_created", + "uq_headscale_servers_one_active": { + "name": "uq_headscale_servers_one_active", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" } ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_pipeline_jobs_status": { - "name": "idx_pipeline_jobs_status", - "columns": [ - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, + "isUnique": true, + "where": "\"headscale_servers\".\"is_active\"", "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "pipeline_jobs_user_id_users_id_fk": { - "name": "pipeline_jobs_user_id_users_id_fk", - "tableFrom": "pipeline_jobs", + "headscale_servers_user_id_users_id_fk": { + "name": "headscale_servers_user_id_users_id_fk", + "tableFrom": "headscale_servers", "tableTo": "users", "columnsFrom": [ "user_id" @@ -1555,67 +819,16 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.chat_session_events": { - "name": "chat_session_events", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "session_id": { - "name": "session_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "event": { - "name": "event", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "idx_chat_session_events_session_id": { - "name": "idx_chat_session_events_session_id", + "uniqueConstraints": { + "uq_headscale_servers_user_url": { + "name": "uq_headscale_servers_user_url", + "nullsNotDistinct": false, "columns": [ - { - "expression": "session_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "user_id", + "url" + ] } }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false @@ -1961,6 +1174,1359 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.push_devices": { + "name": "push_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'production'" + }, + "bundle_id": { + "name": "bundle_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_slug": { + "name": "app_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "failure_count": { + "name": "failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_push_devices_user": { + "name": "idx_push_devices_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_devices_user_id_users_id_fk": { + "name": "push_devices_user_id_users_id_fk", + "tableFrom": "push_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_push_devices_token_bundle": { + "name": "uq_push_devices_token_bundle", + "nullsNotDistinct": false, + "columns": [ + "token", + "bundle_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "ck_push_devices_platform": { + "name": "ck_push_devices_platform", + "value": "\"push_devices\".\"platform\" IN ('ios', 'android')" + }, + "ck_push_devices_environment": { + "name": "ck_push_devices_environment", + "value": "\"push_devices\".\"environment\" IN ('production', 'sandbox')" + } + }, + "isRLSEnabled": false + }, + "public.queue_jobs": { + "name": "queue_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lane": { + "name": "lane", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "current_step": { + "name": "current_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "steps": { + "name": "steps", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_queue_jobs_status_lane": { + "name": "idx_queue_jobs_status_lane", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lane", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_queue_jobs_user": { + "name": "idx_queue_jobs_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "queue_jobs_user_id_users_id_fk": { + "name": "queue_jobs_user_id_users_id_fk", + "tableFrom": "queue_jobs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_logs": { + "name": "task_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_name": { + "name": "task_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_dir_name": { + "name": "task_dir_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entry_name": { + "name": "entry_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entry_type": { + "name": "entry_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_error": { + "name": "is_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_task_logs_user_started": { + "name": "idx_task_logs_user_started", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_logs_user_id_users_id_fk": { + "name": "task_logs_user_id_users_id_fk", + "tableFrom": "task_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.terminal_containers": { + "name": "terminal_containers", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "docker_id": { + "name": "docker_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "terminal_containers_user_id_users_id_fk": { + "name": "terminal_containers_user_id_users_id_fk", + "tableFrom": "terminal_containers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.photos_config": { + "name": "photos_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "photos_config_user_idx": { + "name": "photos_config_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "photos_config_user_id_users_id_fk": { + "name": "photos_config_user_id_users_id_fk", + "tableFrom": "photos_config", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_jobs": { + "name": "pipeline_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_dir_name": { + "name": "task_dir_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_name": { + "name": "task_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pipeline'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "inputs": { + "name": "inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "total_cost": { + "name": "total_cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_pipeline_jobs_user_created": { + "name": "idx_pipeline_jobs_user_created", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_pipeline_jobs_status": { + "name": "idx_pipeline_jobs_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_jobs_user_id_users_id_fk": { + "name": "pipeline_jobs_user_id_users_id_fk", + "tableFrom": "pipeline_jobs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_config": { + "name": "server_config", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_integrations": { + "name": "server_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "server_integrations_provider_unique": { + "name": "server_integrations_provider_unique", + "nullsNotDistinct": false, + "columns": [ + "provider" + ] + } + }, + "policies": {}, + "checkConstraints": { + "ck_server_integrations_provider": { + "name": "ck_server_integrations_provider", + "value": "\"server_integrations\".\"provider\" IN ('google', 'apify')" + } + }, + "isRLSEnabled": false + }, + "public.soulseek_browse_dirs": { + "name": "soulseek_browse_dirs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_path": { + "name": "parent_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "depth": { + "name": "depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "child_count": { + "name": "child_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_size": { + "name": "total_size", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "subtree_file_count": { + "name": "subtree_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "subtree_size": { + "name": "subtree_size", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "idx_soulseek_browse_dirs_snapshot_name": { + "name": "idx_soulseek_browse_dirs_snapshot_name", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_soulseek_browse_dirs_snapshot_parent": { + "name": "idx_soulseek_browse_dirs_snapshot_parent", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "soulseek_browse_dirs_snapshot_id_fk": { + "name": "soulseek_browse_dirs_snapshot_id_fk", + "tableFrom": "soulseek_browse_dirs", + "tableTo": "soulseek_browse_snapshots", + "columnsFrom": [ + "snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.soulseek_browse_snapshots": { + "name": "soulseek_browse_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "directory_count": { + "name": "directory_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_size": { + "name": "total_size", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "soulseek_browse_snapshots_user_id_users_id_fk": { + "name": "soulseek_browse_snapshots_user_id_users_id_fk", + "tableFrom": "soulseek_browse_snapshots", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_soulseek_browse_snapshots_user_username": { + "name": "uq_soulseek_browse_snapshots_user_username", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.soulseek_favorites": { + "name": "soulseek_favorites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "soulseek_favorites_user_id_users_id_fk": { + "name": "soulseek_favorites_user_id_users_id_fk", + "tableFrom": "soulseek_favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_soulseek_favorites_user_username": { + "name": "uq_soulseek_favorites_user_username", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dock_configs": { + "name": "dock_configs", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "paths": { + "name": "paths", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "dock_configs_user_id_users_id_fk": { + "name": "dock_configs_user_id_users_id_fk", + "tableFrom": "dock_configs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_integrations": { + "name": "user_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_integration_id": { + "name": "server_integration_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_integrations_user_id_users_id_fk": { + "name": "user_integrations_user_id_users_id_fk", + "tableFrom": "user_integrations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_integrations_server_integration_id_server_integrations_id_fk": { + "name": "user_integrations_server_integration_id_server_integrations_id_fk", + "tableFrom": "user_integrations", + "tableTo": "server_integrations", + "columnsFrom": [ + "server_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_user_integrations_user_provider": { + "name": "uq_user_integrations_user_provider", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "provider" + ] + } + }, + "policies": {}, + "checkConstraints": { + "ck_user_integrations_provider": { + "name": "ck_user_integrations_provider", + "value": "\"user_integrations\".\"provider\" IN ('google', 'browser-relay')" + } + }, + "isRLSEnabled": false + }, + "public.user_settings": { + "name": "user_settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_settings_user_id_users_id_fk": { + "name": "user_settings_user_id_users_id_fk", + "tableFrom": "user_settings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_state": { + "name": "user_state", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_state_user_id_users_id_fk": { + "name": "user_state_user_id_users_id_fk", + "tableFrom": "user_state", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.vault_tokens": { "name": "vault_tokens", "schema": "", @@ -2076,6 +2642,340 @@ "policies": {}, "checkConstraints": {}, "isRLSEnabled": false + }, + "public.wallet_chain_cache": { + "name": "wallet_chain_cache", + "schema": "", + "columns": { + "wallet_id": { + "name": "wallet_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "wallet_chain_cache_wallet_id_wallet_wallets_id_fk": { + "name": "wallet_chain_cache_wallet_id_wallet_wallets_id_fk", + "tableFrom": "wallet_chain_cache", + "tableTo": "wallet_wallets", + "columnsFrom": [ + "wallet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_frozen_utxos": { + "name": "wallet_frozen_utxos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "wallet_id": { + "name": "wallet_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "outpoint": { + "name": "outpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "wallet_frozen_utxos_wallet_id_wallet_wallets_id_fk": { + "name": "wallet_frozen_utxos_wallet_id_wallet_wallets_id_fk", + "tableFrom": "wallet_frozen_utxos", + "tableTo": "wallet_wallets", + "columnsFrom": [ + "wallet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_wallet_frozen_utxos_wallet_outpoint": { + "name": "uq_wallet_frozen_utxos_wallet_outpoint", + "nullsNotDistinct": false, + "columns": [ + "wallet_id", + "outpoint" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_labels": { + "name": "wallet_labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "wallet_id": { + "name": "wallet_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "wallet_labels_wallet_id_wallet_wallets_id_fk": { + "name": "wallet_labels_wallet_id_wallet_wallets_id_fk", + "tableFrom": "wallet_labels", + "tableTo": "wallet_wallets", + "columnsFrom": [ + "wallet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_wallet_labels_wallet_kind_ref": { + "name": "uq_wallet_labels_wallet_kind_ref", + "nullsNotDistinct": false, + "columns": [ + "wallet_id", + "kind", + "ref" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_wallets": { + "name": "wallet_wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'bitcoin'" + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "seed_envelope": { + "name": "seed_envelope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "xpubs": { + "name": "xpubs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "default_bip": { + "name": "default_bip", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 84 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_wallet_wallets_one_active": { + "name": "uq_wallet_wallets_one_active", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_wallets\".\"is_active\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_wallets_user_id_users_id_fk": { + "name": "wallet_wallets_user_id_users_id_fk", + "tableFrom": "wallet_wallets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_wallet_wallets_user_name": { + "name": "uq_wallet_wallets_user_name", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false } }, "enums": {}, diff --git a/src/databases/officer_db/migrations/meta/_journal.json b/src/databases/officer_db/migrations/meta/_journal.json index 1ef246b6..2fb34a3c 100644 --- a/src/databases/officer_db/migrations/meta/_journal.json +++ b/src/databases/officer_db/migrations/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "7", - "when": 1785338790645, - "tag": "0000_crazy_elektra", + "when": 1785771021535, + "tag": "0000_new_princess_powerful", "breakpoints": true } ] diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 46102e6a..72d969f8 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -141,6 +141,17 @@ export { recordHeadscaleProbe, } from './queries/headscale'; export type { HeadscaleServer, HeadscaleServerCredentials } from './queries/headscale'; +export { + listPhotosAccounts, + getActivePhotosCredentials, + getPhotosCredentials, + createPhotosAccount, + updatePhotosAccount, + setActivePhotosAccount, + deletePhotosAccount, + recordPhotosProbe, +} from './queries/photos'; +export type { PhotosAccount, PhotosCredentials } from './queries/photos'; export { getVaultTokens, setVaultTokens, diff --git a/src/databases/officer_db/src/queries/photos.ts b/src/databases/officer_db/src/queries/photos.ts new file mode 100644 index 00000000..485edce7 --- /dev/null +++ b/src/databases/officer_db/src/queries/photos.ts @@ -0,0 +1,174 @@ +import { eq, and, desc } from 'drizzle-orm'; +import { db } from '../db'; +import { photosConfig } from '../schema'; +import { encryptSecret, decryptSecret } from '../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(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(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(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(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))); +} diff --git a/src/databases/officer_db/src/schema/index.ts b/src/databases/officer_db/src/schema/index.ts index 20e79d81..1d2fb928 100644 --- a/src/databases/officer_db/src/schema/index.ts +++ b/src/databases/officer_db/src/schema/index.ts @@ -6,6 +6,7 @@ export * from './headscale'; export * from './music'; export * from './notify'; export * from './operations'; +export * from './photos'; export * from './pipeline-jobs'; export * from './server'; export * from './soulseek'; diff --git a/src/databases/officer_db/src/schema/photos.ts b/src/databases/officer_db/src/schema/photos.ts new file mode 100644 index 00000000..7cf6b888 --- /dev/null +++ b/src/databases/officer_db/src/schema/photos.ts @@ -0,0 +1,49 @@ +import { pgTable, serial, integer, text, boolean, timestamp, unique, uniqueIndex } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { users } from './auth'; + +// 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 headscale_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. + unique('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}`), + ], +); diff --git a/src/servers/sidecar/photos/config.ts b/src/servers/sidecar/photos/config.ts new file mode 100644 index 00000000..b80cb7cd --- /dev/null +++ b/src/servers/sidecar/photos/config.ts @@ -0,0 +1,195 @@ +import type { UpstreamTarget } from './upstream'; +import { + createPhotosAccount, + deletePhotosAccount, + getPhotosCredentials, + listPhotosAccounts, + recordPhotosProbe, + setActivePhotosAccount, + updatePhotosAccount, +} from 'officerdb'; +import { callUpstream, invalidateConfig, normalizeBase } from './upstream'; + +// `/_config` — the Immich account registry, driven from the app rather than from a shell on the server. +// +// The API key is WRITE-ONLY across this boundary. The list reports each account's label, URL and whether it +// is selected; it has no field that could carry a key, masked or otherwise. The only way to change one is to +// send a new one, which is the same shape headscale's server registry uses. +// +// A save is validated before it is stored: an Immich key that is missing, wrong or under-scoped is a 400 +// with the reason, not a saved row that makes every later screen fail mysteriously. That matters more here +// than usual — Immich keys are SCOPED, and a key created without the right permissions returns 403 on +// individual routes while looking perfectly valid on the ones it does cover. +// +// Adding, editing, switching and removing all invalidate the upstream cache. Forgetting one would leave the +// sidecar serving the previous account's photos for up to a minute, which reads as a caching bug in the grid +// rather than as what it is. + +export type ProbeResult = + | { ok: true; version: string | null; user: string | null } + | { ok: false; version: string | null; error: string }; + +/** + * Ask an instance whether it is really there and whether the key works. + * + * Two calls, because they answer different questions: `/server/version` is unauthenticated, so a failure + * there means the URL is wrong or Immich is down, while `/users/me` failing after it succeeded means the + * key is the problem. Collapsing them would report "instance unreachable" for a mistyped key. + */ +export async function probe(cfg: UpstreamTarget): Promise { + let version: string | null = null; + + try { + const versionRes = await callUpstream(cfg, { path: '/api/server/version', withKey: false }); + if (!versionRes.ok) return { ok: false, version: null, error: `instance returned ${versionRes.status}` }; + + const v = (await versionRes.json()) as { major?: number; minor?: number; patch?: number }; + if ([v.major, v.minor, v.patch].every((n) => typeof n === 'number')) { + version = `${v.major}.${v.minor}.${v.patch}`; + } + } catch (err) { + return { ok: false, version: null, error: `could not reach the instance (${String(err)})` }; + } + + try { + const meRes = await callUpstream(cfg, { path: '/api/users/me' }); + if (!meRes.ok) { + const reason = meRes.status === 403 ? 'API key is under-scoped for this instance' : 'API key was rejected'; + return { ok: false, version, error: `${reason} (${meRes.status})` }; + } + const me = (await meRes.json()) as { email?: string; name?: string }; + return { ok: true, version, user: me.email ?? me.name ?? null }; + } catch (err) { + return { ok: false, version, error: String(err) }; + } +} + +/** What the browser is allowed to know about the registry. Never includes a key. */ +async function accountList(userId: number): Promise { + const accounts = await listPhotosAccounts(userId); + const active = accounts.find((account) => account.isActive) ?? null; + return Response.json({ configured: !!active, activeId: active?.id ?? null, accounts }); +} + +/** Record that the instance answered, so the UI can tell "never connected" from "was working, now isn't". */ +export async function noteProbe(userId: number, id: number, version: string | null): Promise { + await recordPhotosProbe(userId, id, version).catch(() => { + /* a stale lastSeenAt is not worth failing a request over */ + }); +} + +const bad = (error: string, status = 400) => Response.json({ error }, { status }); + +type AccountBody = { label?: unknown; url?: unknown; apiKey?: unknown }; + +const readBody = async (req: Request): Promise => + ((await req.json().catch(() => null)) as AccountBody | null) ?? {}; + +const readLabel = (body: AccountBody): string => (typeof body.label === 'string' ? body.label.trim() : ''); +const readUrl = (body: AccountBody): string => (typeof body.url === 'string' ? normalizeBase(body.url) : ''); +const readKey = (body: AccountBody): string => (typeof body.apiKey === 'string' ? body.apiKey.trim() : ''); + +const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url); + +const duplicateLabel = (err: unknown): boolean => String(err).includes('uq_photos_config_user_label'); + +/** Add an account: validated against the live instance, then stored encrypted. */ +async function addAccount(req: Request, userId: number): Promise { + const body = await readBody(req); + const url = readUrl(body); + const apiKey = readKey(body); + let label = readLabel(body); + + if (!url || !apiKey) return bad('url and apiKey are required'); + if (!isHttpUrl(url)) return bad('url must start with http:// or https://'); + + const result = await probe({ base: url, key: apiKey }); + if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 }); + + // An unlabelled account takes the name Immich itself knows it by, which is nearly always what the owner + // would have typed. Falling back to the host keeps the switcher readable even for an anonymous key. + if (!label) label = result.user ?? new URL(url).host; + + // The first account wins the selection: a registry with rows but nothing selected reads as "not connected". + const existing = await listPhotosAccounts(userId); + const activate = existing.length === 0; + + try { + const account = await createPhotosAccount({ userId, label, url, apiKey, version: result.version, activate }); + invalidateConfig(userId); + return Response.json({ account, user: result.user }); + } catch (err) { + if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`); + throw err; + } +} + +/** Edit one account. A blank key means "keep the stored one", so a rename does not need the key re-typed. */ +async function editAccount(req: Request, userId: number, id: number): Promise { + const body = await readBody(req); + const label = readLabel(body); + const url = readUrl(body); + const apiKey = readKey(body); + + const current = await getPhotosCredentials(userId, id); + if (!current) return bad('no such account', 404); + if (url && !isHttpUrl(url)) return bad('url must start with http:// or https://'); + + // Re-validate whenever what we would talk to changes. A rename on its own never touches the instance. + let version: string | null | undefined; + if ((url && url !== current.url) || apiKey) { + const result = await probe({ base: url || current.url, key: apiKey || current.apiKey }); + if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 }); + version = result.version; + } + + try { + const account = await updatePhotosAccount(userId, id, { + label: label || undefined, + url: url || undefined, + apiKey: apiKey || undefined, + version, + }); + if (!account) return bad('no such account', 404); + invalidateConfig(userId); + return Response.json({ account }); + } catch (err) { + if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`); + throw err; + } +} + +/** `subpath` is '' for /_config, or '/' / '//activate'. */ +export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise { + const [, rawId, action] = subpath.split('/'); + + if (!rawId) { + if (req.method === 'GET') return accountList(userId); + if (req.method === 'POST' || req.method === 'PUT') return addAccount(req, userId); + return bad('method not allowed', 405); + } + + const id = Number(rawId); + if (!Number.isInteger(id) || id <= 0) return bad('invalid account id', 404); + + if (action === 'activate') { + if (req.method !== 'POST') return bad('method not allowed', 405); + const account = await setActivePhotosAccount(userId, id); + if (!account) return bad('no such account', 404); + invalidateConfig(userId); + return accountList(userId); + } + + if (action) return bad('not found', 404); + + if (req.method === 'PATCH' || req.method === 'PUT') return editAccount(req, userId, id); + + if (req.method === 'DELETE') { + const removed = await deletePhotosAccount(userId, id); + if (!removed) return bad('no such account', 404); + invalidateConfig(userId); + return accountList(userId); + } + + return bad('method not allowed', 405); +} diff --git a/src/servers/sidecar/photos/index.ts b/src/servers/sidecar/photos/index.ts index 33a8fb74..dba3b69f 100644 --- a/src/servers/sidecar/photos/index.ts +++ b/src/servers/sidecar/photos/index.ts @@ -1,12 +1,16 @@ import type { SidecarCommand, SidecarEvent } from '../protocol'; import { createSidecarConnector } from '../connect'; +import { handleConfigRoute, noteProbe, probe } from './config'; import { handleOfficerRoute } from './routes'; -import { callUpstream, getBase, getConfig } from './upstream'; +import { getConfig } from './upstream'; // The officer-photos sidecar. Owns the whole Immich contract for Officer: the instance URL and the API key. // The platform API is a thin auth-gated forwarder (src/servers/api/photos/router.ts) holding no Immich // credentials. // +// The connection is the OWNER'S to set, from the UI — it is stored encrypted in `photos_config` and no +// longer read from the environment. See upstream.ts for why that move mattered. +// // Named `photos`, not `immich`: the feature is the owner's photo library, and Immich is the implementation // behind it. The route surface below is Officer's, so swapping the backend would not move the mount point. // @@ -15,9 +19,18 @@ import { callUpstream, getBase, getConfig } from './upstream'; // ───────────────────────────────────────────────────────────────────────────────────────────────── // HTTP CONTRACT — the platform strips its /api/photos mount prefix before forwarding. // -// GET /_health ours. Confirms the key is live and reports the Immich version and who the key is. -// * /_officer/ forwarded to /api/, first-segment allow-list (routes.ts) -// anything else 404 +// GET /_health ours. Confirms the key is live; reports the Immich version and who the key is. +// GET /_config the account registry MINUS every key: { configured, activeId, accounts[] } +// POST /_config { label?, url, apiKey } — validated against the instance, then stored encrypted +// PATCH /_config/:id { label?, url?, apiKey? } — a blank key keeps the stored one +// POST /_config/:id/activate switch to that account +// DEL /_config/:id remove it; the newest survivor is promoted if it was the active one +// * /_officer/ forwarded to /api/, first-segment allow-list (routes.ts) +// anything else 404 +// +// Every route needs `X-Officer-User`, which the platform proxy sets after authenticating the owner. We bind +// loopback only, so its presence is the trust signal — a request without it did not come through the +// platform, and the connection is per-owner data. // // So `/api/photos/_officer/albums` on the platform is `/api/albums` on Immich, and // `/api/photos/_officer/assets//thumbnail?size=preview` streams the thumbnail bytes back, Range and @@ -44,39 +57,52 @@ const server = Bun.serve({ maxRequestBodySize: 4 * 1024 * 1024 * 1024, async fetch(req) { const url = new URL(req.url); - const cfg = getConfig(); - if (url.pathname === '/_health') { - if (!cfg) return Response.json({ ok: false, error: 'IMMICH_URL/IMMICH_API_KEY not configured' }, { status: 503 }); - const started = Date.now(); + const officerUser = req.headers.get('X-Officer-User'); + const userId = Number(officerUser); + if (!officerUser || !Number.isInteger(userId) || userId <= 0) { + return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 }); + } + + if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) { try { - // Version is public, so it separates "instance down" from "key rejected" in one shot. - const [versionRes, meRes] = await Promise.all([ - callUpstream(cfg, { path: '/api/server/version', withKey: false }), - callUpstream(cfg, { path: '/api/users/me' }), - ]); - if (!versionRes.ok) { - return Response.json({ ok: false, error: `upstream returned ${versionRes.status}` }, { status: 502 }); - } - const v = (await versionRes.json()) as { major?: number; minor?: number; patch?: number }; - const version = [v.major, v.minor, v.patch].every((n) => typeof n === 'number') - ? `${v.major}.${v.minor}.${v.patch}` - : null; - if (!meRes.ok) { - return Response.json( - { ok: false, version, error: `IMMICH_API_KEY rejected (${meRes.status})`, ms: Date.now() - started }, - { status: 502 }, - ); - } - const me = (await meRes.json()) as { email?: string; name?: string }; - return Response.json({ ok: true, version, user: me.email ?? me.name ?? null, ms: Date.now() - started }); + return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length)); } catch (err) { - return Response.json({ ok: false, error: String(err), ms: Date.now() - started }, { status: 502 }); + console.error(`[photos] ${req.method} ${url.pathname} failed`, err); + return Response.json({ error: 'internal error' }, { status: 500 }); } } + const cfg = await getConfig(userId); + + // 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a + // configured-but-broken instance is the whole reason the flag is on the response. + if (url.pathname === '/_health') { + if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 }); + + const started = Date.now(); + const result = await probe(cfg); + const ms = Date.now() - started; + + if (!result.ok) { + return Response.json( + { ok: false, configured: true, account: cfg.label, version: result.version, error: result.error, ms }, + { status: 502 }, + ); + } + await noteProbe(userId, cfg.id, result.version); + return Response.json({ + ok: true, + configured: true, + account: cfg.label, + version: result.version, + user: result.user, + ms, + }); + } + if (url.pathname.startsWith('/_officer/')) { - if (!cfg) return Response.json({ error: 'photos not configured' }, { status: 503 }); + if (!cfg) return Response.json({ error: 'photos not connected', configured: false }, { status: 503 }); try { const res = await handleOfficerRoute(cfg, req, url); if (res) return res; @@ -91,7 +117,7 @@ const server = Bun.serve({ }, }); -console.log(`[photos] listening on 127.0.0.1:${port} -> ${getBase() ?? '(IMMICH_URL unset)'}`); +console.log(`[photos] listening on 127.0.0.1:${port} (instance configured from the UI, stored in photos_config)`); type ReplyFn = (msg: SidecarEvent) => void; diff --git a/src/servers/sidecar/photos/upstream.ts b/src/servers/sidecar/photos/upstream.ts index f9bde8ef..e2aee035 100644 --- a/src/servers/sidecar/photos/upstream.ts +++ b/src/servers/sidecar/photos/upstream.ts @@ -1,8 +1,13 @@ // Immich upstream config for the officer-photos sidecar. // -// All knowledge of the Immich instance — its URL and its API key — lives here, mirroring -// officer-invoiceshelf/officer-transmission/officer-slskd: the platform API is a thin auth+forward proxy -// and holds NO Immich credentials. +// All knowledge of the Immich instance — its URL and its API key — lives here. The platform API is a thin +// auth+forward proxy and holds NO Immich credentials. +// +// The instance is CONFIGURED BY THE OWNER FROM THE UI and stored encrypted in `photos_config` (see +// databases/officer_db/src/queries/photos.ts). It is deliberately no longer read from the environment: +// Bun auto-loads `.env` into every process started in the platform directory, so an `IMMICH_API_KEY` there +// was also sitting in `officer`'s own `process.env` — a credential held by the one process that has no code +// to use it and the largest attack surface in the system. Nothing in this file reads process.env. // // Two things about Immich's API are load-bearing: // @@ -14,33 +19,46 @@ // exactly the confusion this sidecar exists to prevent. Bun's fetch adds none of them on its own and // nothing below adds them; the platform proxy forwards only content-type, range and if-none-match. -const { IMMICH_URL, IMMICH_API_KEY } = process.env; +import { getActivePhotosCredentials } from 'officerdb'; -export type UpstreamConfig = { base: string; key: string }; - -let warnedUnset = false; - -/** The instance URL alone, for logging — set without a key is a real state and should read as one. */ -export function getBase(): string | null { - return IMMICH_URL?.trim().replace(/\/+$/, '') || null; -} +/** Everything needed to make one call. A candidate being validated has this and nothing else yet. */ +export type UpstreamTarget = { base: string; key: string }; /** - * The configured instance, or null when unconfigured — the sidecar then answers 503 rather than pretending - * to work. Warns once so a misconfigured deployment is obvious in the logs without flooding them. + * A stored account, which is where every real call goes. + * + * `id` and `label` ride along because the owner can have several accounts registered and only one selected: + * a probe has to be recorded against the row it actually reached, and a log line saying which library + * answered is the difference between "photos is broken" and "you are looking at the other account". */ -export function getConfig(): UpstreamConfig | null { - const base = IMMICH_URL?.trim().replace(/\/+$/, ''); - const key = IMMICH_API_KEY?.trim(); - if (!base || !key) { - if (!warnedUnset) { - const missing = [!base && 'IMMICH_URL', !key && 'IMMICH_API_KEY'].filter(Boolean).join(' and '); - console.warn(`[photos] ${missing} unset — the sidecar will respond 503 until set`); - warnedUnset = true; - } - return null; - } - return { base, key }; +export type UpstreamConfig = UpstreamTarget & { id: number; label: string }; + +/** Trailing slashes off, so `${base}/api/...` never doubles the separator. */ +export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, ''); + +// A thumbnail grid is a hundred requests in a second and each one needs the key, so the row is cached rather +// than re-read per request. Writes invalidate immediately; the TTL only covers someone editing the row in +// psql, which then takes effect within a minute instead of needing a restart. +const TTL_MS = 60_000; +const cache = new Map(); + +/** + * The owner's SELECTED account, or null when /photos has no account yet — the sidecar then answers 503, + * and the UI turns that into the setup form rather than a wall of empty grids. + */ +export async function getConfig(userId: number): Promise { + const hit = cache.get(userId); + if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg; + + const creds = await getActivePhotosCredentials(userId); + const cfg = creds ? { id: creds.id, label: creds.label, base: normalizeBase(creds.url), key: creds.apiKey } : null; + cache.set(userId, { cfg, at: Date.now() }); + return cfg; +} + +/** Drop the cached row — called by the config routes after any add, edit, switch or removal. */ +export function invalidateConfig(userId: number): void { + cache.delete(userId); } type CallOptions = { @@ -59,7 +77,7 @@ type CallOptions = { }; /** The single door to Immich. Everything the sidecar fetches goes through here. */ -export async function callUpstream(cfg: UpstreamConfig, opts: CallOptions): Promise { +export async function callUpstream(cfg: UpstreamTarget, opts: CallOptions): Promise { const headers: Record = { Accept: 'application/json' }; if (opts.withKey !== false) headers['x-api-key'] = cfg.key; diff --git a/src/workspaces/officerdev/src/apps/Photos/AccountSwitcher.tsx b/src/workspaces/officerdev/src/apps/Photos/AccountSwitcher.tsx new file mode 100644 index 00000000..e989129a --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Photos/AccountSwitcher.tsx @@ -0,0 +1,82 @@ +import { Link } from 'react-router'; +import { Check, ChevronsUpDown, Loader2, Settings2 } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { photosSectionPath } from './shared'; +import { usePhotosAccountActions, usePhotosAccounts, usePhotosHealth } from './usePhotosData'; + +// Which Immich library you are looking at, and how to change it. +// +// The subtitle under "Photos" was already the connection state, so the switcher lives there rather than +// adding a second control saying nearly the same thing. With one account it is exactly what it was: a line +// of text plus a way into the settings screen. +// +// Switching invalidates the whole ['photos'] key, because it changes the answer to every query in the +// workspace without changing any of their inputs — the grid, the albums, the map are all the other library's +// now. Anything less and the previous account's thumbnails stay on screen. + +export const AccountSwitcher = () => { + const { data } = usePhotosAccounts(); + const { data: health } = usePhotosHealth(); + const { activate } = usePhotosAccountActions(); + + const accounts = data?.accounts ?? []; + const active = accounts.find((account) => account.isActive) ?? null; + + // Before the registry answers, fall back to health — it is the query that was already driving this line. + const status = active?.label ?? (health?.ok ? (health.version ?? 'connected') : 'not connected'); + + if (accounts.length < 2) { + return ( + + {status} + + ); + } + + return ( + + + {status} + {activate.isPending ? ( + + ) : ( + + )} + + + Immich accounts + {accounts.map((account) => ( + void activate.mutateAsync(account.id).catch(() => undefined)} + className="gap-2" + > + + + {account.label} + {account.url} + + + ))} + + + + + Manage accounts + + + + + ); +}; diff --git a/src/workspaces/officerdev/src/apps/Photos/ConnectionSection.tsx b/src/workspaces/officerdev/src/apps/Photos/ConnectionSection.tsx new file mode 100644 index 00000000..210a605a --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Photos/ConnectionSection.tsx @@ -0,0 +1,265 @@ +import type { PhotosAccount } from './usePhotosData'; +import { useState } from 'react'; +import { Check, CheckCircle2, Loader2, Plug, Trash2, TriangleAlert } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { formatShortDate } from './shared'; +import { photosErrorMessage, usePhotosAccountActions, usePhotosAccounts, usePhotosHealth } from './usePhotosData'; + +// Connecting Immich libraries to Officer, from the app. +// +// This screen is BOTH the setup wizard and the permanent settings page: PhotosView renders it in place of +// whatever section the URL asks for while nothing is connected, and /photos/settings renders it for good. +// One component, so a second account added later goes through exactly the code path that stored the first. +// +// It is a registry, not a single connection — two keys against the same instance (one per Immich user) is +// the ordinary case, which is why each account carries a LABEL and why the label, not the URL, is what has +// to be unique. The API key is never displayed, because Officer cannot display it: it is encrypted at rest +// and the sidecar's GET has no field that could carry it back. + +const HINT = 'text-[11px] leading-relaxed text-muted-foreground'; + +/** Immich's own default — its compose file publishes 2283, and the sidecar dials from this machine. */ +const DEFAULT_URL = 'http://localhost:2283'; + +const URL_HINT = + "The instance's base URL, without /api. Officer reaches it from the server, not from this browser — so " + + 'localhost here means the machine Officer runs on, and a local instance needs no TLS.'; + +const KEY_HINT = + 'Immich → Account Settings → API Keys. Grant all permissions unless you have a reason not to: Immich keys ' + + 'are scoped, and a partial key looks like a broken feature rather than a rejected credential.'; + +type FieldProps = { + label: string; + hint?: string; + value: string; + onChange: (value: string) => void; + placeholder: string; + type?: string; + autoFocus?: boolean; +}; + +const Field = ({ label, hint, value, onChange, placeholder, type, autoFocus }: FieldProps) => ( + +); + +type AccountRowProps = { account: PhotosAccount }; + +/** + * One stored account. The active one carries the live health line, because health only ever describes the + * account actually being used — showing a status next to the others would be inventing one. + */ +const AccountRow = ({ account }: AccountRowProps) => { + const { data: health, refetch: recheck, isFetching: checking } = usePhotosHealth(); + const { edit, activate, remove } = usePhotosAccountActions(); + + const [apiKey, setApiKey] = useState(''); + const [error, setError] = useState(null); + // Removing an account throws away a key Officer can never show again, so the bin asks once. + const [confirming, setConfirming] = useState(false); + + const run = async (action: Promise) => { + setError(null); + try { + await action; + setApiKey(''); + } catch (err) { + setError(photosErrorMessage(err)); + } + }; + + return ( +
+
+ {account.isActive ? : } + {account.label} + {account.isActive && ( + in use + )} +
+ {!account.isActive && ( + + )} + {account.isActive && ( + + )} + +
+
+ +

{account.url}

+ + {account.isActive ? ( +
+ {health?.ok ? ( + + ) : ( + + )} + + {health?.ok + ? `Immich ${health.version ?? '?'}${health.user ? ` · ${health.user}` : ''}` + : (health?.error ?? 'not checked yet')} + +
+ ) : ( +

+ {account.version ? `Immich ${account.version}` : 'version unknown'} + {account.lastSeenAt ? ` · last answered ${formatShortDate(account.lastSeenAt)}` : ''} +

+ )} + +
+ setApiKey(ev.target.value)} + placeholder="Replace API key" + type="password" + autoComplete="off" + spellCheck={false} + className="h-8" + /> + +
+ + {error &&

{error}

} +
+ ); +}; + +export const ConnectionSection = () => { + const { data, isLoading } = usePhotosAccounts(); + const { add } = usePhotosAccountActions(); + + const [label, setLabel] = useState(''); + const [url, setUrl] = useState(DEFAULT_URL); + const [apiKey, setApiKey] = useState(''); + const [error, setError] = useState(null); + + const accounts = data?.accounts ?? []; + const hasAccounts = accounts.length > 0; + + const submit = async () => { + setError(null); + if (!url.trim()) return setError('The Immich URL is required'); + if (!apiKey.trim()) return setError('An API key is required'); + + try { + await add.mutateAsync({ label: label.trim(), url: url.trim(), apiKey: apiKey.trim() }); + setLabel(''); + setApiKey(''); + setUrl(DEFAULT_URL); + } catch (err) { + setError(photosErrorMessage(err)); + } + }; + + return ( +
+
+
+
+ +
+
+

{hasAccounts ? 'Immich accounts' : 'Connect Immich'}

+

+ {hasAccounts + ? 'Officer stores each instance and its key encrypted, for this account only. One is in use at a time.' + : 'Photos needs an Immich instance and an API key before it can show anything.'} +

+
+
+ + {!isLoading && accounts.map((account) => )} + +
{ + ev.preventDefault(); + void submit(); + }} + className="flex flex-col gap-3 rounded-lg border p-4" + > +

{hasAccounts ? 'Add another account' : 'Add an account'}

+ + + + + {error &&

{error}

} + +
+ + {add.isPending && Checking the instance and the key…} +
+ +
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Photos/PhotosNav.tsx b/src/workspaces/officerdev/src/apps/Photos/PhotosNav.tsx index 8670e830..056d8ea4 100644 --- a/src/workspaces/officerdev/src/apps/Photos/PhotosNav.tsx +++ b/src/workspaces/officerdev/src/apps/Photos/PhotosNav.tsx @@ -1,8 +1,9 @@ import type { LucideIcon } from 'lucide-react'; import { NavLink } from 'react-router'; -import { Archive, Compass, Heart, Images, Map as MapIcon, Search, Share2, Trash2, Users } from 'lucide-react'; +import { Archive, Compass, Heart, Images, Map as MapIcon, Plug, Search, Share2, Trash2, Users } from 'lucide-react'; +import { AccountSwitcher } from './AccountSwitcher'; import { PHOTOS_SECTIONS, photosSectionPath, type PhotosSectionId } from './shared'; -import { useAssetStats, usePhotosHealth } from './usePhotosData'; +import { useAssetStats } from './usePhotosData'; // Left panel of the /photos workspace, mirroring Immich's own sidebar: library sections up top, the sharing // and cleanup ones below a divider, and the library size at the bottom. @@ -20,6 +21,7 @@ const ICONS: Record = { archive: Archive, sharing: Share2, trash: Trash2, + settings: Plug, }; // Immich draws the same line: browsing the library, then everything else. @@ -29,7 +31,6 @@ const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-le export const PhotosNav = () => { const { data: stats } = useAssetStats(); - const { data: health } = usePhotosHealth(); const group = (ids: PhotosSectionId[]) => PHOTOS_SECTIONS.filter((section) => ids.includes(section.id)).map(({ id, label }) => { @@ -71,9 +72,7 @@ export const PhotosNav = () => {
Photos
-
- {health?.ok ? (health.version ?? 'connected') : 'not connected'} -
+
diff --git a/src/workspaces/officerdev/src/apps/Photos/PhotosView.tsx b/src/workspaces/officerdev/src/apps/Photos/PhotosView.tsx index 6f6a7408..a1085c1a 100644 --- a/src/workspaces/officerdev/src/apps/Photos/PhotosView.tsx +++ b/src/workspaces/officerdev/src/apps/Photos/PhotosView.tsx @@ -1,29 +1,43 @@ +import { Link } from 'react-router'; import { AlbumsSection } from './AlbumsSection'; +import { ConnectionSection } from './ConnectionSection'; import { ExploreSection } from './ExploreSection'; import { MapSection } from './MapSection'; import { PeopleSection } from './PeopleSection'; import { SearchSection } from './SearchSection'; import { SharingSection } from './SharingSection'; import { TimelineSection } from './TimelineSection'; +import { photosSectionPath } from './shared'; import { usePhotosHealth } from './usePhotosData'; import { usePhotosSection } from './usePhotosSection'; // Right panel of the /photos workspace — renders the section named by the URL. // // Timeline, favorites, archive and trash are one component with a filter; the rest are their own. +// +// Health gates all of them, and its two failure modes are answered differently: nothing configured yet is +// the setup form, whatever section was asked for, because there is nothing else useful to show; a stored +// instance that is failing keeps its own message and a way back to the connection screen, since replacing a +// working key by accident is worse than a wall of text. export const PhotosView = () => { const section = usePhotosSection(); const { data: health, isLoading } = usePhotosHealth(); - // Every section is useless without the upstream, and a wall of empty grids is a worse answer than saying so. + if (section === 'settings') return ; + if (!isLoading && health && !health.ok) { + if (health.configured === false) return ; + return (
-

Photos is not connected

+

Photos is not answering

- {health.error ?? 'The photos sidecar could not reach Immich. Check IMMICH_URL and IMMICH_API_KEY.'} + {health.error ?? 'The photos sidecar could not reach the configured Immich instance.'}

+ + Check the connection +
); } diff --git a/src/workspaces/officerdev/src/apps/Photos/shared.ts b/src/workspaces/officerdev/src/apps/Photos/shared.ts index 8d81bf03..b2b24b63 100644 --- a/src/workspaces/officerdev/src/apps/Photos/shared.ts +++ b/src/workspaces/officerdev/src/apps/Photos/shared.ts @@ -17,6 +17,7 @@ export const PHOTOS_SECTIONS = [ { id: 'archive', label: 'Archive' }, { id: 'sharing', label: 'Sharing' }, { id: 'trash', label: 'Trash' }, + { id: 'settings', label: 'Connection' }, ] as const; export type PhotosSectionId = (typeof PHOTOS_SECTIONS)[number]['id']; diff --git a/src/workspaces/officerdev/src/apps/Photos/usePhotosData.ts b/src/workspaces/officerdev/src/apps/Photos/usePhotosData.ts index 3aab7adf..5fc69fcf 100644 --- a/src/workspaces/officerdev/src/apps/Photos/usePhotosData.ts +++ b/src/workspaces/officerdev/src/apps/Photos/usePhotosData.ts @@ -14,7 +14,7 @@ import type { TimeBucketColumns, } from './shared'; import { useMemo } from 'react'; -import { useQueries, useQuery } from '@tanstack/react-query'; +import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; import { useClient } from 'hooks/useClient'; import { bucketAssets } from './shared'; @@ -234,14 +234,125 @@ export function useServerConfig() { }); } -export type PhotosHealth = { ok: boolean; version?: string | null; user?: string | null; error?: string }; +// ── Accounts ────────────────────────────────────────────────────────────────────────────────────── +// +// Immich instances and their API keys are the owner's to set from /photos/settings; nothing reads them from +// the environment any more. It is a REGISTRY — any number of labelled accounts, one of them selected — so +// two keys against the same instance (one per Immich user) is an ordinary thing to have. +// +// The key is WRITE-ONLY across this boundary: an account carries its label, URL and whether it is selected, +// and has no field that could carry the key back to the browser. +export type PhotosHealth = { + ok: boolean; + /** False only when no account is stored. It is what separates "set this up" from "this used to work". */ + configured?: boolean; + /** Label of the selected account, so a failure names which library did not answer. */ + account?: string; + version?: string | null; + user?: string | null; + error?: string; +}; + +export type PhotosAccount = { + id: number; + label: string; + url: string; + version: string | null; + isActive: boolean; + lastSeenAt: string | null; + createdAt: string; +}; + +export type PhotosAccounts = { configured: boolean; activeId: number | null; accounts: PhotosAccount[] }; + +/** Unwrap the `{ status, message }` useClient throws, where `message` is the sidecar's JSON body. */ +export function photosErrorMessage(err: unknown): string { + const raw = (err as { message?: unknown } | null)?.message; + if (typeof raw !== 'string' || !raw) return 'Something went wrong'; + try { + const parsed = JSON.parse(raw) as { error?: unknown }; + if (typeof parsed.error === 'string' && parsed.error) return parsed.error; + } catch { + /* plain text */ + } + return raw.slice(0, 300); +} + +/** + * Health, including its failure bodies. + * + * `get` throws on any status >= 400, so a plain query would leave `data` undefined for exactly the two + * cases the UI most needs to tell apart — 503 not connected and 502 connected-but-broken. Both carry a + * JSON body, so the throw is turned back into the answer rather than an error state. + */ export function usePhotosHealth() { const { get } = useClient(); return useQuery({ queryKey: [KEY, 'health'], - queryFn: () => get('/photos/_health'), + queryFn: async (): Promise => { + try { + return await get('/photos/_health'); + } catch (err) { + const raw = (err as { message?: unknown } | null)?.message; + if (typeof raw === 'string') { + try { + const body = JSON.parse(raw) as PhotosHealth; + if (body && body.ok === false) return body; + } catch { + /* not the sidecar's body */ + } + } + // Anything else — the platform proxy, auth, the sidecar being down — is a configured instance + // that is failing, not an unconfigured one. Never offer the setup form on a guess. + return { ok: false, configured: true, error: photosErrorMessage(err) }; + } + }, staleTime: 60_000, retry: false, }); } + +export function usePhotosAccounts() { + const { get } = useClient(); + return useQuery({ + queryKey: [KEY, 'accounts'], + queryFn: () => get('/photos/_config'), + staleTime: 60_000, + retry: false, + }); +} + +export type AddPhotosAccountInput = { label: string; url: string; apiKey: string }; +export type EditPhotosAccountInput = { id: number; label?: string; url?: string; apiKey?: string }; + +export function usePhotosAccountActions() { + const { post, patch, delete: del } = useClient(); + const qc = useQueryClient(); + // Adding, editing, switching and removing all change what every other query in this workspace can even + // answer — a switch in particular changes the answer to all of them without changing any of their inputs. + const invalidate = () => qc.invalidateQueries({ queryKey: [KEY] }); + + const add = useMutation({ + mutationFn: (input: AddPhotosAccountInput) => post<{ account: PhotosAccount }>('/photos/_config', input), + onSuccess: invalidate, + }); + + const edit = useMutation({ + mutationFn: ({ id, ...rest }: EditPhotosAccountInput) => + patch<{ account: PhotosAccount }>(`/photos/_config/${id}`, rest), + onSuccess: invalidate, + }); + + const activate = useMutation({ + mutationFn: (id: number) => post(`/photos/_config/${id}/activate`, {}), + onSuccess: invalidate, + }); + + const remove = useMutation({ + mutationFn: (id: number) => del(`/photos/_config/${id}`), + onSuccess: invalidate, + }); + + return { add, edit, activate, remove }; +}