From 5129f7827f1a425226020a4baf9732732afc12bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 6 Mar 2026 11:35:11 +0000 Subject: [PATCH] dashboards: migrate from filesystem to postgresql Replace JSON file storage with DB tables for dashboard layouts, screens, projects, and terminal defaults. Fresh drizzle migration with dashboardDefaults table and new columns on screens/projects. Co-Authored-By: Claude Opus 4.6 --- ..._meteorite.sql => 0000_futuristic_ink.sql} | 103 +-- .../0001_rename_workspaces_to_dashboards.sql | 4 - .../migrations/meta/0000_snapshot.json | 631 +++++++++++------- .../officer_db/migrations/meta/_journal.json | 11 +- src/databases/officer_db/src/index.ts | 12 + .../officer_db/src/queries/dashboards.ts | 219 ++++++ .../officer_db/src/schema/dashboards.ts | 92 ++- src/servers/api/dashboards/dashboards.ts | 152 +++-- src/servers/api/dashboards/index.ts | 2 - src/servers/api/dashboards/types.ts | 3 - src/servers/api/dashboards/utils.ts | 263 -------- src/servers/data-path.ts | 4 - 12 files changed, 871 insertions(+), 625 deletions(-) rename src/databases/officer_db/migrations/{0000_clammy_meteorite.sql => 0000_futuristic_ink.sql} (86%) delete mode 100644 src/databases/officer_db/migrations/0001_rename_workspaces_to_dashboards.sql create mode 100644 src/databases/officer_db/src/queries/dashboards.ts delete mode 100644 src/servers/api/dashboards/types.ts delete mode 100644 src/servers/api/dashboards/utils.ts diff --git a/src/databases/officer_db/migrations/0000_clammy_meteorite.sql b/src/databases/officer_db/migrations/0000_futuristic_ink.sql similarity index 86% rename from src/databases/officer_db/migrations/0000_clammy_meteorite.sql rename to src/databases/officer_db/migrations/0000_futuristic_ink.sql index 23dae652..26c7419e 100644 --- a/src/databases/officer_db/migrations/0000_clammy_meteorite.sql +++ b/src/databases/officer_db/migrations/0000_futuristic_ink.sql @@ -48,10 +48,8 @@ CREATE TABLE "user_integrations" ( "id" serial PRIMARY KEY NOT NULL, "user_id" integer NOT NULL, "provider" text NOT NULL, - "access_token" text, - "refresh_token" text, - "expires_at" timestamp with time zone, - "profile" jsonb, + "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") @@ -115,28 +113,16 @@ CREATE TABLE "chat_sessions" ( "updated_at" timestamp with time zone DEFAULT now() NOT NULL ); --> statement-breakpoint -CREATE TABLE "projects" ( +CREATE TABLE "dashboard_defaults" ( "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, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "terminals" jsonb DEFAULT '{}'::jsonb NOT NULL, + "host_terminals" jsonb DEFAULT '{}'::jsonb NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "uq_projects_user_slug" UNIQUE("user_id","slug") + CONSTRAINT "dashboard_defaults_user_id_unique" UNIQUE("user_id") ); --> statement-breakpoint -CREATE TABLE "screens" ( - "id" serial PRIMARY KEY NOT NULL, - "user_id" integer NOT NULL, - "name" text NOT NULL, - "layout" jsonb DEFAULT '[]'::jsonb NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "uq_screens_user_name" UNIQUE("user_id","name") -); ---> statement-breakpoint -CREATE TABLE "workspaces" ( +CREATE TABLE "dashboards" ( "id" text PRIMARY KEY NOT NULL, "user_id" integer NOT NULL, "name" text NOT NULL, @@ -147,7 +133,31 @@ CREATE TABLE "workspaces" ( "sort_order" integer DEFAULT 0 NOT NULL, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "uq_workspaces_user_id" UNIQUE("user_id","id") + 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, + "name" text NOT NULL, + "layout" jsonb DEFAULT '[]'::jsonb NOT NULL, + "terminals" jsonb DEFAULT '{}'::jsonb NOT NULL, + "host_terminals" jsonb DEFAULT '{}'::jsonb NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "uq_screens_user_name" UNIQUE("user_id","name") ); --> statement-breakpoint CREATE TABLE "extensions" ( @@ -185,20 +195,6 @@ CREATE TABLE "processes" ( CONSTRAINT "uq_processes_scope_user_dir" UNIQUE("scope","user_id","dir_name") ); --> statement-breakpoint -CREATE TABLE "resources" ( - "id" serial PRIMARY KEY NOT NULL, - "scope" text NOT NULL, - "dir_name" text NOT NULL, - "name" text NOT NULL, - "description" text, - "body" text, - "version" integer DEFAULT 1 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 "uq_resources_scope_dir" UNIQUE("scope","dir_name") -); ---> statement-breakpoint CREATE TABLE "skills" ( "id" serial PRIMARY KEY NOT NULL, "scope" text NOT NULL, @@ -296,18 +292,49 @@ CREATE TABLE "server_config" ( "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, + "provider" text NOT NULL, + "email" text NOT NULL, + "display_name" text, + "imap_host" text NOT NULL, + "imap_port" integer NOT NULL, + "imap_secure" boolean DEFAULT true NOT NULL, + "auth_type" text NOT NULL, + "credentials" jsonb DEFAULT '{}'::jsonb NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "status" text DEFAULT 'connected' NOT NULL, + "sync_meta" jsonb DEFAULT '{}'::jsonb NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "uq_email_accounts_user_email" UNIQUE("user_id","email") +); +--> 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 "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 "chat_groups" ADD CONSTRAINT "chat_groups_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_session_id_chat_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."chat_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "chat_sessions" ADD CONSTRAINT "chat_sessions_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 "workspaces" ADD CONSTRAINT "workspaces_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "extensions" ADD CONSTRAINT "extensions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "processes" ADD CONSTRAINT "processes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "skills" ADD CONSTRAINT "skills_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint @@ -316,6 +343,7 @@ ALTER TABLE "tools" ADD CONSTRAINT "tools_user_id_users_id_fk" FOREIGN KEY ("use 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 CREATE INDEX "idx_token_blacklist_expires" ON "token_blacklist" USING btree ("expires_at");--> statement-breakpoint CREATE INDEX "idx_chat_groups_user" ON "chat_groups" USING btree ("user_id");--> statement-breakpoint CREATE INDEX "idx_messages_session_order" ON "chat_messages" USING btree ("session_id","sort_order");--> statement-breakpoint @@ -327,7 +355,6 @@ CREATE INDEX "idx_extensions_user" ON "extensions" USING btree ("user_id");--> s CREATE INDEX "idx_item_chats_type_item" ON "item_chats" USING btree ("item_type","item_id");--> statement-breakpoint CREATE INDEX "idx_processes_scope" ON "processes" USING btree ("scope");--> statement-breakpoint CREATE INDEX "idx_processes_user" ON "processes" USING btree ("user_id");--> statement-breakpoint -CREATE INDEX "idx_resources_scope" ON "resources" USING btree ("scope");--> statement-breakpoint CREATE INDEX "idx_skills_scope" ON "skills" USING btree ("scope");--> statement-breakpoint CREATE INDEX "idx_skills_user" ON "skills" USING btree ("user_id");--> statement-breakpoint CREATE INDEX "idx_tasks_scope" ON "tasks" USING btree ("scope");--> statement-breakpoint diff --git a/src/databases/officer_db/migrations/0001_rename_workspaces_to_dashboards.sql b/src/databases/officer_db/migrations/0001_rename_workspaces_to_dashboards.sql deleted file mode 100644 index 6107e76e..00000000 --- a/src/databases/officer_db/migrations/0001_rename_workspaces_to_dashboards.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE "workspaces" RENAME TO "dashboards";--> statement-breakpoint -ALTER TABLE "dashboards" DROP CONSTRAINT "workspaces_user_id_users_id_fk";--> 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 "dashboards" RENAME CONSTRAINT "uq_workspaces_user_id" TO "uq_dashboards_user_id"; diff --git a/src/databases/officer_db/migrations/meta/0000_snapshot.json b/src/databases/officer_db/migrations/meta/0000_snapshot.json index 2911c3fb..e07dcce1 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": "65f7442c-6226-471f-b6b3-d559e820ab61", + "id": "7187e43e-021e-4623-8c74-8e5e7bc73926", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -347,29 +347,18 @@ "primaryKey": false, "notNull": true }, - "access_token": { - "name": "access_token", - "type": "text", + "server_integration_id": { + "name": "server_integration_id", + "type": "integer", "primaryKey": false, "notNull": false }, - "refresh_token": { - "name": "refresh_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "profile": { - "name": "profile", + "config": { + "name": "config", "type": "jsonb", "primaryKey": false, - "notNull": false + "notNull": true, + "default": "'{}'::jsonb" }, "created_at": { "name": "created_at", @@ -400,6 +389,19 @@ ], "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": {}, @@ -929,6 +931,177 @@ "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": "", @@ -972,6 +1145,13 @@ "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", @@ -1047,6 +1227,20 @@ "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", @@ -1086,109 +1280,6 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.workspaces": { - "name": "workspaces", - "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": { - "workspaces_user_id_users_id_fk": { - "name": "workspaces_user_id_users_id_fk", - "tableFrom": "workspaces", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "uq_workspaces_user_id": { - "name": "uq_workspaces_user_id", - "nullsNotDistinct": false, - "columns": [ - "user_id", - "id" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, "public.extensions": { "name": "extensions", "schema": "", @@ -1514,108 +1605,6 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.resources": { - "name": "resources", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "dir_name": { - "name": "dir_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "body": { - "name": "body", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "version": { - "name": "version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 1 - }, - "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": { - "idx_resources_scope": { - "name": "idx_resources_scope", - "columns": [ - { - "expression": "scope", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "uq_resources_scope_dir": { - "name": "uq_resources_scope_dir", - "nullsNotDistinct": false, - "columns": [ - "scope", - "dir_name" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, "public.skills": { "name": "skills", "schema": "", @@ -2427,6 +2416,200 @@ "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 } }, "enums": {}, diff --git a/src/databases/officer_db/migrations/meta/_journal.json b/src/databases/officer_db/migrations/meta/_journal.json index 51dcb83c..53cf823f 100644 --- a/src/databases/officer_db/migrations/meta/_journal.json +++ b/src/databases/officer_db/migrations/meta/_journal.json @@ -5,15 +5,8 @@ { "idx": 0, "version": "7", - "when": 1772121115932, - "tag": "0000_clammy_meteorite", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1772265600000, - "tag": "0001_rename_workspaces_to_dashboards", + "when": 1772795711164, + "tag": "0000_futuristic_ink", "breakpoints": true } ] diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 865cef1f..47e459d9 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -52,5 +52,17 @@ export { getAllSyncedAccounts, } from './queries/email-accounts'; +export { + getAllDashboardState, + upsertDashboard, + deleteDashboard, + upsertScreen, + deleteScreen, + upsertProject, + deleteProject, + getDefaults, + upsertDefaults, +} from './queries/dashboards'; + export { db } from './db'; export * as schema from './schema'; diff --git a/src/databases/officer_db/src/queries/dashboards.ts b/src/databases/officer_db/src/queries/dashboards.ts new file mode 100644 index 00000000..f4581069 --- /dev/null +++ b/src/databases/officer_db/src/queries/dashboards.ts @@ -0,0 +1,219 @@ +import { eq, and } from 'drizzle-orm'; +import { db } from '../db'; +import { dashboards, screens, projects, dashboardDefaults } from '../schema'; + +// ── Full state read ── + +export async function getAllDashboardState(userId: number): Promise> { + const result: Record = {}; + + const [dashRows, screenRows, projectRows, defaultsRow] = await Promise.all([ + db.select().from(dashboards).where(eq(dashboards.userId, userId)), + db.select().from(screens).where(eq(screens.userId, userId)), + db.select().from(projects).where(eq(projects.userId, userId)), + db + .select() + .from(dashboardDefaults) + .where(eq(dashboardDefaults.userId, userId)) + .then((rows) => rows[0]), + ]); + + // Dashboards → workspaces array + per-dashboard keys + if (dashRows.length > 0) { + const sorted = [...dashRows].sort((a, b) => a.sortOrder - b.sortOrder); + result['workspaces'] = sorted.map((d) => ({ id: d.id, name: d.name, ...(d.config as object) })); + + for (const d of dashRows) { + result[`ws-layout-${d.id}`] = d.layout; + result[`ws-terminals-${d.id}`] = d.terminals; + result[`ws-host-terminals-${d.id}`] = d.hostTerminals; + } + } + + // Defaults + if (defaultsRow) { + result['ws-terminals-default'] = defaultsRow.terminals; + result['ws-host-terminals-default'] = defaultsRow.hostTerminals; + } + + // Screens + for (const s of screenRows) { + result[`screens/${s.name}`] = s.layout; + } + + // Projects + const projectList = projectRows.map((p) => ({ + ...(p.meta as object), + id: p.slug, + cwd: `/Projects/${p.slug}`, + })); + result['projects'] = projectList; + + for (const p of projectRows) { + result[`proj-layout-${p.slug}`] = p.layout; + result[`proj-terminals-${p.slug}`] = p.terminals; + result[`proj-host-terminals-${p.slug}`] = p.hostTerminals; + } + + return result; +} + +// ── Dashboard CRUD ── + +type UpsertDashboardData = { + name?: string; + config?: unknown; + layout?: unknown; + terminals?: unknown; + hostTerminals?: unknown; + sortOrder?: number; +}; + +export async function upsertDashboard(userId: number, id: string, data: UpsertDashboardData): Promise { + const now = new Date(); + const existing = await db + .select() + .from(dashboards) + .where(and(eq(dashboards.userId, userId), eq(dashboards.id, id))) + .then((rows) => rows[0]); + + if (existing) { + const set: Record = { updatedAt: now }; + if (data.name !== undefined) set.name = data.name; + if (data.config !== undefined) set.config = data.config; + if (data.layout !== undefined) set.layout = data.layout; + if (data.terminals !== undefined) set.terminals = data.terminals; + if (data.hostTerminals !== undefined) set.hostTerminals = data.hostTerminals; + if (data.sortOrder !== undefined) set.sortOrder = data.sortOrder; + await db.update(dashboards).set(set).where(eq(dashboards.id, id)); + } else { + await db.insert(dashboards).values({ + id, + userId, + name: data.name ?? id, + config: data.config ?? {}, + layout: data.layout ?? [], + terminals: data.terminals ?? [], + hostTerminals: data.hostTerminals ?? [], + sortOrder: data.sortOrder ?? 0, + createdAt: now, + updatedAt: now, + }); + } +} + +export async function deleteDashboard(userId: number, id: string): Promise { + await db.delete(dashboards).where(and(eq(dashboards.userId, userId), eq(dashboards.id, id))); +} + +// ── Screen CRUD ── + +type UpsertScreenData = { + layout?: unknown; + terminals?: unknown; + hostTerminals?: unknown; +}; + +export async function upsertScreen(userId: number, name: string, data: UpsertScreenData): Promise { + const now = new Date(); + const set: Record = { updatedAt: now }; + if (data.layout !== undefined) set.layout = data.layout; + if (data.terminals !== undefined) set.terminals = data.terminals; + if (data.hostTerminals !== undefined) set.hostTerminals = data.hostTerminals; + + await db + .insert(screens) + .values({ + userId, + name, + layout: (data.layout ?? []) as never, + terminals: (data.terminals ?? {}) as never, + hostTerminals: (data.hostTerminals ?? {}) as never, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [screens.userId, screens.name], + set, + }); +} + +export async function deleteScreen(userId: number, name: string): Promise { + await db.delete(screens).where(and(eq(screens.userId, userId), eq(screens.name, name))); +} + +// ── Project CRUD ── + +type UpsertProjectData = { + meta?: unknown; + layout?: unknown; + terminals?: unknown; + hostTerminals?: unknown; +}; + +export async function upsertProject(userId: number, slug: string, data: UpsertProjectData): Promise { + const now = new Date(); + const existing = await db + .select() + .from(projects) + .where(and(eq(projects.userId, userId), eq(projects.slug, slug))) + .then((rows) => rows[0]); + + if (existing) { + const set: Record = { updatedAt: now }; + if (data.meta !== undefined) set.meta = data.meta; + if (data.layout !== undefined) set.layout = data.layout; + if (data.terminals !== undefined) set.terminals = data.terminals; + if (data.hostTerminals !== undefined) set.hostTerminals = data.hostTerminals; + await db + .update(projects) + .set(set) + .where(and(eq(projects.userId, userId), eq(projects.slug, slug))); + } else { + await db.insert(projects).values({ + userId, + slug, + meta: (data.meta ?? {}) as never, + layout: (data.layout ?? []) as never, + terminals: (data.terminals ?? []) as never, + hostTerminals: (data.hostTerminals ?? {}) as never, + createdAt: now, + updatedAt: now, + }); + } +} + +export async function deleteProject(userId: number, slug: string): Promise { + await db.delete(projects).where(and(eq(projects.userId, userId), eq(projects.slug, slug))); +} + +// ── Defaults ── + +type UpsertDefaultsData = { + terminals?: unknown; + hostTerminals?: unknown; +}; + +export async function getDefaults(userId: number): Promise<{ terminals: unknown; hostTerminals: unknown }> { + const [row] = await db.select().from(dashboardDefaults).where(eq(dashboardDefaults.userId, userId)); + return { terminals: row?.terminals ?? {}, hostTerminals: row?.hostTerminals ?? {} }; +} + +export async function upsertDefaults(userId: number, data: UpsertDefaultsData): Promise { + const now = new Date(); + const set: Record = { updatedAt: now }; + if (data.terminals !== undefined) set.terminals = data.terminals; + if (data.hostTerminals !== undefined) set.hostTerminals = data.hostTerminals; + + await db + .insert(dashboardDefaults) + .values({ + userId, + terminals: (data.terminals ?? {}) as never, + hostTerminals: (data.hostTerminals ?? {}) as never, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: dashboardDefaults.userId, + set, + }); +} diff --git a/src/databases/officer_db/src/schema/dashboards.ts b/src/databases/officer_db/src/schema/dashboards.ts index 981397a2..1f6127b3 100644 --- a/src/databases/officer_db/src/schema/dashboards.ts +++ b/src/databases/officer_db/src/schema/dashboards.ts @@ -1,40 +1,66 @@ import { pgTable, serial, text, integer, timestamp, jsonb, unique } from 'drizzle-orm/pg-core'; import { users } from './auth'; -export const dashboards = pgTable('dashboards', { - id: text('id').primaryKey(), - userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), - name: text('name').notNull(), - config: jsonb('config').notNull().default({}), - layout: jsonb('layout').notNull().default([]), - terminals: jsonb('terminals').notNull().default([]), - hostTerminals: jsonb('host_terminals').notNull().default([]), - sortOrder: integer('sort_order').notNull().default(0), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), -}, (table) => [ - unique('uq_dashboards_user_id').on(table.userId, table.id), -]); +export const dashboards = pgTable( + 'dashboards', + { + id: text('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + config: jsonb('config').notNull().default({}), + layout: jsonb('layout').notNull().default([]), + terminals: jsonb('terminals').notNull().default([]), + hostTerminals: jsonb('host_terminals').notNull().default([]), + sortOrder: integer('sort_order').notNull().default(0), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [unique('uq_dashboards_user_id').on(table.userId, table.id)], +); -export const screens = pgTable('screens', { - id: serial('id').primaryKey(), - userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), - name: text('name').notNull(), - layout: jsonb('layout').notNull().default([]), - updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), -}, (table) => [ - unique('uq_screens_user_name').on(table.userId, table.name), -]); +export const screens = pgTable( + 'screens', + { + id: serial('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + layout: jsonb('layout').notNull().default([]), + terminals: jsonb('terminals').notNull().default({}), + hostTerminals: jsonb('host_terminals').notNull().default({}), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [unique('uq_screens_user_name').on(table.userId, table.name)], +); -export const projects = pgTable('projects', { +export const projects = pgTable( + 'projects', + { + id: serial('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + slug: text('slug').notNull(), + meta: jsonb('meta').notNull().default({}), + layout: jsonb('layout').notNull().default([]), + terminals: jsonb('terminals').notNull().default([]), + hostTerminals: jsonb('host_terminals').notNull().default({}), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [unique('uq_projects_user_slug').on(table.userId, table.slug)], +); + +export const dashboardDefaults = pgTable('dashboard_defaults', { id: serial('id').primaryKey(), - userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), - slug: text('slug').notNull(), - meta: jsonb('meta').notNull().default({}), - layout: jsonb('layout').notNull().default([]), - terminals: jsonb('terminals').notNull().default([]), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }) + .unique(), + terminals: jsonb('terminals').notNull().default({}), + hostTerminals: jsonb('host_terminals').notNull().default({}), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), -}, (table) => [ - unique('uq_projects_user_slug').on(table.userId, table.slug), -]); +}); diff --git a/src/servers/api/dashboards/dashboards.ts b/src/servers/api/dashboards/dashboards.ts index af47a6e7..1dd47b12 100644 --- a/src/servers/api/dashboards/dashboards.ts +++ b/src/servers/api/dashboards/dashboards.ts @@ -1,8 +1,18 @@ -import { mkdir, readdir, rm, cp } from 'node:fs/promises'; +import { mkdir, rm, cp } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { readdirSync } from 'node:fs'; import { createRouter } from '@@/create-router'; -import { getDirs, migrateDashboardsDir, migrateFromState, migrateHomepageToScreens, readAllDashboardsState, resolveKey, writeJsonFile } from './utils'; +import { getUserProjectsDir } from '@@/data-path'; +import { + getAllDashboardState, + upsertDashboard, + deleteDashboard, + upsertScreen, + deleteScreen, + upsertProject, + deleteProject, + upsertDefaults, +} from 'officerdb'; const TEMPLATES_DIR = resolve(import.meta.dir, '../../../../seed/project-templates'); @@ -10,51 +20,96 @@ export const dashboardsRouter = createRouter(); // GET /dashboards dashboardsRouter.get('/', async (ctx) => { - const email = ctx.get('user').email; - - await migrateDashboardsDir(email); - - const dirs = getDirs(email); - - const dirFile = Bun.file(join(dirs.dashDir, 'index.json')); - if (!(await dirFile.exists())) { - await migrateFromState(email, dirs); - } - - await migrateHomepageToScreens(dirs, email); - - const state = await readAllDashboardsState(dirs); + const userId = ctx.get('user').id; + const state = await getAllDashboardState(userId); return ctx.json(state); }); // PATCH /dashboards dashboardsRouter.patch('/', async (ctx) => { - const email = ctx.get('user').email; + const user = ctx.get('user'); + const userId = user.id; const body = ctx.get('body') as Record; - const dirs = getDirs(email); - - await mkdir(dirs.dashDir, { recursive: true }); for (const [key, value] of Object.entries(body)) { - // Handle proj-meta-{slug}: create/update/delete project + // workspaces — array of dashboard definitions with ordering + if (key === 'workspaces') { + const workspaces = value as Array<{ id: string; name: string; [k: string]: unknown }>; + for (let i = 0; i < workspaces.length; i++) { + const ws = workspaces[i]!; + const { id, name, ...config } = ws; + await upsertDashboard(userId, id, { name, config, sortOrder: i }); + } + continue; + } + + // ws-layout-{id} + const wsLayoutMatch = key.match(/^ws-layout-(.+)$/); + if (wsLayoutMatch) { + const id = wsLayoutMatch[1]!; + if (value === null) { + await deleteDashboard(userId, id); + } else { + await upsertDashboard(userId, id, { layout: value }); + } + continue; + } + + // ws-terminals-default / ws-host-terminals-default + if (key === 'ws-terminals-default') { + await upsertDefaults(userId, { terminals: value }); + continue; + } + if (key === 'ws-host-terminals-default') { + await upsertDefaults(userId, { hostTerminals: value }); + continue; + } + + // ws-terminals-{id} + const wsTerminalsMatch = key.match(/^ws-terminals-(.+)$/); + if (wsTerminalsMatch) { + const id = wsTerminalsMatch[1]!; + await upsertDashboard(userId, id, { terminals: value }); + continue; + } + + // ws-host-terminals-{id} + const wsHostTerminalsMatch = key.match(/^ws-host-terminals-(.+)$/); + if (wsHostTerminalsMatch) { + const id = wsHostTerminalsMatch[1]!; + await upsertDashboard(userId, id, { hostTerminals: value }); + continue; + } + + // screens/{name} + const screensMatch = key.match(/^screens\/(.+)$/); + if (screensMatch) { + const name = screensMatch[1]!; + if (value === null) { + await deleteScreen(userId, name); + } else { + await upsertScreen(userId, name, { layout: value }); + } + continue; + } + + // proj-meta-{slug} — create/update/delete project const projMetaMatch = key.match(/^proj-meta-(.+)$/); if (projMetaMatch) { const slug = projMetaMatch[1]!; - const projectDir = join(dirs.projDir, slug); + const projectDir = join(getUserProjectsDir(user.email), slug); if (value === null) { + await deleteProject(userId, slug); await rm(projectDir, { recursive: true, force: true }); continue; } - const officerdevDir = join(projectDir, '.officerdev'); - const metaFile = join(officerdevDir, 'meta.json'); - const isNew = !(await Bun.file(metaFile).exists()); - await mkdir(officerdevDir, { recursive: true }); - await writeJsonFile(metaFile, value); + const meta = value as Record; + const isNew = !(await Bun.file(join(projectDir, '.officerdev', 'meta.json')).exists()); + await upsertProject(userId, slug, { meta: value }); if (isNew) { - const meta = value as Record; if (meta.projectType === 'app') { const templateDir = join(TEMPLATES_DIR, 'simple-app-template'); const entries = readdirSync(templateDir); @@ -63,7 +118,9 @@ dashboardsRouter.patch('/', async (ctx) => { await cp(join(templateDir, entry), join(projectDir, entry), { recursive: true }); } const pkgPath = join(projectDir, 'package.json'); - const pkg = await Bun.file(pkgPath).json().catch(() => null); + const pkg = await Bun.file(pkgPath) + .json() + .catch(() => null); if (pkg) { pkg.name = slug; await Bun.write(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); @@ -77,26 +134,31 @@ dashboardsRouter.patch('/', async (ctx) => { continue; } - const mapping = resolveKey(dirs, key); - if (!mapping) continue; - - if (value === null) { - try { - await rm(mapping.file, { force: true }); - if (mapping.dir) { - const remaining = await readdir(mapping.dir); - if (remaining.length === 0) await rm(mapping.dir, { recursive: true, force: true }); - } - } catch { - // ignore - } + // proj-layout-{slug} + const projLayoutMatch = key.match(/^proj-layout-(.+)$/); + if (projLayoutMatch) { + const slug = projLayoutMatch[1]!; + await upsertProject(userId, slug, { layout: value }); continue; } - if (mapping.dir) await mkdir(mapping.dir, { recursive: true }); - await writeJsonFile(mapping.file, value); + // proj-terminals-{slug} + const projTerminalsMatch = key.match(/^proj-terminals-(.+)$/); + if (projTerminalsMatch) { + const slug = projTerminalsMatch[1]!; + await upsertProject(userId, slug, { terminals: value }); + continue; + } + + // proj-host-terminals-{slug} + const projHostTerminalsMatch = key.match(/^proj-host-terminals-(.+)$/); + if (projHostTerminalsMatch) { + const slug = projHostTerminalsMatch[1]!; + await upsertProject(userId, slug, { hostTerminals: value }); + continue; + } } - const state = await readAllDashboardsState(dirs); + const state = await getAllDashboardState(userId); return ctx.json(state); }); diff --git a/src/servers/api/dashboards/index.ts b/src/servers/api/dashboards/index.ts index 0732128f..954313fd 100644 --- a/src/servers/api/dashboards/index.ts +++ b/src/servers/api/dashboards/index.ts @@ -1,3 +1 @@ export * from './dashboards'; -export * from './types'; -export * from './utils'; diff --git a/src/servers/api/dashboards/types.ts b/src/servers/api/dashboards/types.ts deleted file mode 100644 index 722c0920..00000000 --- a/src/servers/api/dashboards/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type KeyMapping = { file: string; dir?: string }; - -export type ResolveDirs = { dashDir: string; screensDir: string; projDir: string }; diff --git a/src/servers/api/dashboards/utils.ts b/src/servers/api/dashboards/utils.ts deleted file mode 100644 index 572c8e8c..00000000 --- a/src/servers/api/dashboards/utils.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { mkdir, readdir, rename, rm } from 'node:fs/promises'; -import { join } from 'node:path'; -import { getUserDashboardsDir, getUserHomepageDashboardDir, getUserStateFile, getUserProjectsDir, DATA_PATH } from '@@/data-path'; -import type { KeyMapping, ResolveDirs } from './types'; - -const RESERVED_DIRS = new Set(['screens']); - -export function resolveKey(dirs: ResolveDirs, key: string): KeyMapping | null { - if (key === 'workspaces') return { file: join(dirs.dashDir, 'index.json') }; - if (key === 'ws-terminals-default') return { file: join(dirs.dashDir, 'default-terminals.json') }; - if (key === 'ws-host-terminals-default') return { file: join(dirs.dashDir, 'default-host-terminals.json') }; - - // screens/{name} → screens/{name}/layout.json - const screensMatch = key.match(/^screens\/(.+)$/); - if (screensMatch) { - const name = screensMatch[1]!; - const dir = join(dirs.screensDir, name); - return { file: join(dir, 'layout.json'), dir }; - } - - const layoutMatch = key.match(/^ws-layout-(.+)$/); - if (layoutMatch) { - const id = layoutMatch[1]!; - const dir = join(dirs.dashDir, id); - return { file: join(dir, 'layout.json'), dir }; - } - - const terminalsMatch = key.match(/^ws-terminals-(.+)$/); - if (terminalsMatch) { - const id = terminalsMatch[1]!; - const dir = join(dirs.dashDir, id); - return { file: join(dir, 'terminals.json'), dir }; - } - - const hostTerminalsMatch = key.match(/^ws-host-terminals-(.+)$/); - if (hostTerminalsMatch) { - const id = hostTerminalsMatch[1]!; - const dir = join(dirs.dashDir, id); - return { file: join(dir, 'host-terminals.json'), dir }; - } - - // Project keys — stored in {projDir}/{slug}/.officerdev/ - const projMetaMatch = key.match(/^proj-meta-(.+)$/); - if (projMetaMatch) { - const slug = projMetaMatch[1]!; - const dir = join(dirs.projDir, slug, '.officerdev'); - return { file: join(dir, 'meta.json'), dir }; - } - - const projLayoutMatch = key.match(/^proj-layout-(.+)$/); - if (projLayoutMatch) { - const slug = projLayoutMatch[1]!; - const dir = join(dirs.projDir, slug, '.officerdev'); - return { file: join(dir, 'layout.json'), dir }; - } - - const projTerminalsMatch = key.match(/^proj-terminals-(.+)$/); - if (projTerminalsMatch) { - const slug = projTerminalsMatch[1]!; - const dir = join(dirs.projDir, slug, '.officerdev'); - return { file: join(dir, 'terminals.json'), dir }; - } - - const projHostTerminalsMatch = key.match(/^proj-host-terminals-(.+)$/); - if (projHostTerminalsMatch) { - const slug = projHostTerminalsMatch[1]!; - const dir = join(dirs.projDir, slug, '.officerdev'); - return { file: join(dir, 'host-terminals.json'), dir }; - } - - return null; -} - -export async function readJsonFile(path: string): Promise { - try { - const file = Bun.file(path); - if (!(await file.exists())) return null; - return await file.json(); - } catch { - return null; - } -} - -export async function writeJsonFile(path: string, data: unknown) { - await Bun.write(path, JSON.stringify(data, null, 2)); -} - -export async function migrateFromState(email: string, dirs: ResolveDirs) { - const stateFile = getUserStateFile(email); - const file = Bun.file(stateFile); - if (!(await file.exists())) return; - - let state: Record; - try { state = (await file.json()) as Record; } catch { return; } - const wsKeys = Object.keys(state).filter( - (k) => k === 'workspaces' || k.startsWith('ws-layout-') || k.startsWith('ws-terminals-') || k.startsWith('ws-host-terminals-'), - ); - if (wsKeys.length === 0) return; - - await mkdir(dirs.dashDir, { recursive: true }); - - for (const key of wsKeys) { - const migratedKey = key === 'ws-layout-workspaces' ? 'screens/homepage' : key; - const mapping = resolveKey(dirs, migratedKey); - if (!mapping) continue; - if (mapping.dir) await mkdir(mapping.dir, { recursive: true }); - await writeJsonFile(mapping.file, state[key]); - } - - const cleaned = { ...state }; - for (const key of wsKeys) delete cleaned[key]; - await Bun.write(stateFile, JSON.stringify(cleaned, null, 2)); -} - -export async function migrateHomepageToScreens(dirs: ResolveDirs, email: string) { - const oldHomepageDir = getUserHomepageDashboardDir(email); - const layoutFile = join(oldHomepageDir, 'layout.json'); - if (!(await Bun.file(layoutFile).exists())) return; - - const targetDir = join(dirs.screensDir, 'homepage'); - await mkdir(targetDir, { recursive: true }); - - for (const name of ['layout.json', 'terminals.json', 'host-terminals.json']) { - const src = join(oldHomepageDir, name); - if (await Bun.file(src).exists()) { - await rename(src, join(targetDir, name)); - } - } - - const remaining = await readdir(oldHomepageDir); - if (remaining.length === 0) await rm(oldHomepageDir, { recursive: true, force: true }); - - const oldWsHomepageDir = join(dirs.dashDir, 'ws-homepage'); - const oldWsLayout = join(oldWsHomepageDir, 'layout.json'); - if (!(await Bun.file(oldWsLayout).exists())) return; - - for (const name of ['layout.json', 'terminals.json', 'host-terminals.json']) { - const src = join(oldWsHomepageDir, name); - const target = join(targetDir, name); - if (await Bun.file(src).exists() && !(await Bun.file(target).exists())) { - await rename(src, target); - } - } - - const wsRemaining = await readdir(oldWsHomepageDir); - if (wsRemaining.length === 0) await rm(oldWsHomepageDir, { recursive: true, force: true }); -} - -// Migrate on-disk directory from old 'workspaces' name to 'dashboards' -export async function migrateDashboardsDir(email: string) { - const oldDir = join(DATA_PATH, email, 'workspaces'); - const newDir = getUserDashboardsDir(email); - try { - const oldExists = await Bun.file(join(oldDir, 'index.json')).exists() || await readdir(oldDir).then(() => true).catch(() => false); - if (oldExists) { - const newExists = await readdir(newDir).then(() => true).catch(() => false); - if (!newExists) { - await rename(oldDir, newDir); - } - } - } catch { - // ignore — old dir doesn't exist - } -} - -async function readDashboardDir(dirPath: string, id: string, result: Record) { - const layout = await readJsonFile(join(dirPath, 'layout.json')); - if (layout !== null) result[`ws-layout-${id}`] = layout; - - const terminals = await readJsonFile(join(dirPath, 'terminals.json')); - if (terminals !== null) result[`ws-terminals-${id}`] = terminals; - - const hostTerminals = await readJsonFile(join(dirPath, 'host-terminals.json')); - if (hostTerminals !== null) result[`ws-host-terminals-${id}`] = hostTerminals; -} - -async function readProjectDir(projectPath: string, slug: string, result: Record): Promise { - const officerdevDir = join(projectPath, '.officerdev'); - const meta = await readJsonFile(join(officerdevDir, 'meta.json')); - if (!meta) return null; - - const layout = await readJsonFile(join(officerdevDir, 'layout.json')); - if (layout !== null) result[`proj-layout-${slug}`] = layout; - - const terminals = await readJsonFile(join(officerdevDir, 'terminals.json')); - if (terminals !== null) result[`proj-terminals-${slug}`] = terminals; - - const hostTerminals = await readJsonFile(join(officerdevDir, 'host-terminals.json')); - if (hostTerminals !== null) result[`proj-host-terminals-${slug}`] = hostTerminals; - - return { ...(meta as object), id: slug, cwd: `/Projects/${slug}` }; -} - -async function readScreensDir(screensDir: string, result: Record) { - let entries: import('node:fs').Dirent[] = []; - try { - entries = await readdir(screensDir, { withFileTypes: true }); - } catch { - return; - } - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const layout = await readJsonFile(join(screensDir, entry.name, 'layout.json')); - if (layout !== null) result[`screens/${entry.name}`] = layout; - } -} - -export async function readAllDashboardsState(dirs: ResolveDirs): Promise> { - const result: Record = {}; - - const indexData = await readJsonFile(join(dirs.dashDir, 'index.json')); - if (indexData !== null) result['workspaces'] = indexData; - - const defaultTerminals = await readJsonFile(join(dirs.dashDir, 'default-terminals.json')); - if (defaultTerminals !== null) result['ws-terminals-default'] = defaultTerminals; - - const defaultHostTerminals = await readJsonFile(join(dirs.dashDir, 'default-host-terminals.json')); - if (defaultHostTerminals !== null) result['ws-host-terminals-default'] = defaultHostTerminals; - - // Read screens - await readScreensDir(dirs.screensDir, result); - - // Read per-dashboard subdirs - let entries: import('node:fs').Dirent[] = []; - try { - entries = await readdir(dirs.dashDir, { withFileTypes: true }); - } catch { - return result; - } - - for (const entry of entries) { - if (!entry.isDirectory() || RESERVED_DIRS.has(entry.name)) continue; - await readDashboardDir(join(dirs.dashDir, entry.name), entry.name, result); - } - - // Read project data — scan directories containing .officerdev/meta.json - const projects: unknown[] = []; - let projEntries: import('node:fs').Dirent[] = []; - try { - projEntries = await readdir(dirs.projDir, { withFileTypes: true }); - } catch { - result['projects'] = projects; - return result; - } - - for (const entry of projEntries) { - if (!entry.isDirectory()) continue; - const meta = await readProjectDir(join(dirs.projDir, entry.name), entry.name, result); - if (meta) projects.push(meta); - } - result['projects'] = projects; - - return result; -} - -export function getDirs(email: string): ResolveDirs { - return { - dashDir: getUserDashboardsDir(email), - screensDir: join(getUserDashboardsDir(email), 'screens'), - projDir: getUserProjectsDir(email), - }; -} diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts index eb9a77f3..a045972a 100644 --- a/src/servers/data-path.ts +++ b/src/servers/data-path.ts @@ -37,12 +37,8 @@ export const getUserStateDir = (email: string) => join(DATA_PATH, email, 'state' export const getUserStateFile = (email: string) => join(DATA_PATH, email, 'state', 'state.json'); -export const getUserDashboardsDir = (email: string) => join(DATA_PATH, email, 'dashboards'); - export const getUserProjectsDir = (email: string) => join(DATA_PATH, email, 'home', 'Projects'); -export const getUserHomepageDashboardDir = (email: string) => join(DATA_PATH, email, 'ws-homepage'); - export const getNativeSkillsDir = () => join(SEED_PATH, 'skills'); export const getGlobalSkillsDir = () => join(DATA_PATH, 'skills');