From 7a966c780e6e107ca4c32f1b2ede9be15cc11cc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 28 Jul 2026 00:37:29 +0000 Subject: [PATCH] add per-user named music playlists (server-side infra) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the existing per-user music state (favorites / now-playing): Postgres tables + query layer + REST endpoints on the music router, all scoped to the caller's user id and served directly by the platform (not proxied to the user-stateless sidecar). Item `key`s are opaque track homePaths, same contract as favorites — the server never interprets them. - schema: music_playlists (name unique per user) + music_playlist_items (0-based position, dupes allowed, cascade delete). - queries: get/create/rename/delete playlists; add (append) / set (replace, covers reorder+remove) items; every mutation ownership-checked; item ops in a transaction that also bumps the playlist updatedAt. - router (/api/music, before the catch-all proxy): GET/POST /playlists, GET/PATCH/DELETE /playlists/:id, POST/PUT /playlists/:id/items. 409 on name collision, 404 on a playlist that isn't the caller's. - migration 0002 (also backfills music_favorites/now_playing into the snapshot, which were originally applied via a direct db:push). Applied to the DB. Verified end-to-end against the live DB: create, dupes, ordering, append, replace/reorder, ownership scoping, rename, counts, delete — all pass. Co-Authored-By: Claude Opus 4.8 --- .../migrations/0002_gray_killmonger.sql | 44 + .../migrations/meta/0002_snapshot.json | 1961 +++++++++++++++++ .../officer_db/migrations/meta/_journal.json | 7 + src/databases/officer_db/src/index.ts | 17 +- src/databases/officer_db/src/queries/music.ts | 127 +- src/databases/officer_db/src/schema/music.ts | 40 +- src/servers/api/music/router.ts | 87 + 7 files changed, 2277 insertions(+), 6 deletions(-) create mode 100644 src/databases/officer_db/migrations/0002_gray_killmonger.sql create mode 100644 src/databases/officer_db/migrations/meta/0002_snapshot.json diff --git a/src/databases/officer_db/migrations/0002_gray_killmonger.sql b/src/databases/officer_db/migrations/0002_gray_killmonger.sql new file mode 100644 index 00000000..0ec6abdd --- /dev/null +++ b/src/databases/officer_db/migrations/0002_gray_killmonger.sql @@ -0,0 +1,44 @@ +CREATE TABLE "music_favorites" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "kind" text NOT NULL, + "key" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "uq_music_favorites_user_kind_key" UNIQUE("user_id","kind","key") +); +--> statement-breakpoint +CREATE TABLE "music_now_playing" ( + "user_id" integer PRIMARY KEY NOT NULL, + "home_path" text NOT NULL, + "dir" text DEFAULT '' NOT NULL, + "title" text DEFAULT '' NOT NULL, + "artist" text DEFAULT '' NOT NULL, + "album" text DEFAULT '' NOT NULL, + "duration_sec" real DEFAULT 0 NOT NULL, + "position_sec" real DEFAULT 0 NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "music_playlist_items" ( + "id" serial PRIMARY KEY NOT NULL, + "playlist_id" integer NOT NULL, + "key" text NOT NULL, + "position" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "music_playlists" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "name" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "uq_music_playlists_user_name" UNIQUE("user_id","name") +); +--> 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 +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 diff --git a/src/databases/officer_db/migrations/meta/0002_snapshot.json b/src/databases/officer_db/migrations/meta/0002_snapshot.json new file mode 100644 index 00000000..ba18024b --- /dev/null +++ b/src/databases/officer_db/migrations/meta/0002_snapshot.json @@ -0,0 +1,1961 @@ +{ + "id": "a58f1b9d-71a0-43f7-add2-6439d8ff2bc0", + "prevId": "aa436a71-d24a-488c-b237-a375ec312eae", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.passkey_challenges": { + "name": "passkey_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "passkey_challenges_user_id_users_id_fk": { + "name": "passkey_challenges_user_id_users_id_fk", + "tableFrom": "passkey_challenges", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkeys": { + "name": "passkeys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "passkeys_user_id_users_id_fk": { + "name": "passkeys_user_id_users_id_fk", + "tableFrom": "passkeys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.token_blacklist": { + "name": "token_blacklist", + "schema": "", + "columns": { + "jti": { + "name": "jti", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_token_blacklist_expires": { + "name": "idx_token_blacklist_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Unverified'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_changed_at": { + "name": "password_changed_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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "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": {}, + "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.dashboard_defaults": { + "name": "dashboard_defaults", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "terminals": { + "name": "terminals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "host_terminals": { + "name": "host_terminals", + "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": { + "dashboard_defaults_user_id_users_id_fk": { + "name": "dashboard_defaults_user_id_users_id_fk", + "tableFrom": "dashboard_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dashboard_defaults_user_id_unique": { + "name": "dashboard_defaults_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboards": { + "name": "dashboards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "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" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "dashboards_user_id_users_id_fk": { + "name": "dashboards_user_id_users_id_fk", + "tableFrom": "dashboards", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_dashboards_user_id": { + "name": "uq_dashboards_user_id", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "id" + ] + } + }, + "policies": {}, + "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": "", + "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 + }, + "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" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "screens_user_id_users_id_fk": { + "name": "screens_user_id_users_id_fk", + "tableFrom": "screens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_screens_user_name": { + "name": "uq_screens_user_name", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "name" + ] + } + }, + "policies": {}, + "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": "", + "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 + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "imap_host": { + "name": "imap_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imap_port": { + "name": "imap_port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "imap_secure": { + "name": "imap_secure", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credentials": { + "name": "credentials", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connected'" + }, + "sync_meta": { + "name": "sync_meta", + "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": { + "email_accounts_user_id_users_id_fk": { + "name": "email_accounts_user_id_users_id_fk", + "tableFrom": "email_accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_email_accounts_user_email": { + "name": "uq_email_accounts_user_email", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "email" + ] + } + }, + "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.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", + "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": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.music_favorites": { + "name": "music_favorites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_music_favorites_user_kind": { + "name": "idx_music_favorites_user_kind", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "music_favorites_user_id_users_id_fk": { + "name": "music_favorites_user_id_users_id_fk", + "tableFrom": "music_favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_music_favorites_user_kind_key": { + "name": "uq_music_favorites_user_kind_key", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "kind", + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.music_now_playing": { + "name": "music_now_playing", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "home_path": { + "name": "home_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dir": { + "name": "dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "artist": { + "name": "artist", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "album": { + "name": "album", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "duration_sec": { + "name": "duration_sec", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "position_sec": { + "name": "position_sec", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "music_now_playing_user_id_users_id_fk": { + "name": "music_now_playing_user_id_users_id_fk", + "tableFrom": "music_now_playing", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.music_playlist_items": { + "name": "music_playlist_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "playlist_id": { + "name": "playlist_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_music_playlist_items_playlist": { + "name": "idx_music_playlist_items_playlist", + "columns": [ + { + "expression": "playlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "music_playlist_items_playlist_id_music_playlists_id_fk": { + "name": "music_playlist_items_playlist_id_music_playlists_id_fk", + "tableFrom": "music_playlist_items", + "tableTo": "music_playlists", + "columnsFrom": [ + "playlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.music_playlists": { + "name": "music_playlists", + "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 + }, + "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": { + "music_playlists_user_id_users_id_fk": { + "name": "music_playlists_user_id_users_id_fk", + "tableFrom": "music_playlists", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_music_playlists_user_name": { + "name": "uq_music_playlists_user_name", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/databases/officer_db/migrations/meta/_journal.json b/src/databases/officer_db/migrations/meta/_journal.json index 8bc0c7d4..776712e6 100644 --- a/src/databases/officer_db/migrations/meta/_journal.json +++ b/src/databases/officer_db/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1785110579165, "tag": "0001_omniscient_photon", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1785198942654, + "tag": "0002_gray_killmonger", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 8d1d88d4..4c79f9af 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -64,7 +64,6 @@ export { upsertDefaults, } from './queries/dashboards'; - export { createPipelineJob, getPipelineJob, @@ -86,8 +85,22 @@ export { getNowPlaying, setNowPlaying, clearNowPlaying, + getPlaylists, + getPlaylist, + createPlaylist, + renamePlaylist, + deletePlaylist, + addPlaylistItems, + setPlaylistItems, +} from './queries/music'; +export type { + FavoriteKind, + GroupedFavorites, + NowPlaying, + NowPlayingInput, + PlaylistSummary, + Playlist, } from './queries/music'; -export type { FavoriteKind, GroupedFavorites, NowPlaying, NowPlayingInput } from './queries/music'; export { db } from './db'; export * as schema from './schema'; diff --git a/src/databases/officer_db/src/queries/music.ts b/src/databases/officer_db/src/queries/music.ts index 619b69fc..aab4ecad 100644 --- a/src/databases/officer_db/src/queries/music.ts +++ b/src/databases/officer_db/src/queries/music.ts @@ -1,6 +1,6 @@ -import { eq, and, desc } from 'drizzle-orm'; +import { eq, and, desc, asc, sql } from 'drizzle-orm'; import { db } from '../db'; -import { musicFavorites, musicNowPlaying } from '../schema'; +import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from '../schema'; export type FavoriteKind = 'track' | 'album' | 'artist'; export type GroupedFavorites = { tracks: string[]; albums: string[]; artists: string[] }; @@ -107,3 +107,126 @@ export async function setNowPlaying(userId: number, np: NowPlayingInput): Promis export async function clearNowPlaying(userId: number): Promise { await db.delete(musicNowPlaying).where(eq(musicNowPlaying.userId, userId)); } + +// ── Named playlists ──────────────────────────────────────────────────────────────────────────────── + +export type PlaylistSummary = { id: number; name: string; count: number; createdAt: Date; updatedAt: Date }; +export type Playlist = { id: number; name: string; items: string[]; createdAt: Date; updatedAt: Date }; + +/** All of a user's playlists with item counts, most-recently-updated first. */ +export async function getPlaylists(userId: number): Promise { + return db + .select({ + id: musicPlaylists.id, + name: musicPlaylists.name, + count: sql`count(${musicPlaylistItems.id})::int`, + createdAt: musicPlaylists.createdAt, + updatedAt: musicPlaylists.updatedAt, + }) + .from(musicPlaylists) + .leftJoin(musicPlaylistItems, eq(musicPlaylistItems.playlistId, musicPlaylists.id)) + .where(eq(musicPlaylists.userId, userId)) + .groupBy(musicPlaylists.id) + .orderBy(desc(musicPlaylists.updatedAt)); +} + +/** One playlist with its ordered item keys, or null if it doesn't exist or isn't this user's. */ +export async function getPlaylist(userId: number, id: number): Promise { + const [header] = await db + .select({ + id: musicPlaylists.id, + name: musicPlaylists.name, + createdAt: musicPlaylists.createdAt, + updatedAt: musicPlaylists.updatedAt, + }) + .from(musicPlaylists) + .where(and(eq(musicPlaylists.id, id), eq(musicPlaylists.userId, userId))); + if (!header) return null; + const rows = await db + .select({ key: musicPlaylistItems.key }) + .from(musicPlaylistItems) + .where(eq(musicPlaylistItems.playlistId, id)) + .orderBy(asc(musicPlaylistItems.position)); + return { ...header, items: rows.map((r) => r.key) }; +} + +/** Create a named playlist. Returns the new row, or null if the name is already taken for this user. */ +export async function createPlaylist(userId: number, name: string): Promise { + const [row] = await db + .insert(musicPlaylists) + .values({ userId, name }) + .onConflictDoNothing({ target: [musicPlaylists.userId, musicPlaylists.name] }) + .returning({ + id: musicPlaylists.id, + name: musicPlaylists.name, + createdAt: musicPlaylists.createdAt, + updatedAt: musicPlaylists.updatedAt, + }); + return row ? { ...row, count: 0 } : null; +} + +/** Rename a playlist. Returns false if it isn't this user's, or the new name collides. */ +export async function renamePlaylist(userId: number, id: number, name: string): Promise { + const existing = await db + .select({ id: musicPlaylists.id }) + .from(musicPlaylists) + .where(and(eq(musicPlaylists.userId, userId), eq(musicPlaylists.name, name))); + if (existing.some((r) => r.id !== id)) return false; // name taken by a different playlist + const updated = await db + .update(musicPlaylists) + .set({ name, updatedAt: new Date() }) + .where(and(eq(musicPlaylists.id, id), eq(musicPlaylists.userId, userId))) + .returning({ id: musicPlaylists.id }); + return updated.length > 0; +} + +/** Delete a playlist (items cascade). Returns false if it wasn't this user's. */ +export async function deletePlaylist(userId: number, id: number): Promise { + const deleted = await db + .delete(musicPlaylists) + .where(and(eq(musicPlaylists.id, id), eq(musicPlaylists.userId, userId))) + .returning({ id: musicPlaylists.id }); + return deleted.length > 0; +} + +/** Verify a playlist belongs to the user; returns its id or null. */ +async function ownedPlaylist(userId: number, id: number): Promise { + const [row] = await db + .select({ id: musicPlaylists.id }) + .from(musicPlaylists) + .where(and(eq(musicPlaylists.id, id), eq(musicPlaylists.userId, userId))); + return row?.id ?? null; +} + +/** Append keys to the end of a playlist, preserving order. Returns the new count, or null if not the user's. */ +export async function addPlaylistItems(userId: number, id: number, keys: string[]): Promise { + if ((await ownedPlaylist(userId, id)) === null) return null; + return db.transaction(async (tx) => { + const [{ next } = { next: 0 }] = await tx + .select({ next: sql`coalesce(max(${musicPlaylistItems.position}) + 1, 0)::int` }) + .from(musicPlaylistItems) + .where(eq(musicPlaylistItems.playlistId, id)); + if (keys.length) { + await tx.insert(musicPlaylistItems).values(keys.map((key, i) => ({ playlistId: id, key, position: next + i }))); + } + await tx.update(musicPlaylists).set({ updatedAt: new Date() }).where(eq(musicPlaylists.id, id)); + const [{ total } = { total: 0 }] = await tx + .select({ total: sql`count(*)::int` }) + .from(musicPlaylistItems) + .where(eq(musicPlaylistItems.playlistId, id)); + return total; + }); +} + +/** Replace a playlist's entire ordered item list (covers reorder + remove). Returns the new count, or null. */ +export async function setPlaylistItems(userId: number, id: number, keys: string[]): Promise { + if ((await ownedPlaylist(userId, id)) === null) return null; + return db.transaction(async (tx) => { + await tx.delete(musicPlaylistItems).where(eq(musicPlaylistItems.playlistId, id)); + if (keys.length) { + await tx.insert(musicPlaylistItems).values(keys.map((key, i) => ({ playlistId: id, key, position: i }))); + } + await tx.update(musicPlaylists).set({ updatedAt: new Date() }).where(eq(musicPlaylists.id, id)); + return keys.length; + }); +} diff --git a/src/databases/officer_db/src/schema/music.ts b/src/databases/officer_db/src/schema/music.ts index 4734158e..8d4a8c0f 100644 --- a/src/databases/officer_db/src/schema/music.ts +++ b/src/databases/officer_db/src/schema/music.ts @@ -9,7 +9,9 @@ export const musicFavorites = pgTable( 'music_favorites', { id: serial('id').primaryKey(), - userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), kind: text('kind').notNull(), // 'track' | 'album' | 'artist' key: text('key').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), @@ -20,12 +22,46 @@ export const musicFavorites = pgTable( ], ); +// Per-user named playlists (header) + their ordered items (below). Like favorites, an item `key` is the +// opaque track homePath "Music//" the app supplies — the server never interprets it. A playlist +// name is unique per user; items are position-ordered and MAY repeat (a track can appear twice). +export const musicPlaylists = pgTable( + 'music_playlists', + { + id: serial('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [unique('uq_music_playlists_user_name').on(t.userId, t.name)], +); + +// Ordered track entries of a playlist. `position` is 0-based; deletes cascade from the playlist. +export const musicPlaylistItems = pgTable( + 'music_playlist_items', + { + id: serial('id').primaryKey(), + playlistId: integer('playlist_id') + .notNull() + .references(() => musicPlaylists.id, { onDelete: 'cascade' }), + key: text('key').notNull(), // track homePath "Music//" + position: integer('position').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [index('idx_music_playlist_items_playlist').on(t.playlistId, t.position)], +); + // Per-user "currently playing" for resume-across-launch/device: the current track + playback position, // plus a light metadata snapshot so the resume card renders before the library index has synced on a // fresh device. `dir` is the folder to rebuild the album queue from ('' for a cross-album queue). One // row per user (upserted; the app writes it throttled while playing and on pause/track-change/close). export const musicNowPlaying = pgTable('music_now_playing', { - userId: integer('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }), + userId: integer('user_id') + .primaryKey() + .references(() => users.id, { onDelete: 'cascade' }), homePath: text('home_path').notNull(), dir: text('dir').notNull().default(''), title: text('title').notNull().default(''), diff --git a/src/servers/api/music/router.ts b/src/servers/api/music/router.ts index cced3f7e..70cd9e02 100644 --- a/src/servers/api/music/router.ts +++ b/src/servers/api/music/router.ts @@ -7,6 +7,13 @@ import { getNowPlaying, setNowPlaying, clearNowPlaying, + getPlaylists, + getPlaylist, + createPlaylist, + renamePlaylist, + deletePlaylist, + addPlaylistItems, + setPlaylistItems, type FavoriteKind, } from 'officerdb'; @@ -80,6 +87,86 @@ musicRouter.delete('/now-playing', async (ctx) => { return ctx.json({ ok: true }); }); +// ── Named playlists ────────────────────────────────────────────────────────────────────────────── +// Playlist item `key`s are opaque track homePaths, same contract as favorites. Every route is scoped to +// the caller's user id (a playlist id that isn't theirs reads/writes as 404). + +const MAX_NAME = 200; +const cleanName = (v: unknown): string | null => { + if (typeof v !== 'string') return null; + const n = v.trim(); + return n && n.length <= MAX_NAME ? n : null; +}; +const asKeys = (v: unknown): string[] | null => + Array.isArray(v) && v.every((k) => typeof k === 'string' && k) ? (v as string[]) : null; + +// GET /playlists → [{ id, name, count, createdAt, updatedAt }] (most-recently-updated first). +musicRouter.get('/playlists', async (ctx) => { + return ctx.json(await getPlaylists(ctx.get('user').id)); +}); + +// POST /playlists { name } → create; 409 if the name is already taken. +musicRouter.post('/playlists', async (ctx) => { + const { name } = (await ctx.req.json().catch(() => ({}))) as { name?: unknown }; + const n = cleanName(name); + if (!n) return ctx.json({ error: 'name required (1-200 chars)' }, 400); + const row = await createPlaylist(ctx.get('user').id, n); + if (!row) return ctx.json({ error: 'a playlist with that name already exists' }, 409); + return ctx.json(row, 201); +}); + +// GET /playlists/:id → { id, name, items: string[], createdAt, updatedAt }; 404 if not the user's. +musicRouter.get('/playlists/:id', async (ctx) => { + const id = Number(ctx.req.param('id')); + if (!Number.isInteger(id)) return ctx.json({ error: 'bad id' }, 400); + const pl = await getPlaylist(ctx.get('user').id, id); + return pl ? ctx.json(pl) : ctx.json({ error: 'not found' }, 404); +}); + +// PATCH /playlists/:id { name } → rename; 404 if not the user's, 409 on a name collision. +musicRouter.patch('/playlists/:id', async (ctx) => { + const id = Number(ctx.req.param('id')); + if (!Number.isInteger(id)) return ctx.json({ error: 'bad id' }, 400); + const { name } = (await ctx.req.json().catch(() => ({}))) as { name?: unknown }; + const n = cleanName(name); + if (!n) return ctx.json({ error: 'name required (1-200 chars)' }, 400); + const userId = ctx.get('user').id; + // Distinguish "not yours" (404) from "name collides" (409): confirm ownership first. + if (!(await getPlaylist(userId, id))) return ctx.json({ error: 'not found' }, 404); + const ok = await renamePlaylist(userId, id, n); + return ok ? ctx.json({ ok: true }) : ctx.json({ error: 'a playlist with that name already exists' }, 409); +}); + +// DELETE /playlists/:id → delete (items cascade); 404 if not the user's. +musicRouter.delete('/playlists/:id', async (ctx) => { + const id = Number(ctx.req.param('id')); + if (!Number.isInteger(id)) return ctx.json({ error: 'bad id' }, 400); + const ok = await deletePlaylist(ctx.get('user').id, id); + return ok ? ctx.json({ ok: true }) : ctx.json({ error: 'not found' }, 404); +}); + +// POST /playlists/:id/items { keys: string[] } → append to the end. Returns { count }. +musicRouter.post('/playlists/:id/items', async (ctx) => { + const id = Number(ctx.req.param('id')); + if (!Number.isInteger(id)) return ctx.json({ error: 'bad id' }, 400); + const { keys } = (await ctx.req.json().catch(() => ({}))) as { keys?: unknown }; + const ks = asKeys(keys); + if (!ks) return ctx.json({ error: 'keys[] required' }, 400); + const count = await addPlaylistItems(ctx.get('user').id, id, ks); + return count === null ? ctx.json({ error: 'not found' }, 404) : ctx.json({ count }); +}); + +// PUT /playlists/:id/items { keys: string[] } → replace the whole ordered list (reorder / remove). +musicRouter.put('/playlists/:id/items', async (ctx) => { + const id = Number(ctx.req.param('id')); + if (!Number.isInteger(id)) return ctx.json({ error: 'bad id' }, 400); + const { keys } = (await ctx.req.json().catch(() => ({}))) as { keys?: unknown }; + const ks = asKeys(keys); + if (!ks) return ctx.json({ error: 'keys[] required' }, 400); + const count = await setPlaylistItems(ctx.get('user').id, id, ks); + return count === null ? ctx.json({ error: 'not found' }, 404) : ctx.json({ count }); +}); + const PREFIX = '/api/music'; musicRouter.all('/*', async (ctx) => {