Compare commits

..

52 Commits

Author SHA1 Message Date
MangoPig d421a10189 Fix: export Go path for local auto migrations 2026-07-06 13:22:50 +01:00
MangoPig b232d6ef36 Fix: harden local dev startup flow 2026-07-06 13:06:32 +01:00
MangoPig 58bc285748 Refactor: migrate POSIX scaffolding to CBOR and JSONC 2026-07-06 13:06:32 +01:00
MangoPig 2c8338d1cd Merge branch 'Refactor/Code-Modularity' 2026-06-29 04:29:58 +01:00
MangoPig 95fb8629d1 Refactor: improve backend modularity 2026-06-29 04:29:05 +01:00
MangoPig fc5ef23af8 Refactor: improve code modularity 2026-06-29 03:06:18 +01:00
MangoPig c47eae1381 Merge branch 'Refactor/Sidebar-Usability' 2026-06-28 19:19:10 +01:00
MangoPig bcfe73411c Feat: improve sidebar tree usability 2026-06-28 19:04:48 +01:00
MangoPig 32acf6dc17 Merge branch 'Refactor/Usability' 2026-06-28 04:08:58 +01:00
MangoPig 6b87e8a1fe Refactor: improve bootstrap usability 2026-06-28 04:08:31 +01:00
MangoPig 3247f28c87 Merge branch 'Fix/Bootstrap' 2026-06-26 23:31:10 +01:00
MangoPig 891e8b83ed Fix: harden bootstrap worker flow 2026-06-26 23:30:50 +01:00
MangoPig ae1f347549 Merge branch 'Refactor/Code-Quality' 2026-06-26 18:33:12 +01:00
MangoPig 7e62ff6d9a Refactor: improve code quality and worker flow 2026-06-26 18:32:49 +01:00
MangoPig adcc9afe05 Merge branch 'Refactor/Folder-Layout' 2026-06-25 21:53:48 +01:00
MangoPig 24d1e472a2 Refactor: add stable folder layout foundation 2026-06-25 21:53:29 +01:00
MangoPig da1b210865 Merge branch 'Fix/Backend/Bootstrap-Personal-Folder' 2026-06-24 19:46:02 +01:00
MangoPig eadf630c61 Fix: scaffold bootstrap personal folder 2026-06-24 19:46:02 +01:00
MangoPig 4fb073a1ff Merge branch 'Fix/Frontend/Folder-Move-Persistence' 2026-06-24 18:00:05 +01:00
MangoPig 9ddfa0c3c7 Fix: persist folder drag and drop moves 2026-06-24 17:59:43 +01:00
MangoPig a92e188f84 Merge branch 'Fix/Frontend/Folder-Casing' 2026-06-24 17:24:59 +01:00
MangoPig dcf181d640 Fix: preserve folder label casing 2026-06-24 17:24:59 +01:00
MangoPig 1a8556df68 Merge branch 'Feature/Renaming-Folders' 2026-06-24 17:07:27 +01:00
MangoPig a5f0c41cba Feat: add persisted folder rename 2026-06-24 17:07:08 +01:00
MangoPig 268093d223 Merge branch 'Fix/Backend/Project-Item-Deletion' 2026-06-24 16:15:49 +01:00
MangoPig c64a7b8d44 Fix: add persisted project folder deletion 2026-06-24 16:15:30 +01:00
MangoPig 0b368b09fa Merge branch 'Refactor/Frontend/Nav-DragAndDrop-Logic' 2026-06-24 02:11:23 +01:00
MangoPig 212dd1c435 Refactor: share nav drag and drop logic 2026-06-24 02:11:02 +01:00
MangoPig 69af324b1b Merge branch 'Fix/Project-Tree-Items-Persistence' 2026-06-24 01:29:40 +01:00
MangoPig 5758074f6f Feat: persist project tree hierarchy 2026-06-24 01:28:50 +01:00
MangoPig 5b9e14b442 Merge branch 'Fix/Backend/Project-Folder-Persistence' 2026-06-22 14:15:34 +01:00
MangoPig 618e3e84be Fix: persist project folder hierarchy 2026-06-22 14:14:13 +01:00
MangoPig 2ff7fbd9e7 Merge branch 'Fix/Backend/Posix-Hierarchy-Fix' 2026-06-22 11:14:08 +01:00
MangoPig 8a94d83e7e Fix: align POSIX hierarchy contract 2026-06-22 11:13:25 +01:00
MangoPig 07590f1c4f Merge branch 'Features/Backend/Posix-DB-Projection' 2026-06-21 22:03:43 +01:00
MangoPig 3c7a73853d Feat: add POSIX DB projection 2026-06-21 22:02:59 +01:00
MangoPig 9b4f1ce197 Merge branch 'Features/Backend/Posix-Lite-Persistence' 2026-06-21 21:03:38 +01:00
MangoPig 5735e3008d Feat: add POSIX-lite bootstrap foundation 2026-06-21 21:02:59 +01:00
MangoPig 626ae02df0 Docs: Update TODO roadmap 2026-06-21 15:43:04 +01:00
MangoPig 7f47ca84fa Merge branch 'Features/Frontend/Sidebar-Folder-Creation' 2026-06-21 12:32:27 +01:00
MangoPig eac4fb423e Feat: Add draggable shell trees 2026-06-21 12:31:47 +01:00
MangoPig 14ac0f46de Merge branch 'Fix/Frontend/Projects-Menu-Polish' 2026-06-20 07:56:47 +01:00
MangoPig 5a565f8165 Fix: Polish projects menu 2026-06-20 07:56:47 +01:00
MangoPig 12cbc68db6 Merge branch 'Fix/Frontend/Bootstrap-Polish' 2026-06-19 22:47:12 +01:00
MangoPig 699574e345 Fix: Polish bootstrap flow 2026-06-19 22:47:12 +01:00
MangoPig 35c1a861f5 Merge branch 'Features/Backend/Bootstrap-Reset' 2026-06-19 19:57:46 +01:00
MangoPig 27101bbdd6 Feat: Add development bootstrap reset 2026-06-19 19:57:44 +01:00
MangoPig 6ba04effcf Feat: Hydrate shell from app state 2026-06-19 17:39:39 +01:00
MangoPig 913825f596 Feat: Add bootstrap persistence and shell routes 2026-06-19 17:39:21 +01:00
MangoPig 93ce3e07f0 Merge branch 'Features/Frontend/Future-Model-Prep' 2026-06-18 16:58:53 +01:00
MangoPig 25c6934801 Feat: Prepare frontend future model 2026-06-18 16:58:31 +01:00
MangoPig fcf96590bb Merge branch 'Features/Frontend/Context-Menu' 2026-06-18 11:17:23 +01:00
149 changed files with 16808 additions and 1941 deletions
+2
View File
@@ -27,3 +27,5 @@ tmp/
bin/ bin/
.cgcignore .cgcignore
POSIX/
+3
View File
@@ -6,6 +6,9 @@ WORKDIR /app
RUN apk add --no-cache ca-certificates curl git tzdata && update-ca-certificates RUN apk add --no-cache ca-certificates curl git tzdata && update-ca-certificates
RUN mkdir -p /tmp/home /tmp/go/pkg/mod /tmp/go-build \
&& chmod 0777 /tmp/home /tmp/go /tmp/go/pkg /tmp/go/pkg/mod /tmp/go-build
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
+52
View File
@@ -0,0 +1,52 @@
package main
import (
"context"
"fmt"
"log"
"os"
"moku-backend/internal/config"
"moku-backend/internal/database"
"moku-backend/internal/posixproj"
)
func main() {
command := "rebuild"
if len(os.Args) > 1 {
command = os.Args[1]
}
switch command {
case "rebuild":
if err := rebuildProjection(context.Background()); err != nil {
log.Fatalf("rebuild POSIX projection: %v", err)
}
default:
log.Fatalf("unsupported posix command %q (supported: rebuild)", command)
}
}
func rebuildProjection(ctx context.Context) error {
cfg := config.Load()
db, err := database.NewPostgres(cfg.PostgresURL)
if err != nil {
return fmt.Errorf("connect database: %w", err)
}
defer db.Close()
summary, err := posixproj.NewProjector(db, cfg.POSIXRoot).RebuildWithSummary(ctx)
if err != nil {
return err
}
fmt.Printf(
"POSIX projection rebuilt from %s\n total nodes: %d\n directories: %d\n files: %d\n",
cfg.POSIXRoot,
summary.TotalNodes,
summary.DirectoryCount,
summary.FileCount,
)
return nil
}
+25 -3
View File
@@ -1,10 +1,16 @@
package main package main
import ( import (
"context"
"encoding/json"
"log" "log"
"os/signal"
"syscall"
"time"
"moku-backend/internal/bootstrap" "moku-backend/internal/bootstrap"
"moku-backend/internal/process" "moku-backend/internal/jobs"
"moku-backend/internal/worker"
) )
func main() { func main() {
@@ -18,9 +24,25 @@ func main() {
} }
}() }()
app.Logger.Info("worker ready", "service", app.ServiceName, "environment", app.Config.Environment) jobStore := jobs.NewStore(app.Database)
runner := worker.NewRunner(jobStore, app.Logger, time.Second)
runner.Register(jobs.KindBootstrapStructureMaterialize, func(ctx context.Context, job jobs.Job) error {
var payload jobs.BootstrapStructureMaterializePayload
if len(job.Payload) > 0 {
if err := json.Unmarshal(job.Payload, &payload); err != nil {
return err
}
}
if err := process.WaitForShutdown(app.ServiceName, app.Logger); err != nil { return bootstrap.NewService(app.Database, app.Config.POSIXRoot).ProcessBootstrapStructureMaterialization(ctx, payload.InstallationID)
})
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
app.Logger.Info("worker ready", "service", app.ServiceName, "environment", app.Config.Environment, "pollInterval", time.Second)
if err := runner.Run(ctx); err != nil {
app.Logger.Error("worker stopped", "error", err) app.Logger.Error("worker stopped", "error", err)
log.Fatal(err) log.Fatal(err)
} }
@@ -0,0 +1,180 @@
-- +goose Up
CREATE TYPE instance_mode AS ENUM ('personal', 'organizational');
CREATE TYPE instance_access AS ENUM ('local', 'remote');
CREATE TYPE instance_protocol AS ENUM ('http', 'https');
CREATE TYPE workspace_kind AS ENUM ('organization', 'department', 'team', 'project');
CREATE TYPE membership_role AS ENUM ('owner', 'admin', 'member');
CREATE TABLE IF NOT EXISTS installations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
singleton BOOLEAN NOT NULL DEFAULT TRUE UNIQUE,
mode instance_mode NOT NULL,
access instance_access NOT NULL,
protocol instance_protocol NOT NULL DEFAULT 'http',
host TEXT NOT NULL,
is_bootstrapped BOOLEAN NOT NULL DEFAULT FALSE,
bootstrapped_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_instance_admin BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique ON users (LOWER(email));
CREATE TABLE IF NOT EXISTS user_homes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE organizations
ADD COLUMN IF NOT EXISTS created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL;
CREATE TABLE IF NOT EXISTS organization_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role membership_role NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (organization_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_organization_memberships_user_id ON organization_memberships (user_id);
CREATE TABLE IF NOT EXISTS departments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
slug TEXT NOT NULL,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (organization_id, slug)
);
CREATE INDEX IF NOT EXISTS idx_departments_organization_id ON departments (organization_id);
CREATE TABLE IF NOT EXISTS teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
department_id UUID REFERENCES departments(id) ON DELETE SET NULL,
name TEXT NOT NULL,
slug TEXT NOT NULL,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (organization_id, slug)
);
CREATE INDEX IF NOT EXISTS idx_teams_organization_id ON teams (organization_id);
CREATE INDEX IF NOT EXISTS idx_teams_department_id ON teams (department_id);
CREATE TABLE IF NOT EXISTS team_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role membership_role NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (team_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_team_memberships_user_id ON team_memberships (user_id);
CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
department_id UUID REFERENCES departments(id) ON DELETE SET NULL,
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
name TEXT NOT NULL,
slug TEXT NOT NULL,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (organization_id, slug)
);
CREATE INDEX IF NOT EXISTS idx_projects_organization_id ON projects (organization_id);
CREATE INDEX IF NOT EXISTS idx_projects_department_id ON projects (department_id);
CREATE INDEX IF NOT EXISTS idx_projects_team_id ON projects (team_id);
CREATE TABLE IF NOT EXISTS project_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role membership_role NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_project_memberships_user_id ON project_memberships (user_id);
ALTER TABLE workspaces
ADD COLUMN IF NOT EXISTS kind workspace_kind NOT NULL DEFAULT 'organization',
ADD COLUMN IF NOT EXISTS created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS department_id UUID REFERENCES departments(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS project_id UUID REFERENCES projects(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_workspaces_department_id ON workspaces (department_id);
CREATE INDEX IF NOT EXISTS idx_workspaces_team_id ON workspaces (team_id);
CREATE INDEX IF NOT EXISTS idx_workspaces_project_id ON workspaces (project_id);
-- +goose Down
DROP INDEX IF EXISTS idx_workspaces_project_id;
DROP INDEX IF EXISTS idx_workspaces_team_id;
DROP INDEX IF EXISTS idx_workspaces_department_id;
ALTER TABLE workspaces
DROP COLUMN IF EXISTS project_id,
DROP COLUMN IF EXISTS team_id,
DROP COLUMN IF EXISTS department_id,
DROP COLUMN IF EXISTS created_by_user_id,
DROP COLUMN IF EXISTS kind;
DROP INDEX IF EXISTS idx_project_memberships_user_id;
DROP TABLE IF EXISTS project_memberships;
DROP INDEX IF EXISTS idx_projects_team_id;
DROP INDEX IF EXISTS idx_projects_department_id;
DROP INDEX IF EXISTS idx_projects_organization_id;
DROP TABLE IF EXISTS projects;
DROP INDEX IF EXISTS idx_team_memberships_user_id;
DROP TABLE IF EXISTS team_memberships;
DROP INDEX IF EXISTS idx_teams_department_id;
DROP INDEX IF EXISTS idx_teams_organization_id;
DROP TABLE IF EXISTS teams;
DROP INDEX IF EXISTS idx_departments_organization_id;
DROP TABLE IF EXISTS departments;
DROP INDEX IF EXISTS idx_organization_memberships_user_id;
DROP TABLE IF EXISTS organization_memberships;
ALTER TABLE organizations
DROP COLUMN IF EXISTS created_by_user_id;
DROP TABLE IF EXISTS user_homes;
DROP INDEX IF EXISTS idx_users_email_unique;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS installations;
DROP TYPE IF EXISTS membership_role;
DROP TYPE IF EXISTS workspace_kind;
DROP TYPE IF EXISTS instance_protocol;
DROP TYPE IF EXISTS instance_access;
DROP TYPE IF EXISTS instance_mode;
@@ -0,0 +1,9 @@
-- +goose Up
ALTER TABLE installations
ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE installations
DROP COLUMN IF EXISTS name;
@@ -0,0 +1,45 @@
-- +goose Up
CREATE TYPE posix_node_kind AS ENUM ('directory', 'file');
CREATE TABLE IF NOT EXISTS posix_nodes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
path TEXT NOT NULL UNIQUE,
parent_path TEXT,
name TEXT NOT NULL,
depth INTEGER NOT NULL,
node_kind posix_node_kind NOT NULL,
logical_type TEXT NOT NULL DEFAULT 'generic',
file_role TEXT,
resource_id TEXT,
resource_name TEXT,
resource_slug TEXT,
installation_id TEXT,
organization_id TEXT,
organization_slug TEXT,
department_slug TEXT,
team_slug TEXT,
project_slug TEXT,
personal_slug TEXT,
content_json JSONB,
size_bytes BIGINT NOT NULL DEFAULT 0,
checksum TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_posix_nodes_parent_path ON posix_nodes (parent_path);
CREATE INDEX IF NOT EXISTS idx_posix_nodes_logical_type ON posix_nodes (logical_type);
CREATE INDEX IF NOT EXISTS idx_posix_nodes_project_slug ON posix_nodes (project_slug);
CREATE INDEX IF NOT EXISTS idx_posix_nodes_department_slug ON posix_nodes (department_slug);
CREATE INDEX IF NOT EXISTS idx_posix_nodes_team_slug ON posix_nodes (team_slug);
-- +goose Down
DROP INDEX IF EXISTS idx_posix_nodes_team_slug;
DROP INDEX IF EXISTS idx_posix_nodes_department_slug;
DROP INDEX IF EXISTS idx_posix_nodes_project_slug;
DROP INDEX IF EXISTS idx_posix_nodes_logical_type;
DROP INDEX IF EXISTS idx_posix_nodes_parent_path;
DROP TABLE IF EXISTS posix_nodes;
DROP TYPE IF EXISTS posix_node_kind;
@@ -0,0 +1,64 @@
-- +goose Up
CREATE TYPE bootstrap_materialization_status AS ENUM ('not_started', 'pending', 'running', 'succeeded', 'failed');
CREATE TYPE background_job_status AS ENUM ('pending', 'running', 'succeeded', 'failed');
ALTER TABLE installations
ADD COLUMN IF NOT EXISTS materialization_status bootstrap_materialization_status NOT NULL DEFAULT 'not_started',
ADD COLUMN IF NOT EXISTS materialization_error TEXT,
ADD COLUMN IF NOT EXISTS materialization_requested_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS materialization_started_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS materialization_finished_at TIMESTAMPTZ;
UPDATE installations
SET
materialization_status = CASE
WHEN is_bootstrapped THEN 'succeeded'::bootstrap_materialization_status
ELSE 'not_started'::bootstrap_materialization_status
END,
materialization_error = NULL,
materialization_requested_at = CASE
WHEN is_bootstrapped THEN COALESCE(bootstrapped_at, created_at, NOW())
ELSE NULL
END,
materialization_started_at = CASE
WHEN is_bootstrapped THEN COALESCE(bootstrapped_at, created_at, NOW())
ELSE NULL
END,
materialization_finished_at = CASE
WHEN is_bootstrapped THEN COALESCE(bootstrapped_at, created_at, NOW())
ELSE NULL
END
WHERE materialization_status = 'not_started'::bootstrap_materialization_status;
CREATE TABLE IF NOT EXISTS background_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
kind TEXT NOT NULL,
status background_job_status NOT NULL DEFAULT 'pending',
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 1,
available_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_background_jobs_claim ON background_jobs (status, available_at, created_at);
-- +goose Down
DROP INDEX IF EXISTS idx_background_jobs_claim;
DROP TABLE IF EXISTS background_jobs;
ALTER TABLE installations
DROP COLUMN IF EXISTS materialization_finished_at,
DROP COLUMN IF EXISTS materialization_started_at,
DROP COLUMN IF EXISTS materialization_requested_at,
DROP COLUMN IF EXISTS materialization_error,
DROP COLUMN IF EXISTS materialization_status;
DROP TYPE IF EXISTS background_job_status;
DROP TYPE IF EXISTS bootstrap_materialization_status;
+39 -3
View File
@@ -23,14 +23,50 @@ target "dev-image" {
tags = ["${REGISTRY}/moku/work-backend:dev-${TAG}"] tags = ["${REGISTRY}/moku/work-backend:dev-${TAG}"]
} }
target "prod-api" {
inherits = ["_app"]
target = "runtime"
args = {
SERVICE_NAME = "api"
}
tags = ["moku/work-backend:local-prod-api"]
}
target "prod-worker" {
inherits = ["_app"]
target = "runtime"
args = {
SERVICE_NAME = "worker"
}
tags = ["moku/work-backend:local-prod-worker"]
}
target "prod-api-image" {
inherits = ["_app"]
target = "runtime"
args = {
SERVICE_NAME = "api"
}
tags = ["${REGISTRY}/moku/work-backend:prod-api-${TAG}"]
}
target "prod-worker-image" {
inherits = ["_app"]
target = "runtime"
args = {
SERVICE_NAME = "worker"
}
tags = ["${REGISTRY}/moku/work-backend:prod-worker-${TAG}"]
}
group "local" { group "local" {
targets = ["dev"] targets = ["dev", "prod-api", "prod-worker"]
} }
group "registry" { group "registry" {
targets = ["dev-image"] targets = ["dev-image", "prod-api-image", "prod-worker-image"]
} }
group "default" { group "default" {
targets = ["dev"] targets = ["dev", "prod-api", "prod-worker"]
} }
+3
View File
@@ -3,11 +3,13 @@ module moku-backend
go 1.25.7 go 1.25.7
require ( require (
github.com/fxamacker/cbor/v2 v2.9.0
github.com/go-chi/chi/v5 v5.3.0 github.com/go-chi/chi/v5 v5.3.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0 github.com/jackc/pgx/v5 v5.10.0
github.com/pressly/goose/v3 v3.27.1 github.com/pressly/goose/v3 v3.27.1
github.com/redis/go-redis/v9 v9.20.1 github.com/redis/go-redis/v9 v9.20.1
github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a
) )
require ( require (
@@ -17,6 +19,7 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect github.com/mfridman/interpolate v0.0.2 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
+8
View File
@@ -9,8 +9,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -44,6 +48,10 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a h1:a6TNDN9CgG+cYjaeN8l2mc4kSz2iMiCDQxPEyltUV/I=
github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
@@ -0,0 +1,81 @@
// Path: Backend/internal/bootstrap/bootstrap_helpers.go
package bootstrap
import (
"context"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"moku-backend/internal/database"
)
func NewService(db *database.DB, posixRoot string) *Service {
return &Service{db: db, posixRoot: strings.TrimSpace(posixRoot)}
}
func upsertNamedRecord(ctx context.Context, tx pgx.Tx, query string, args ...any) (namedRecord, error) {
var record namedRecord
if err := tx.QueryRow(ctx, query, args...).Scan(&record.ID, &record.Name, &record.Slug); err != nil {
return namedRecord{}, err
}
return record, nil
}
func upsertWorkspace(ctx context.Context, tx pgx.Tx, organizationID, name, slug, kind, createdByUserID string, departmentID, teamID, projectID *string) error {
_, err := tx.Exec(ctx, `
INSERT INTO workspaces (organization_id, name, slug, kind, created_by_user_id, department_id, team_id, project_id)
VALUES ($1::uuid, $2, $3, $4::workspace_kind, $5::uuid, $6::uuid, $7::uuid, $8::uuid)
ON CONFLICT (organization_id, slug) DO UPDATE
SET
name = EXCLUDED.name,
kind = EXCLUDED.kind,
created_by_user_id = EXCLUDED.created_by_user_id,
department_id = EXCLUDED.department_id,
team_id = EXCLUDED.team_id,
project_id = EXCLUDED.project_id,
updated_at = NOW();
`, organizationID, name, slug, kind, createdByUserID, departmentID, teamID, projectID)
return err
}
func defaultRootOrganizationName(installationName, mode, host, adminDisplayName string) string {
trimmedInstallationName := strings.TrimSpace(installationName)
trimmedHost := strings.TrimSpace(host)
trimmedAdminDisplayName := strings.TrimSpace(adminDisplayName)
if trimmedInstallationName != "" {
return trimmedInstallationName
}
if strings.EqualFold(mode, defaultInstallationMode) {
if trimmedAdminDisplayName != "" {
return fmt.Sprintf("%s %s", trimmedAdminDisplayName, defaultPersonalServerSuffix)
}
return defaultPersonalDisplayName
}
if trimmedHost != "" {
return trimmedHost
}
return defaultOrganizationName
}
func personalHomeTitle(displayName string) string {
trimmedDisplayName := strings.TrimSpace(displayName)
if trimmedDisplayName == "" {
return "Home"
}
if strings.HasSuffix(strings.ToLower(trimmedDisplayName), "s") {
return fmt.Sprintf("%s' Home", trimmedDisplayName)
}
return fmt.Sprintf("%s's Home", trimmedDisplayName)
}
@@ -0,0 +1,159 @@
package bootstrap
import (
"context"
"errors"
"strings"
"github.com/jackc/pgx/v5"
)
const (
materializationNotStarted = "not_started"
materializationPending = "pending"
materializationRunning = "running"
materializationSucceeded = "succeeded"
materializationFailed = "failed"
)
type bootstrapStructurePrerequisites struct {
installation InstallationRecord
admin AdminSummary
}
func (service *Service) GetInstallation(ctx context.Context) (*InstallationRecord, error) {
record, err := scanInstallationRecord(service.db.Pool.QueryRow(ctx, `
SELECT
id::text,
name,
mode::text,
access::text,
protocol::text,
host,
is_bootstrapped,
materialization_status::text,
materialization_error
FROM installations
WHERE singleton = TRUE
LIMIT 1;
`))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &record, nil
}
func loadInstallation(ctx context.Context, tx pgx.Tx) (InstallationRecord, error) {
return scanInstallationRecord(tx.QueryRow(ctx, `
SELECT
id::text,
name,
mode::text,
access::text,
protocol::text,
host,
is_bootstrapped,
materialization_status::text,
materialization_error
FROM installations
WHERE singleton = TRUE
LIMIT 1;
`))
}
func updateBootstrappedInstallation(ctx context.Context, tx pgx.Tx) (InstallationRecord, error) {
return scanInstallationRecord(tx.QueryRow(ctx, `
UPDATE installations
SET
is_bootstrapped = TRUE,
bootstrapped_at = COALESCE(bootstrapped_at, NOW()),
materialization_status = 'pending'::bootstrap_materialization_status,
materialization_error = NULL,
materialization_requested_at = NOW(),
materialization_started_at = NULL,
materialization_finished_at = NULL,
updated_at = NOW()
WHERE singleton = TRUE
RETURNING
id::text,
name,
mode::text,
access::text,
protocol::text,
host,
is_bootstrapped,
materialization_status::text,
materialization_error;
`))
}
func scanInstallationRecord(row pgx.Row) (InstallationRecord, error) {
var record InstallationRecord
if err := row.Scan(
&record.ID,
&record.Name,
&record.Mode,
&record.Access,
&record.Protocol,
&record.Host,
&record.IsBootstrapped,
&record.MaterializationStatus,
&record.MaterializationError,
); err != nil {
return InstallationRecord{}, err
}
if strings.TrimSpace(record.MaterializationStatus) == "" {
record.MaterializationStatus = materializationNotStarted
}
return record, nil
}
func loadPrimaryAdmin(ctx context.Context, tx pgx.Tx) (AdminSummary, error) {
var admin AdminSummary
if err := tx.QueryRow(ctx, `
SELECT id::text, email, display_name
FROM users
WHERE is_instance_admin = TRUE
ORDER BY created_at ASC
LIMIT 1;
`).Scan(&admin.ID, &admin.Email, &admin.DisplayName); err != nil {
return AdminSummary{}, err
}
return admin, nil
}
func (service *Service) loadBootstrapStructurePrerequisites(
ctx context.Context,
tx pgx.Tx,
) (bootstrapStructurePrerequisites, error) {
installation, err := loadInstallation(ctx, tx)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return bootstrapStructurePrerequisites{}, ErrInstallationNotConfigured
}
return bootstrapStructurePrerequisites{}, err
}
admin, err := loadPrimaryAdmin(ctx, tx)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return bootstrapStructurePrerequisites{}, ErrAdminNotConfigured
}
return bootstrapStructurePrerequisites{}, err
}
return bootstrapStructurePrerequisites{
installation: installation,
admin: admin,
}, nil
}
@@ -0,0 +1,180 @@
package bootstrap
import (
"context"
"errors"
"fmt"
"strings"
"moku-backend/internal/jobs"
"moku-backend/internal/posixproj"
)
func (service *Service) enqueueBootstrapStructureMaterialization(
ctx context.Context,
installation *InstallationRecord,
) error {
if installation == nil {
return nil
}
jobStore := jobs.NewStore(service.db)
if _, err := jobStore.Enqueue(ctx, jobs.EnqueueInput{
Kind: jobs.KindBootstrapStructureMaterialize,
Payload: jobs.BootstrapStructureMaterializePayload{
InstallationID: installation.ID,
},
}); err != nil {
failure := fmt.Sprintf("enqueue bootstrap materialization job: %v", err)
if markErr := service.markBootstrapMaterializationFailed(ctx, installation.ID, failure); markErr != nil {
return errors.Join(err, markErr)
}
// The relational bootstrap write already committed successfully, so keep the
// response successful and surface the enqueue problem via materialization state.
installation.MaterializationStatus = materializationFailed
installation.MaterializationError = &failure
}
return nil
}
func (service *Service) ProcessBootstrapStructureMaterialization(ctx context.Context, installationID string) error {
trimmedInstallationID := strings.TrimSpace(installationID)
if trimmedInstallationID == "" {
return ErrInstallationNotConfigured
}
installation, err := service.GetInstallation(ctx)
if err != nil {
return err
}
if installation == nil || installation.ID != trimmedInstallationID {
return ErrInstallationNotConfigured
}
if err := service.markBootstrapMaterializationRunning(ctx, trimmedInstallationID); err != nil {
return err
}
if err := service.materializeBootstrapStructure(ctx, *installation); err != nil {
failure := strings.TrimSpace(err.Error())
if failure == "" {
failure = "bootstrap materialization failed"
}
if markErr := service.markBootstrapMaterializationFailed(ctx, trimmedInstallationID, failure); markErr != nil {
return errors.Join(err, markErr)
}
return err
}
return service.markBootstrapMaterializationSucceeded(ctx, trimmedInstallationID)
}
func (service *Service) materializeBootstrapStructure(ctx context.Context, installation InstallationRecord) error {
admin, err := service.GetAdmin(ctx)
if err != nil {
return err
}
if admin == nil {
return ErrAdminNotConfigured
}
organization, err := service.loadPrimaryOrganization(ctx)
if err != nil {
return err
}
if organization == nil {
return ErrBootstrapStructureMissing
}
department, err := service.loadPrimaryDepartment(ctx)
if err != nil {
return err
}
if department == nil {
return ErrBootstrapStructureMissing
}
team, err := service.loadPrimaryTeam(ctx)
if err != nil {
return err
}
if team == nil {
return ErrBootstrapStructureMissing
}
project, err := service.loadPrimaryProject(ctx)
if err != nil {
return err
}
if project == nil {
return ErrBootstrapStructureMissing
}
if err := service.ensureBootstrapPOSIXSkeleton(
installation,
AdminSummary{ID: admin.ID, Email: admin.Email, DisplayName: admin.DisplayName},
namedRecord{ID: organization.ID, Name: organization.Name, Slug: organization.Slug},
namedRecord{ID: department.ID, Name: department.Name, Slug: department.Slug},
namedRecord{ID: team.ID, Name: team.Name, Slug: team.Slug},
namedRecord{ID: project.ID, Name: project.Name, Slug: project.Slug},
); err != nil {
return err
}
return service.rebuildProjection(ctx)
}
func (service *Service) rebuildProjection(ctx context.Context) error {
if err := posixproj.NewProjector(service.db, service.posixRoot).Rebuild(ctx); err != nil {
return fmt.Errorf("rebuild POSIX projection: %w", err)
}
return nil
}
func (service *Service) markBootstrapMaterializationRunning(ctx context.Context, installationID string) error {
_, err := service.db.Pool.Exec(ctx, `
UPDATE installations
SET
materialization_status = 'running'::bootstrap_materialization_status,
materialization_error = NULL,
materialization_started_at = NOW(),
materialization_finished_at = NULL,
updated_at = NOW()
WHERE id = $1::uuid;
`, strings.TrimSpace(installationID))
return err
}
func (service *Service) markBootstrapMaterializationSucceeded(ctx context.Context, installationID string) error {
_, err := service.db.Pool.Exec(ctx, `
UPDATE installations
SET
materialization_status = 'succeeded'::bootstrap_materialization_status,
materialization_error = NULL,
materialization_finished_at = NOW(),
updated_at = NOW()
WHERE id = $1::uuid;
`, strings.TrimSpace(installationID))
return err
}
func (service *Service) markBootstrapMaterializationFailed(ctx context.Context, installationID, failure string) error {
_, err := service.db.Pool.Exec(ctx, `
UPDATE installations
SET
materialization_status = 'failed'::bootstrap_materialization_status,
materialization_error = $2,
materialization_finished_at = NOW(),
updated_at = NOW()
WHERE id = $1::uuid;
`, strings.TrimSpace(installationID), strings.TrimSpace(failure))
return err
}
@@ -0,0 +1,73 @@
package bootstrap
import "context"
func (service *Service) GetState(ctx context.Context) (BootstrapState, error) {
installation, err := service.GetInstallation(ctx)
if err != nil {
return BootstrapState{}, err
}
admin, err := service.GetAdmin(ctx)
if err != nil {
return BootstrapState{}, err
}
structure, err := service.GetStructure(ctx)
if err != nil {
return BootstrapState{}, err
}
return BootstrapState{
Installation: installation,
Admin: admin,
Structure: structure,
}, nil
}
func (service *Service) GetAppShellState(ctx context.Context) (AppShellState, error) {
installation, err := service.GetInstallation(ctx)
if err != nil {
return AppShellState{}, err
}
admin, err := service.GetAdmin(ctx)
if err != nil {
return AppShellState{}, err
}
organizations, err := service.listOrganizations(ctx)
if err != nil {
return AppShellState{}, err
}
departments, err := service.listDepartments(ctx)
if err != nil {
return AppShellState{}, err
}
teams, err := service.listTeams(ctx)
if err != nil {
return AppShellState{}, err
}
projects, err := service.listProjects(ctx)
if err != nil {
return AppShellState{}, err
}
workspaces, err := service.listWorkspaces(ctx)
if err != nil {
return AppShellState{}, err
}
return AppShellState{
Installation: installation,
Admin: admin,
Organizations: organizations,
Departments: departments,
Teams: teams,
Projects: projects,
Workspaces: workspaces,
}, nil
}
@@ -0,0 +1,309 @@
// Path: Backend/internal/bootstrap/bootstrap_types.go
package bootstrap
import (
"errors"
"moku-backend/internal/database"
)
const (
primaryOrganizationSlug = "primary-organization"
primaryDepartmentSlug = "primary-department"
primaryTeamSlug = "primary-team"
primaryProjectSlug = "primary-project"
organizationWorkspaceSlug = "organization-home"
departmentWorkspaceSlug = "department-home"
teamWorkspaceSlug = "team-home"
projectWorkspaceSlug = "project-home"
defaultInstallationHost = "localhost"
defaultInstallationMode = "personal"
defaultInstallationAccess = "local"
defaultInstallationProtocol = "http"
defaultOrganizationName = "Moku"
defaultPersonalServerSuffix = "Personal"
defaultPersonalDisplayName = "Personal"
bootstrapWorkspaceKindOrg = "organization"
bootstrapWorkspaceKindDept = "department"
bootstrapWorkspaceKindTeam = "team"
bootstrapWorkspaceKindProject = "project"
projectFolderOrderRootKey = "__root__"
projectFolderOrderHierarchy = "hierarchy"
projectFolderOrderTree = "tree"
)
var (
ErrInstallationNotConfigured = errors.New("bootstrap installation step has not been completed")
ErrAdminNotConfigured = errors.New("bootstrap admin step has not been completed")
ErrBootstrapStructureMissing = errors.New("bootstrap structure is incomplete")
ErrProjectNotFound = errors.New("project not found")
ErrProjectFolderNotFound = errors.New("project folder not found")
ErrProjectItemNotFound = errors.New("project item not found")
ErrInvalidProjectFolderMove = errors.New("invalid project folder move")
ErrInvalidProjectItemMove = errors.New("invalid project item move")
)
type Service struct {
db *database.DB
posixRoot string
}
type SaveInstanceInput struct {
Protocol string
Access string
Host string
}
type SaveModeInput struct {
Mode string
Name string
}
type SaveAdminInput struct {
DisplayName string
Email string
Password string
}
type SaveStructureInput struct {
OrganizationName string
DepartmentName string
TeamName string
ProjectName string
}
type InstallationRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Mode string `json:"mode"`
Access string `json:"access"`
Protocol string `json:"protocol"`
Host string `json:"host"`
IsBootstrapped bool `json:"isBootstrapped"`
MaterializationStatus string `json:"materializationStatus"`
MaterializationError *string `json:"materializationError,omitempty"`
}
type AdminRecord struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
IsInstanceAdmin bool `json:"isInstanceAdmin"`
HomeTitle string `json:"homeTitle"`
}
type OrganizationRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type DepartmentRecord struct {
ID string `json:"id"`
OrganizationID string `json:"organizationId"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type TeamRecord struct {
ID string `json:"id"`
OrganizationID string `json:"organizationId"`
DepartmentID *string `json:"departmentId,omitempty"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type ProjectRecord struct {
ID string `json:"id"`
OrganizationID string `json:"organizationId"`
DepartmentID *string `json:"departmentId,omitempty"`
TeamID *string `json:"teamId,omitempty"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type WorkspaceRecord struct {
ID string `json:"id"`
OrganizationID string `json:"organizationId"`
Name string `json:"name"`
Slug string `json:"slug"`
Kind string `json:"kind"`
DepartmentID *string `json:"departmentId,omitempty"`
TeamID *string `json:"teamId,omitempty"`
ProjectID *string `json:"projectId,omitempty"`
}
type StructureRecord struct {
Installation InstallationRecord `json:"installation"`
Organization namedRecord `json:"organization"`
Department namedRecord `json:"department"`
Team namedRecord `json:"team"`
Project namedRecord `json:"project"`
Admin AdminSummary `json:"admin"`
}
type AdminSummary struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
}
type BootstrapStructureState struct {
Organization *OrganizationRecord `json:"organization,omitempty"`
Department *DepartmentRecord `json:"department,omitempty"`
Team *TeamRecord `json:"team,omitempty"`
Project *ProjectRecord `json:"project,omitempty"`
Workspaces []WorkspaceRecord `json:"workspaces"`
}
type BootstrapState struct {
Installation *InstallationRecord `json:"installation,omitempty"`
Admin *AdminRecord `json:"admin,omitempty"`
Structure BootstrapStructureState `json:"structure"`
}
type AppShellState struct {
Installation *InstallationRecord `json:"installation,omitempty"`
Admin *AdminRecord `json:"admin,omitempty"`
Organizations []OrganizationRecord `json:"organizations"`
Departments []DepartmentRecord `json:"departments"`
Teams []TeamRecord `json:"teams"`
Projects []ProjectRecord `json:"projects"`
Workspaces []WorkspaceRecord `json:"workspaces"`
}
type namedRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type ProjectHierarchyFolderRecord struct {
ID string `json:"id"`
Path string `json:"path"`
Label string `json:"label"`
Children []ProjectHierarchyFolderRecord `json:"children"`
}
type ProjectTreeNodeRecord struct {
ID string `json:"id"`
Path string `json:"path"`
Label string `json:"label"`
Kind string `json:"kind"`
ItemType string `json:"itemType,omitempty"`
Children []ProjectTreeNodeRecord `json:"children,omitempty"`
}
type CreateProjectFolderInput struct {
ProjectID string
ParentFolderPath string
Name string
}
type DeleteProjectFolderInput struct {
ProjectID string
FolderPath string
}
type RenameProjectFolderInput struct {
ProjectID string
FolderPath string
Name string
}
type MoveProjectFolderInput struct {
ProjectID string
FolderPath string
FolderStableID string
ParentFolderPath string
ParentStableID string
TargetIndex int
}
type CreateProjectItemInput struct {
ProjectID string
ParentFolderPath string
Name string
ItemType string
}
type DeleteProjectItemInput struct {
ProjectID string
ItemPath string
}
type MoveProjectItemInput struct {
ProjectID string
ItemPath string
ItemStableID string
ParentFolderPath string
ParentStableID string
TargetIndex int
}
type CreateProjectFolderResult struct {
ProjectID string `json:"projectId"`
CreatedFolder ProjectHierarchyFolderRecord `json:"createdFolder"`
Folders []ProjectHierarchyFolderRecord `json:"folders"`
}
type DeleteProjectFolderResult struct {
ProjectID string `json:"projectId"`
DeletedFolderStableID string `json:"deletedFolderId"`
DeletedFolderPath string `json:"deletedFolderPath"`
Folders []ProjectHierarchyFolderRecord `json:"folders"`
}
type RenameProjectFolderResult struct {
ProjectID string `json:"projectId"`
PreviousFolderStableID string `json:"previousFolderId"`
PreviousFolderPath string `json:"previousFolderPath"`
RenamedFolder ProjectHierarchyFolderRecord `json:"renamedFolder"`
Folders []ProjectHierarchyFolderRecord `json:"folders"`
}
type MoveProjectFolderResult struct {
ProjectID string `json:"projectId"`
PreviousFolderStableID string `json:"previousFolderId"`
PreviousFolderPath string `json:"previousFolderPath"`
MovedFolder ProjectHierarchyFolderRecord `json:"movedFolder"`
Folders []ProjectHierarchyFolderRecord `json:"folders"`
}
type CreateProjectItemResult struct {
ProjectID string `json:"projectId"`
CreatedItem ProjectTreeNodeRecord `json:"createdItem"`
Nodes []ProjectTreeNodeRecord `json:"nodes"`
}
type DeleteProjectItemResult struct {
ProjectID string `json:"projectId"`
DeletedItemStableID string `json:"deletedItemId"`
DeletedItemPath string `json:"deletedItemPath"`
Nodes []ProjectTreeNodeRecord `json:"nodes"`
}
type MoveProjectItemResult struct {
ProjectID string `json:"projectId"`
PreviousItemStableID string `json:"previousItemId"`
PreviousItemPath string `json:"previousItemPath"`
MovedItem ProjectTreeNodeRecord `json:"movedItem"`
Nodes []ProjectTreeNodeRecord `json:"nodes"`
}
type projectHierarchyFolderRow struct {
ID string
Path string
ParentPath string
Label string
}
type projectTreeNodeRow struct {
ID string
Path string
ParentPath string
Label string
Kind string
ItemType string
}
@@ -0,0 +1,48 @@
// Path: Backend/internal/bootstrap/project_disk.go
package bootstrap
import (
"path/filepath"
)
func (service *Service) createProjectHierarchyFolderOnDisk(projectSlug, parentFolderID, name string) (string, string, error) {
return service.createProjectFolderOnDisk(projectSlug, parentFolderID, name, projectHierarchyRootPath)
}
func (service *Service) createProjectTreeFolderOnDisk(projectSlug, parentFolderID, name string) (string, string, error) {
return service.createProjectFolderOnDisk(projectSlug, parentFolderID, name, projectTreeRootPath)
}
func (service *Service) deleteProjectHierarchyFolderOnDisk(projectSlug, folderID string) (string, error) {
return service.deleteProjectFolderOnDisk(projectSlug, folderID, projectHierarchyRootPath)
}
func (service *Service) deleteProjectTreeFolderOnDisk(projectSlug, folderID string) (string, error) {
return service.deleteProjectFolderOnDisk(projectSlug, folderID, projectTreeRootPath)
}
func (service *Service) renameProjectHierarchyFolderOnDisk(projectSlug, folderID, name string) (string, string, error) {
return service.renameProjectFolderOnDisk(projectSlug, folderID, name, projectHierarchyRootPath)
}
func (service *Service) renameProjectTreeFolderOnDisk(projectSlug, folderID, name string) (string, string, error) {
return service.renameProjectFolderOnDisk(projectSlug, folderID, name, projectTreeRootPath)
}
func (service *Service) moveProjectHierarchyFolderOnDisk(projectSlug, folderID, parentFolderID string) (string, string, error) {
return service.moveProjectFolderOnDisk(projectSlug, folderID, parentFolderID, projectHierarchyRootPath)
}
func (service *Service) moveProjectTreeFolderOnDisk(projectSlug, folderID, parentFolderID string) (string, string, error) {
return service.moveProjectFolderOnDisk(projectSlug, folderID, parentFolderID, projectTreeRootPath)
}
func projectHierarchyRootPath(projectSlug string) string {
return filepath.ToSlash(filepath.Join("projects", slugDir("project", projectSlug), "children"))
}
func projectTreeRootPath(projectSlug string) string {
return filepath.ToSlash(filepath.Join("projects", slugDir("project", projectSlug), "tree"))
}
@@ -0,0 +1,257 @@
// Path: Backend/internal/bootstrap/project_disk_bootstrap.go
package bootstrap
import (
"fmt"
"os"
"path/filepath"
"strings"
)
func (service *Service) ensureBootstrapPOSIXSkeleton(
installation InstallationRecord,
admin AdminSummary,
organization namedRecord,
department namedRecord,
team namedRecord,
project namedRecord,
) error {
rootPath := strings.TrimSpace(service.posixRoot)
if rootPath == "" {
return nil
}
if err := os.MkdirAll(rootPath, 0o755); err != nil {
return fmt.Errorf("create POSIX root: %w", err)
}
if err := writeCBORFile(filepath.Join(rootPath, posixSettingsFileName), map[string]any{
"installation": map[string]any{
"id": installation.ID,
"name": installation.Name,
"mode": installation.Mode,
"access": installation.Access,
"protocol": installation.Protocol,
"host": installation.Host,
"isBootstrapped": installation.IsBootstrapped,
},
"organization": map[string]any{
"id": organization.ID,
"name": organization.Name,
"slug": organization.Slug,
},
}); err != nil {
return fmt.Errorf("write tenant %s: %w", posixSettingsFileName, err)
}
if err := writeCBORFile(filepath.Join(rootPath, posixLayoutFileName), map[string]any{
"version": 1,
"type": "tenant-layout",
"home": map[string]any{
"defaultProjectSlug": project.Slug,
},
}); err != nil {
return fmt.Errorf("write tenant %s: %w", posixLayoutFileName, err)
}
if err := os.MkdirAll(filepath.Join(rootPath, "catalog", "packs"), 0o755); err != nil {
return fmt.Errorf("create catalog packs root: %w", err)
}
if err := os.MkdirAll(filepath.Join(rootPath, "catalog", "standalone"), 0o755); err != nil {
return fmt.Errorf("create catalog standalone root: %w", err)
}
corePackPath := filepath.Join(rootPath, "catalog", "packs", "pack-core")
corePackEntriesPath := filepath.Join(corePackPath, "entries")
coreAppEntryPath := filepath.Join(corePackEntriesPath, "app-shell")
standaloneAppPath := filepath.Join(rootPath, "catalog", "standalone", "app-shell")
for _, dirPath := range []string{corePackPath, corePackEntriesPath, coreAppEntryPath, standaloneAppPath} {
if err := os.MkdirAll(dirPath, 0o755); err != nil {
return fmt.Errorf("create catalog directory %s: %w", dirPath, err)
}
}
if err := writeJSONCFile(filepath.Join(corePackPath, posixManifestFileName), map[string]any{
"id": "pack-core",
"slug": "core",
"type": "pack",
"name": "Core Pack",
"version": "0.1.0",
"description": "First-party shared package of starter Moku surfaces.",
"entries": []map[string]any{{
"slug": "app-shell",
"path": "entries/app-shell",
}},
"capabilities": []string{"first-party", "bootstrap", "catalog-pack"},
}); err != nil {
return fmt.Errorf("write catalog pack %s: %w", posixManifestFileName, err)
}
if err := writeJSONCFile(filepath.Join(coreAppEntryPath, posixManifestFileName), map[string]any{
"id": "app-shell",
"slug": "app-shell",
"type": "app",
"name": "App Shell",
"version": "0.1.0",
"description": "Primary shell surface for navigating the installation.",
"source": "pack-core",
"runtime": map[string]any{
"kind": "route",
"path": "/v1/app-shell",
},
"capabilities": []string{"shell", "navigation", "workspace"},
}); err != nil {
return fmt.Errorf("write catalog entry %s: %w", posixManifestFileName, err)
}
if err := writeJSONCFile(filepath.Join(standaloneAppPath, posixManifestFileName), map[string]any{
"id": "app-shell",
"slug": "app-shell",
"type": "app",
"name": "App Shell",
"version": "0.1.0",
"description": "Standalone registration for the primary shell surface.",
"source": "standalone",
"runtime": map[string]any{
"kind": "route",
"path": "/v1/app-shell",
},
"capabilities": []string{"shell", "navigation", "standalone"},
}); err != nil {
return fmt.Errorf("write standalone entry %s: %w", posixManifestFileName, err)
}
departmentPath := filepath.Join(rootPath, "departments", slugDir("department", department.Slug))
teamPath := filepath.Join(departmentPath, "teams", slugDir("team", team.Slug))
projectPath := filepath.Join(rootPath, "projects", slugDir("project", project.Slug))
usersPath := filepath.Join(rootPath, "users")
personalName := strings.TrimSpace(admin.DisplayName)
if personalName == "" {
personalName = defaultPersonalDisplayName
}
personalSlug := normalizePOSIXSlug(personalName)
personalHomePath := filepath.Join(usersPath, "personals", slugDir("personal", personalSlug))
for _, dirPath := range []string{
departmentPath,
teamPath,
projectPath,
filepath.Join(projectPath, "children"),
filepath.Join(projectPath, "tree"),
filepath.Join(usersPath, "personals"),
personalHomePath,
filepath.Join(personalHomePath, "tree"),
} {
if err := os.MkdirAll(dirPath, 0o755); err != nil {
return fmt.Errorf("create POSIX directory %s: %w", dirPath, err)
}
}
if err := writeCBORFile(filepath.Join(departmentPath, posixSettingsFileName), map[string]any{
"id": department.ID,
"name": department.Name,
"slug": department.Slug,
"type": "department",
}); err != nil {
return fmt.Errorf("write department %s: %w", posixSettingsFileName, err)
}
if err := writeCBORFile(filepath.Join(departmentPath, posixUsersFileName), map[string]any{
"owners": []map[string]string{{
"id": admin.ID,
"email": admin.Email,
"displayName": admin.DisplayName,
}},
}); err != nil {
return fmt.Errorf("write department %s: %w", posixUsersFileName, err)
}
if err := writeCBORFile(filepath.Join(teamPath, posixSettingsFileName), map[string]any{
"id": team.ID,
"name": team.Name,
"slug": team.Slug,
"type": "team",
}); err != nil {
return fmt.Errorf("write team %s: %w", posixSettingsFileName, err)
}
if err := writeCBORFile(filepath.Join(teamPath, posixUsersFileName), map[string]any{
"owners": []map[string]string{{
"id": admin.ID,
"email": admin.Email,
"displayName": admin.DisplayName,
}},
}); err != nil {
return fmt.Errorf("write team %s: %w", posixUsersFileName, err)
}
if err := writeCBORFile(filepath.Join(projectPath, posixSettingsFileName), map[string]any{
"id": project.ID,
"name": project.Name,
"slug": project.Slug,
"type": "project",
}); err != nil {
return fmt.Errorf("write project %s: %w", posixSettingsFileName, err)
}
if err := writeCBORFile(filepath.Join(projectPath, posixHomeFileName), map[string]any{
"type": "project-home",
"title": project.Name,
}); err != nil {
return fmt.Errorf("write project %s: %w", posixHomeFileName, err)
}
if err := writeCBORFile(filepath.Join(projectPath, posixACLFileName), map[string]any{
"version": 1,
"inherits": true,
"rules": []any{},
}); err != nil {
return fmt.Errorf("write project %s: %w", posixACLFileName, err)
}
if err := writeCBORFile(filepath.Join(usersPath, posixSettingsFileName), map[string]any{
"primaryAdminId": admin.ID,
}); err != nil {
return fmt.Errorf("write users %s: %w", posixSettingsFileName, err)
}
if err := writeCBORFile(filepath.Join(usersPath, posixDataFileName), map[string]any{
"admins": []map[string]string{{
"id": admin.ID,
"email": admin.Email,
"displayName": admin.DisplayName,
}},
}); err != nil {
return fmt.Errorf("write users %s: %w", posixDataFileName, err)
}
if err := writeCBORFile(filepath.Join(personalHomePath, posixSettingsFileName), map[string]any{
"type": "personal",
"name": personalName,
"slug": personalSlug,
}); err != nil {
return fmt.Errorf("write personal %s: %w", posixSettingsFileName, err)
}
if err := writeCBORFile(filepath.Join(personalHomePath, posixLayoutFileName), map[string]any{
"version": 1,
"type": "personal-layout",
"home": map[string]any{
"defaultProjectSlug": project.Slug,
},
}); err != nil {
return fmt.Errorf("write personal %s: %w", posixLayoutFileName, err)
}
if err := writeCBORFile(filepath.Join(personalHomePath, posixHomeFileName), map[string]any{
"type": "personal-home",
"title": personalHomeTitle(personalName),
}); err != nil {
return fmt.Errorf("write personal %s: %w", posixHomeFileName, err)
}
return nil
}
@@ -0,0 +1,252 @@
// Path: Backend/internal/bootstrap/project_disk_folders.go
package bootstrap
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
)
func (service *Service) createProjectFolderOnDisk(projectSlug, parentFolderID, name string, rootPathBuilder func(projectSlug string) string) (string, string, error) {
posixRoot := strings.TrimSpace(service.posixRoot)
if posixRoot == "" {
return "", "", fmt.Errorf("POSIX root is not configured")
}
trimmedName := strings.TrimSpace(name)
if trimmedName == "" {
return "", "", fmt.Errorf("folder name is required")
}
containerProjectionPath := rootPathBuilder(projectSlug)
parentDir := filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath))
if strings.TrimSpace(parentFolderID) != "" {
containerProjectionPath = filepath.ToSlash(filepath.Join(strings.TrimSpace(parentFolderID), "children"))
parentDir = filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath))
info, err := os.Stat(parentDir)
if err != nil {
if os.IsNotExist(err) {
return "", "", ErrProjectFolderNotFound
}
return "", "", fmt.Errorf("stat parent project folder: %w", err)
}
if !info.IsDir() {
return "", "", ErrProjectFolderNotFound
}
}
if err := os.MkdirAll(parentDir, 0o755); err != nil {
return "", "", fmt.Errorf("create parent project folder path: %w", err)
}
baseSlug := normalizePOSIXSlug(trimmedName)
folderName := slugDir("folder", baseSlug)
folderDir := filepath.Join(parentDir, folderName)
folderSlug := baseSlug
for attempt := 2; ; attempt += 1 {
if _, err := os.Stat(folderDir); os.IsNotExist(err) {
break
} else if err != nil {
return "", "", fmt.Errorf("stat candidate project folder: %w", err)
}
folderSlug = fmt.Sprintf("%s-%d", baseSlug, attempt)
folderName = slugDir("folder", folderSlug)
folderDir = filepath.Join(parentDir, folderName)
}
if err := os.MkdirAll(filepath.Join(folderDir, "children"), 0o755); err != nil {
return "", "", fmt.Errorf("create project hierarchy folder: %w", err)
}
folderID := uuid.NewString()
if err := writeCBORFile(filepath.Join(folderDir, posixFolderFileName), map[string]any{"id": folderID, "name": trimmedName, "slug": folderSlug, "type": "folder"}); err != nil {
return "", "", fmt.Errorf("write project %s: %w", posixFolderFileName, err)
}
if err := writeCBORFile(filepath.Join(folderDir, posixACLFileName), map[string]any{"version": 1, "inherits": true, "rules": []any{}}); err != nil {
return "", "", fmt.Errorf("write project %s: %w", posixACLFileName, err)
}
return filepath.ToSlash(filepath.Join(containerProjectionPath, folderName)), folderSlug, nil
}
func (service *Service) deleteProjectFolderOnDisk(projectSlug, folderID string, rootPathBuilder func(projectSlug string) string) (string, error) {
posixRoot := strings.TrimSpace(service.posixRoot)
if posixRoot == "" {
return "", fmt.Errorf("POSIX root is not configured")
}
trimmedFolderID := strings.TrimSpace(folderID)
if trimmedFolderID == "" {
return "", ErrProjectFolderNotFound
}
rootProjectionPath := rootPathBuilder(projectSlug)
folderProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+trimmedFolderID)), "/")
if folderProjectionPath == "." || folderProjectionPath == rootProjectionPath || !strings.HasPrefix(folderProjectionPath, rootProjectionPath+"/") {
return "", ErrProjectFolderNotFound
}
folderDir := filepath.Join(posixRoot, filepath.FromSlash(folderProjectionPath))
info, err := os.Stat(folderDir)
if err != nil {
if os.IsNotExist(err) {
return "", ErrProjectFolderNotFound
}
return "", fmt.Errorf("stat project folder: %w", err)
}
if !info.IsDir() {
return "", ErrProjectFolderNotFound
}
if err := os.RemoveAll(folderDir); err != nil {
return "", fmt.Errorf("delete project folder: %w", err)
}
return folderProjectionPath, nil
}
func (service *Service) renameProjectFolderOnDisk(projectSlug, folderID, name string, rootPathBuilder func(projectSlug string) string) (string, string, error) {
posixRoot := strings.TrimSpace(service.posixRoot)
if posixRoot == "" {
return "", "", fmt.Errorf("POSIX root is not configured")
}
trimmedFolderID := strings.TrimSpace(folderID)
if trimmedFolderID == "" {
return "", "", ErrProjectFolderNotFound
}
trimmedName := strings.TrimSpace(name)
if trimmedName == "" {
return "", "", fmt.Errorf("folder name is required")
}
rootProjectionPath := rootPathBuilder(projectSlug)
folderProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+trimmedFolderID)), "/")
if folderProjectionPath == "." || folderProjectionPath == rootProjectionPath || !strings.HasPrefix(folderProjectionPath, rootProjectionPath+"/") {
return "", "", ErrProjectFolderNotFound
}
folderDir := filepath.Join(posixRoot, filepath.FromSlash(folderProjectionPath))
info, err := os.Stat(folderDir)
if err != nil {
if os.IsNotExist(err) {
return "", "", ErrProjectFolderNotFound
}
return "", "", fmt.Errorf("stat project folder: %w", err)
}
if !info.IsDir() {
return "", "", ErrProjectFolderNotFound
}
parentDir := filepath.Dir(folderDir)
baseSlug := normalizePOSIXSlug(trimmedName)
folderName := slugDir("folder", baseSlug)
folderSlug := baseSlug
destinationDir := filepath.Join(parentDir, folderName)
for attempt := 2; ; attempt += 1 {
if destinationDir == folderDir {
break
}
if _, err := os.Stat(destinationDir); os.IsNotExist(err) {
break
} else if err != nil {
return "", "", fmt.Errorf("stat candidate renamed project folder: %w", err)
}
folderSlug = fmt.Sprintf("%s-%d", baseSlug, attempt)
folderName = slugDir("folder", folderSlug)
destinationDir = filepath.Join(parentDir, folderName)
}
renamedProjectionPath := filepath.ToSlash(filepath.Join(filepath.Dir(folderProjectionPath), folderName))
if destinationDir != folderDir {
if err := os.Rename(folderDir, destinationDir); err != nil {
return "", "", fmt.Errorf("rename project folder: %w", err)
}
}
folderPayload := readStructuredFileMap(filepath.Join(destinationDir, posixFolderFileName))
folderMetadataID, _ := folderPayload["id"].(string)
if strings.TrimSpace(folderMetadataID) == "" {
folderMetadataID = uuid.NewString()
}
if err := writeCBORFile(filepath.Join(destinationDir, posixFolderFileName), map[string]any{"id": folderMetadataID, "name": trimmedName, "slug": folderSlug, "type": "folder"}); err != nil {
return "", "", fmt.Errorf("write renamed project %s: %w", posixFolderFileName, err)
}
return folderProjectionPath, renamedProjectionPath, nil
}
func (service *Service) moveProjectFolderOnDisk(projectSlug, folderID, parentFolderID string, rootPathBuilder func(projectSlug string) string) (string, string, error) {
posixRoot := strings.TrimSpace(service.posixRoot)
if posixRoot == "" {
return "", "", fmt.Errorf("POSIX root is not configured")
}
rootProjectionPath := rootPathBuilder(projectSlug)
folderProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+strings.TrimSpace(folderID))), "/")
if folderProjectionPath == "." || folderProjectionPath == rootProjectionPath || !strings.HasPrefix(folderProjectionPath, rootProjectionPath+"/") {
return "", "", ErrProjectFolderNotFound
}
folderDir := filepath.Join(posixRoot, filepath.FromSlash(folderProjectionPath))
info, err := os.Stat(folderDir)
if err != nil {
if os.IsNotExist(err) {
return "", "", ErrProjectFolderNotFound
}
return "", "", fmt.Errorf("stat project folder: %w", err)
}
if !info.IsDir() {
return "", "", ErrProjectFolderNotFound
}
trimmedParentFolderID := strings.TrimSpace(parentFolderID)
parentChildrenProjectionPath := rootProjectionPath
parentDir := filepath.Join(posixRoot, filepath.FromSlash(rootProjectionPath))
if trimmedParentFolderID != "" {
parentProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+trimmedParentFolderID)), "/")
if parentProjectionPath == "." || parentProjectionPath == rootProjectionPath || !strings.HasPrefix(parentProjectionPath, rootProjectionPath+"/") {
return "", "", ErrProjectFolderNotFound
}
if parentProjectionPath == folderProjectionPath || strings.HasPrefix(parentProjectionPath, folderProjectionPath+"/children/") {
return "", "", ErrInvalidProjectFolderMove
}
parentChildrenProjectionPath = filepath.ToSlash(filepath.Join(parentProjectionPath, "children"))
parentDir = filepath.Join(posixRoot, filepath.FromSlash(parentChildrenProjectionPath))
}
parentInfo, err := os.Stat(parentDir)
if err != nil {
if os.IsNotExist(err) {
return "", "", ErrProjectFolderNotFound
}
return "", "", fmt.Errorf("stat project folder parent: %w", err)
}
if !parentInfo.IsDir() {
return "", "", ErrProjectFolderNotFound
}
currentParentDir := filepath.Dir(folderDir)
if samePath(currentParentDir, parentDir) {
return folderProjectionPath, folderProjectionPath, nil
}
folderPayload := readStructuredFileMap(filepath.Join(folderDir, posixFolderFileName))
folderMetadataID, _ := folderPayload["id"].(string)
if strings.TrimSpace(folderMetadataID) == "" {
folderMetadataID = uuid.NewString()
}
folderName, _ := folderPayload["name"].(string)
if strings.TrimSpace(folderName) == "" {
folderName = fallbackFolderLabel(folderProjectionPath)
}
currentBase := filepath.Base(folderDir)
baseSlug := strings.TrimPrefix(currentBase, "folder-")
if strings.TrimSpace(baseSlug) == "" {
baseSlug = normalizePOSIXSlug(folderName)
}
folderSlug := baseSlug
folderDirName := slugDir("folder", folderSlug)
destinationDir := filepath.Join(parentDir, folderDirName)
for attempt := 2; ; attempt += 1 {
if _, err := os.Stat(destinationDir); os.IsNotExist(err) {
break
} else if err != nil {
return "", "", fmt.Errorf("stat candidate moved project folder: %w", err)
}
folderSlug = fmt.Sprintf("%s-%d", baseSlug, attempt)
folderDirName = slugDir("folder", folderSlug)
destinationDir = filepath.Join(parentDir, folderDirName)
}
if err := os.Rename(folderDir, destinationDir); err != nil {
return "", "", fmt.Errorf("move project folder: %w", err)
}
folderPayload["id"] = folderMetadataID
folderPayload["name"] = folderName
folderPayload["slug"] = folderSlug
folderPayload["type"] = "folder"
if err := writeCBORFile(filepath.Join(destinationDir, posixFolderFileName), folderPayload); err != nil {
return "", "", fmt.Errorf("write moved project %s: %w", posixFolderFileName, err)
}
movedProjectionPath := filepath.ToSlash(filepath.Join(parentChildrenProjectionPath, folderDirName))
return folderProjectionPath, movedProjectionPath, nil
}
@@ -0,0 +1,233 @@
// Path: Backend/internal/bootstrap/project_disk_helpers.go
package bootstrap
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"unicode"
"github.com/fxamacker/cbor/v2"
"github.com/tailscale/hujson"
)
const (
posixSettingsFileName = "settings.cbor"
posixLayoutFileName = "layout.cbor"
posixHomeFileName = "home.cbor"
posixUsersFileName = "users.cbor"
posixACLFileName = "acl.cbor"
posixFolderFileName = "folder.cbor"
posixItemFileName = "item.cbor"
posixDataFileName = "data.cbor"
posixSchemaFileName = "schema.json"
posixManifestFileName = "manifest.jsonc"
)
func samePath(left, right string) bool {
cleanLeft := filepath.Clean(left)
cleanRight := filepath.Clean(right)
if cleanLeft == cleanRight {
return true
}
leftInfo, leftErr := os.Stat(cleanLeft)
rightInfo, rightErr := os.Stat(cleanRight)
if leftErr == nil && rightErr == nil {
return os.SameFile(leftInfo, rightInfo)
}
return false
}
func normalizePOSIXSlug(value string) string {
trimmed := strings.TrimSpace(strings.ToLower(value))
if trimmed == "" {
return "untitled"
}
var builder strings.Builder
lastDash := false
for _, r := range trimmed {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r):
builder.WriteRune(r)
lastDash = false
case r == '-' || r == '_' || unicode.IsSpace(r):
if !lastDash && builder.Len() > 0 {
builder.WriteByte('-')
lastDash = true
}
}
}
slug := strings.Trim(builder.String(), "-")
if slug == "" {
return "untitled"
}
return slug
}
func fallbackFolderLabel(path string) string {
base := filepath.Base(filepath.FromSlash(path))
trimmed := strings.TrimPrefix(base, "folder-")
parts := strings.FieldsFunc(trimmed, func(r rune) bool { return r == '-' || r == '_' })
for index, part := range parts {
if part != "" {
parts[index] = strings.ToUpper(part[:1]) + part[1:]
}
}
label := strings.Join(parts, " ")
if label == "" {
return base
}
return label
}
func fallbackItemLabel(path string) string {
base := filepath.Base(filepath.FromSlash(path))
trimmed := strings.TrimPrefix(base, "item-")
parts := strings.FieldsFunc(trimmed, func(r rune) bool { return r == '-' || r == '_' })
for index, part := range parts {
if part != "" {
parts[index] = strings.ToUpper(part[:1]) + part[1:]
}
}
label := strings.Join(parts, " ")
if label == "" {
return base
}
return label
}
func normalizeProjectTreeItemType(itemType string) string {
switch strings.TrimSpace(strings.ToLower(itemType)) {
case "", "board", "core.board", "core.board.kanban", "kanban":
return "core.board.kanban"
case "core.doc", "doc", "document":
return "core.doc"
case "core.board.list", "list", "list-board":
return "core.board.list"
default:
return strings.TrimSpace(itemType)
}
}
func defaultProjectTreeItemSchema(itemType string) map[string]any {
return map[string]any{"type": "object", "itemType": normalizeProjectTreeItemType(itemType)}
}
func defaultProjectTreeItemData(itemType, name string) map[string]any {
return map[string]any{"title": strings.TrimSpace(name), "itemType": normalizeProjectTreeItemType(itemType)}
}
func slugDir(prefix, slug string) string {
trimmedSlug := strings.TrimSpace(slug)
if trimmedSlug == "" {
return prefix
}
return fmt.Sprintf("%s-%s", prefix, trimmedSlug)
}
func writeJSONFile(path string, payload any) error {
parentDir := filepath.Dir(path)
if err := os.MkdirAll(parentDir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
return os.WriteFile(path, data, 0o644)
}
func writeJSONCFile(path string, payload any) error {
parentDir := filepath.Dir(path)
if err := os.MkdirAll(parentDir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
content := append([]byte("// This file is JSONC. Comments and trailing commas are allowed.\n"), data...)
content = append(content, '\n')
return os.WriteFile(path, content, 0o644)
}
func writeCBORFile(path string, payload any) error {
parentDir := filepath.Dir(path)
if err := os.MkdirAll(parentDir, 0o755); err != nil {
return err
}
data, err := cbor.Marshal(payload)
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
func readStructuredFileMap(path string) map[string]any {
data, err := os.ReadFile(path)
if err != nil {
return map[string]any{}
}
var payload any
switch strings.ToLower(filepath.Ext(path)) {
case ".cbor":
err = cbor.Unmarshal(data, &payload)
case ".json":
err = json.Unmarshal(data, &payload)
case ".jsonc":
payload, err = decodeJSONCToAny(data)
default:
return map[string]any{}
}
if err != nil || payload == nil {
return map[string]any{}
}
normalized, ok := normalizeStructuredValue(payload).(map[string]any)
if !ok || normalized == nil {
return map[string]any{}
}
return normalized
}
func decodeJSONCToAny(data []byte) (any, error) {
ast, err := hujson.Parse(data)
if err != nil {
return nil, err
}
ast.Standardize()
standardized := ast.Pack()
var payload any
if err := json.Unmarshal(standardized, &payload); err != nil {
return nil, err
}
return payload, nil
}
func normalizeStructuredValue(value any) any {
switch typed := value.(type) {
case map[string]any:
normalized := make(map[string]any, len(typed))
for key, child := range typed {
normalized[key] = normalizeStructuredValue(child)
}
return normalized
case map[any]any:
normalized := make(map[string]any, len(typed))
for key, child := range typed {
normalized[fmt.Sprint(key)] = normalizeStructuredValue(child)
}
return normalized
case []any:
normalized := make([]any, len(typed))
for index, child := range typed {
normalized[index] = normalizeStructuredValue(child)
}
return normalized
default:
return value
}
}
@@ -0,0 +1,189 @@
// Path: Backend/internal/bootstrap/project_disk_items.go
package bootstrap
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
)
func (service *Service) createProjectTreeItemOnDisk(projectSlug, parentFolderPath, name, itemType string) (string, error) {
posixRoot := strings.TrimSpace(service.posixRoot)
if posixRoot == "" {
return "", fmt.Errorf("POSIX root is not configured")
}
trimmedName := strings.TrimSpace(name)
if trimmedName == "" {
return "", fmt.Errorf("item name is required")
}
canonicalItemType := normalizeProjectTreeItemType(itemType)
containerProjectionPath := projectTreeRootPath(projectSlug)
parentDir := filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath))
if trimmedParentFolderPath := strings.TrimSpace(parentFolderPath); trimmedParentFolderPath != "" {
containerProjectionPath = trimmedParentFolderPath
parentDir = filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath))
info, err := os.Stat(parentDir)
if err != nil {
if os.IsNotExist(err) {
return "", ErrProjectFolderNotFound
}
return "", fmt.Errorf("stat parent project folder: %w", err)
}
if !info.IsDir() {
return "", ErrProjectFolderNotFound
}
}
if err := os.MkdirAll(parentDir, 0o755); err != nil {
return "", fmt.Errorf("create parent project item path: %w", err)
}
baseSlug := normalizePOSIXSlug(trimmedName)
itemDirName := slugDir("item", baseSlug)
itemDir := filepath.Join(parentDir, itemDirName)
itemSlug := baseSlug
for attempt := 2; ; attempt += 1 {
if _, err := os.Stat(itemDir); os.IsNotExist(err) {
break
} else if err != nil {
return "", fmt.Errorf("stat candidate project item: %w", err)
}
itemSlug = fmt.Sprintf("%s-%d", baseSlug, attempt)
itemDirName = slugDir("item", itemSlug)
itemDir = filepath.Join(parentDir, itemDirName)
}
if err := os.MkdirAll(itemDir, 0o755); err != nil {
return "", fmt.Errorf("create project item: %w", err)
}
itemID := uuid.NewString()
if err := writeCBORFile(filepath.Join(itemDir, posixItemFileName), map[string]any{"id": itemID, "name": trimmedName, "slug": itemSlug, "type": canonicalItemType}); err != nil {
return "", fmt.Errorf("write project %s: %w", posixItemFileName, err)
}
if err := writeJSONFile(filepath.Join(itemDir, posixSchemaFileName), defaultProjectTreeItemSchema(canonicalItemType)); err != nil {
return "", fmt.Errorf("write project %s: %w", posixSchemaFileName, err)
}
if err := writeCBORFile(filepath.Join(itemDir, posixDataFileName), defaultProjectTreeItemData(canonicalItemType, trimmedName)); err != nil {
return "", fmt.Errorf("write project %s: %w", posixDataFileName, err)
}
return filepath.ToSlash(filepath.Join(containerProjectionPath, itemDirName)), nil
}
func (service *Service) deleteProjectTreeItemOnDisk(projectSlug, itemPath string) (string, error) {
posixRoot := strings.TrimSpace(service.posixRoot)
if posixRoot == "" {
return "", fmt.Errorf("POSIX root is not configured")
}
rootProjectionPath := projectTreeRootPath(projectSlug)
itemProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+strings.TrimSpace(itemPath))), "/")
if itemProjectionPath == "." || itemProjectionPath == rootProjectionPath || !strings.HasPrefix(itemProjectionPath, rootProjectionPath+"/") {
return "", ErrProjectItemNotFound
}
itemDir := filepath.Join(posixRoot, filepath.FromSlash(itemProjectionPath))
info, err := os.Stat(itemDir)
if err != nil {
if os.IsNotExist(err) {
return "", ErrProjectItemNotFound
}
return "", fmt.Errorf("stat project item: %w", err)
}
if !info.IsDir() {
return "", ErrProjectItemNotFound
}
if err := os.RemoveAll(itemDir); err != nil {
return "", fmt.Errorf("delete project item: %w", err)
}
return itemProjectionPath, nil
}
func (service *Service) moveProjectTreeItemOnDisk(projectSlug, itemPath, parentFolderPath string) (string, string, error) {
posixRoot := strings.TrimSpace(service.posixRoot)
if posixRoot == "" {
return "", "", fmt.Errorf("POSIX root is not configured")
}
rootProjectionPath := projectTreeRootPath(projectSlug)
itemProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+strings.TrimSpace(itemPath))), "/")
if itemProjectionPath == "." || itemProjectionPath == rootProjectionPath || !strings.HasPrefix(itemProjectionPath, rootProjectionPath+"/") {
return "", "", ErrProjectItemNotFound
}
itemDir := filepath.Join(posixRoot, filepath.FromSlash(itemProjectionPath))
info, err := os.Stat(itemDir)
if err != nil {
if os.IsNotExist(err) {
return "", "", ErrProjectItemNotFound
}
return "", "", fmt.Errorf("stat project item: %w", err)
}
if !info.IsDir() {
return "", "", ErrProjectItemNotFound
}
trimmedParentFolderPath := strings.TrimSpace(parentFolderPath)
parentProjectionPath := rootProjectionPath
parentDir := filepath.Join(posixRoot, filepath.FromSlash(parentProjectionPath))
if trimmedParentFolderPath != "" {
parentProjectionPath = strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+trimmedParentFolderPath)), "/")
if parentProjectionPath == "." || parentProjectionPath == rootProjectionPath || !strings.HasPrefix(parentProjectionPath, rootProjectionPath+"/") {
return "", "", ErrProjectFolderNotFound
}
parentDir = filepath.Join(posixRoot, filepath.FromSlash(parentProjectionPath))
}
parentInfo, err := os.Stat(parentDir)
if err != nil {
if os.IsNotExist(err) {
return "", "", ErrProjectFolderNotFound
}
return "", "", fmt.Errorf("stat project item parent: %w", err)
}
if !parentInfo.IsDir() {
return "", "", ErrProjectFolderNotFound
}
currentParentDir := filepath.Dir(itemDir)
currentBase := filepath.Base(itemDir)
itemPayload := readStructuredFileMap(filepath.Join(itemDir, posixItemFileName))
itemID, _ := itemPayload["id"].(string)
if strings.TrimSpace(itemID) == "" {
itemID = uuid.NewString()
}
itemName, _ := itemPayload["name"].(string)
if strings.TrimSpace(itemName) == "" {
itemName = fallbackItemLabel(itemProjectionPath)
}
itemType, _ := itemPayload["type"].(string)
canonicalItemType := normalizeProjectTreeItemType(itemType)
baseSlug := strings.TrimPrefix(currentBase, "item-")
if strings.TrimSpace(baseSlug) == "" {
baseSlug = normalizePOSIXSlug(itemName)
}
itemSlug := baseSlug
itemDirName := slugDir("item", itemSlug)
destinationDir := filepath.Join(parentDir, itemDirName)
for attempt := 2; ; attempt += 1 {
if samePath(destinationDir, itemDir) {
break
}
if _, err := os.Stat(destinationDir); os.IsNotExist(err) {
break
} else if err != nil {
return "", "", fmt.Errorf("stat candidate moved project item: %w", err)
}
itemSlug = fmt.Sprintf("%s-%d", baseSlug, attempt)
itemDirName = slugDir("item", itemSlug)
destinationDir = filepath.Join(parentDir, itemDirName)
}
movedProjectionPath := filepath.ToSlash(filepath.Join(parentProjectionPath, itemDirName))
if samePath(currentParentDir, parentDir) && currentBase == itemDirName {
return itemProjectionPath, itemProjectionPath, nil
}
if err := os.Rename(itemDir, destinationDir); err != nil {
return "", "", fmt.Errorf("move project item: %w", err)
}
itemPayload["id"] = itemID
itemPayload["name"] = itemName
itemPayload["slug"] = itemSlug
itemPayload["type"] = canonicalItemType
if err := writeCBORFile(filepath.Join(destinationDir, posixItemFileName), itemPayload); err != nil {
return "", "", fmt.Errorf("write moved project %s: %w", posixItemFileName, err)
}
return itemProjectionPath, movedProjectionPath, nil
}
@@ -0,0 +1,491 @@
// Path: Backend/internal/bootstrap/project_mutations.go
package bootstrap
import (
"context"
"fmt"
"strings"
)
func (service *Service) CreateProjectFolder(ctx context.Context, input CreateProjectFolderInput) (CreateProjectFolderResult, error) {
return service.createProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.createProjectHierarchyFolderOnDisk)
}
func (service *Service) CreateProjectTreeFolder(ctx context.Context, input CreateProjectFolderInput) (CreateProjectFolderResult, error) {
return service.createProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.createProjectTreeFolderOnDisk)
}
func (service *Service) DeleteProjectFolder(ctx context.Context, input DeleteProjectFolderInput) (DeleteProjectFolderResult, error) {
return service.deleteProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.deleteProjectHierarchyFolderOnDisk)
}
func (service *Service) DeleteProjectTreeFolder(ctx context.Context, input DeleteProjectFolderInput) (DeleteProjectFolderResult, error) {
return service.deleteProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.deleteProjectTreeFolderOnDisk)
}
func (service *Service) RenameProjectFolder(ctx context.Context, input RenameProjectFolderInput) (RenameProjectFolderResult, error) {
return service.renameProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.renameProjectHierarchyFolderOnDisk)
}
func (service *Service) RenameProjectTreeFolder(ctx context.Context, input RenameProjectFolderInput) (RenameProjectFolderResult, error) {
return service.renameProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.renameProjectTreeFolderOnDisk)
}
func (service *Service) MoveProjectFolder(ctx context.Context, input MoveProjectFolderInput) (MoveProjectFolderResult, error) {
return service.moveProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.moveProjectHierarchyFolderOnDisk)
}
func (service *Service) MoveProjectTreeFolder(ctx context.Context, input MoveProjectFolderInput) (MoveProjectFolderResult, error) {
return service.moveProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.moveProjectTreeFolderOnDisk)
}
func (service *Service) CreateProjectTreeItem(ctx context.Context, input CreateProjectItemInput) (CreateProjectItemResult, error) {
return service.createProjectTreeItem(ctx, input, projectTreeRootPath)
}
func (service *Service) DeleteProjectTreeItem(ctx context.Context, input DeleteProjectItemInput) (DeleteProjectItemResult, error) {
return service.deleteProjectTreeItem(ctx, input, projectTreeRootPath)
}
func (service *Service) MoveProjectTreeItem(ctx context.Context, input MoveProjectItemInput) (MoveProjectItemResult, error) {
return service.moveProjectTreeItem(ctx, input, projectTreeRootPath)
}
func (service *Service) createProjectHierarchyFolder(
ctx context.Context,
input CreateProjectFolderInput,
rootPath func(projectSlug string) string,
createOnDisk func(projectSlug, parentFolderPath, name string) (string, string, error),
) (CreateProjectFolderResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil {
return CreateProjectFolderResult{}, err
}
currentFolders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return CreateProjectFolderResult{}, err
}
parentOrderID := ""
trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
if trimmedParentFolderPath != "" {
parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderPath)
if !found {
return CreateProjectFolderResult{}, ErrProjectFolderNotFound
}
parentOrderID = parentFolder.ID
}
createdPath, _, err := createOnDisk(project.Slug, strings.TrimSpace(input.ParentFolderPath), input.Name)
if err != nil {
return CreateProjectFolderResult{}, err
}
if err := service.rebuildProjection(ctx); err != nil {
return CreateProjectFolderResult{}, err
}
folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return CreateProjectFolderResult{}, err
}
createdFolder, ok := findProjectHierarchyFolderByPath(folders, createdPath)
if !ok {
return CreateProjectFolderResult{}, fmt.Errorf("created project folder missing from projection")
}
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
seedFolderOrderParent(folderOrder, currentFolders, parentOrderID)
insertFolderOrder(folderOrder, parentOrderID, createdFolder.ID, len(folderOrderChildren(folderOrder, parentOrderID)))
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
return CreateProjectFolderResult{}, err
}
folders, err = service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return CreateProjectFolderResult{}, err
}
createdFolder, ok = findProjectHierarchyFolderByPath(folders, createdPath)
if !ok {
return CreateProjectFolderResult{}, fmt.Errorf("created project folder missing from ordered projection")
}
return CreateProjectFolderResult{ProjectID: project.ID, CreatedFolder: createdFolder, Folders: folders}, nil
}
func (service *Service) deleteProjectHierarchyFolder(
ctx context.Context,
input DeleteProjectFolderInput,
rootPath func(projectSlug string) string,
deleteOnDisk func(projectSlug, folderPath string) (string, error),
) (DeleteProjectFolderResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil {
return DeleteProjectFolderResult{}, err
}
currentFolders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return DeleteProjectFolderResult{}, err
}
deletedFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderPath))
if !found {
return DeleteProjectFolderResult{}, ErrProjectFolderNotFound
}
deletedFolderPath, err := deleteOnDisk(project.Slug, input.FolderPath)
if err != nil {
return DeleteProjectFolderResult{}, err
}
if err := service.rebuildProjection(ctx); err != nil {
return DeleteProjectFolderResult{}, err
}
folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return DeleteProjectFolderResult{}, err
}
if _, found := findProjectHierarchyFolderByPath(folders, deletedFolderPath); found {
return DeleteProjectFolderResult{}, fmt.Errorf("deleted project folder still present in projection")
}
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
removeFolderOrder(folderOrder, deletedFolder.ID)
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
return DeleteProjectFolderResult{}, err
}
folders, err = service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return DeleteProjectFolderResult{}, err
}
return DeleteProjectFolderResult{ProjectID: project.ID, DeletedFolderStableID: deletedFolder.ID, DeletedFolderPath: deletedFolderPath, Folders: folders}, nil
}
func (service *Service) renameProjectHierarchyFolder(
ctx context.Context,
input RenameProjectFolderInput,
rootPath func(projectSlug string) string,
renameOnDisk func(projectSlug, folderPath, name string) (string, string, error),
) (RenameProjectFolderResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil {
return RenameProjectFolderResult{}, err
}
previousFolderPath, renamedFolderPath, err := renameOnDisk(project.Slug, input.FolderPath, input.Name)
if err != nil {
return RenameProjectFolderResult{}, err
}
if err := service.rebuildProjection(ctx); err != nil {
return RenameProjectFolderResult{}, err
}
folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return RenameProjectFolderResult{}, err
}
renamedFolder, found := findProjectHierarchyFolderByPath(folders, renamedFolderPath)
if !found {
return RenameProjectFolderResult{}, fmt.Errorf("renamed project folder missing from projection")
}
if _, found := findProjectHierarchyFolderByPath(folders, previousFolderPath); found {
return RenameProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection")
}
return RenameProjectFolderResult{ProjectID: project.ID, PreviousFolderStableID: renamedFolder.ID, PreviousFolderPath: previousFolderPath, RenamedFolder: renamedFolder, Folders: folders}, nil
}
func (service *Service) moveProjectHierarchyFolder(
ctx context.Context,
input MoveProjectFolderInput,
rootPath func(projectSlug string) string,
moveOnDisk func(projectSlug, folderPath, parentFolderPath string) (string, string, error),
) (MoveProjectFolderResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil {
return MoveProjectFolderResult{}, err
}
currentFolders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return MoveProjectFolderResult{}, err
}
currentFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderPath))
if !found {
return MoveProjectFolderResult{}, ErrProjectFolderNotFound
}
movedFolderStableID := currentFolder.ID
providedFolderStableID := strings.TrimSpace(input.FolderStableID)
if providedFolderStableID != "" && providedFolderStableID != movedFolderStableID {
return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove
}
parentOrderID := ""
trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
providedParentStableID := strings.TrimSpace(input.ParentStableID)
if trimmedParentFolderPath != "" {
parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderPath)
if !found {
return MoveProjectFolderResult{}, ErrProjectFolderNotFound
}
parentOrderID = parentFolder.ID
if providedParentStableID != "" && providedParentStableID != parentOrderID {
return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove
}
} else if providedParentStableID != "" {
return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove
}
previousFolderPath, movedFolderPath, err := moveOnDisk(project.Slug, input.FolderPath, input.ParentFolderPath)
if err != nil {
return MoveProjectFolderResult{}, err
}
if err := service.rebuildProjection(ctx); err != nil {
return MoveProjectFolderResult{}, err
}
folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return MoveProjectFolderResult{}, err
}
movedFolder, found := findProjectHierarchyFolderByPath(folders, movedFolderPath)
if !found {
return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from projection")
}
if previousFolderPath != movedFolderPath {
if _, found := findProjectHierarchyFolderByPath(folders, previousFolderPath); found {
return MoveProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection")
}
}
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
seedFolderOrderParent(folderOrder, currentFolders, parentOrderID)
removeFolderOrderReference(folderOrder, movedFolderStableID)
removeFolderOrderReference(folderOrder, movedFolder.ID)
insertFolderOrder(folderOrder, parentOrderID, movedFolder.ID, input.TargetIndex)
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
return MoveProjectFolderResult{}, err
}
folders, err = service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return MoveProjectFolderResult{}, err
}
movedFolder, found = findProjectHierarchyFolderByPath(folders, movedFolderPath)
if !found {
return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from ordered projection")
}
return MoveProjectFolderResult{ProjectID: project.ID, PreviousFolderStableID: movedFolder.ID, PreviousFolderPath: previousFolderPath, MovedFolder: movedFolder, Folders: folders}, nil
}
func (service *Service) createProjectTreeItem(ctx context.Context, input CreateProjectItemInput, rootPath func(projectSlug string) string) (CreateProjectItemResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil {
return CreateProjectItemResult{}, err
}
currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return CreateProjectItemResult{}, err
}
parentOrderID := ""
trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
if trimmedParentFolderPath != "" {
parentFolder, found := findProjectTreeFolderByPath(currentNodes, trimmedParentFolderPath)
if !found {
return CreateProjectItemResult{}, ErrProjectFolderNotFound
}
parentOrderID = parentFolder.ID
}
createdPath, err := service.createProjectTreeItemOnDisk(project.Slug, trimmedParentFolderPath, input.Name, input.ItemType)
if err != nil {
return CreateProjectItemResult{}, err
}
if err := service.rebuildProjection(ctx); err != nil {
return CreateProjectItemResult{}, err
}
nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return CreateProjectItemResult{}, err
}
createdItem, ok := findProjectTreeNodeByPath(nodes, createdPath)
if !ok || createdItem.Kind != "item" {
return CreateProjectItemResult{}, fmt.Errorf("created project item missing from projection")
}
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
seedProjectTreeOrderParent(folderOrder, currentNodes, parentOrderID)
insertFolderOrder(folderOrder, parentOrderID, createdItem.ID, len(folderOrderChildren(folderOrder, parentOrderID)))
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
return CreateProjectItemResult{}, err
}
nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return CreateProjectItemResult{}, err
}
createdItem, ok = findProjectTreeNodeByPath(nodes, createdPath)
if !ok || createdItem.Kind != "item" {
return CreateProjectItemResult{}, fmt.Errorf("created project item missing from ordered projection")
}
return CreateProjectItemResult{ProjectID: project.ID, CreatedItem: createdItem, Nodes: nodes}, nil
}
func (service *Service) deleteProjectTreeItem(ctx context.Context, input DeleteProjectItemInput, rootPath func(projectSlug string) string) (DeleteProjectItemResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil {
return DeleteProjectItemResult{}, err
}
currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return DeleteProjectItemResult{}, err
}
deletedItem, found := findProjectTreeNodeByPath(currentNodes, strings.TrimSpace(input.ItemPath))
if !found || deletedItem.Kind != "item" {
return DeleteProjectItemResult{}, ErrProjectItemNotFound
}
deletedItemPath, err := service.deleteProjectTreeItemOnDisk(project.Slug, input.ItemPath)
if err != nil {
return DeleteProjectItemResult{}, err
}
if err := service.rebuildProjection(ctx); err != nil {
return DeleteProjectItemResult{}, err
}
nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return DeleteProjectItemResult{}, err
}
if _, found := findProjectTreeNodeByPath(nodes, deletedItemPath); found {
return DeleteProjectItemResult{}, fmt.Errorf("deleted project item still present in projection")
}
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
removeFolderOrderReference(folderOrder, deletedItem.ID)
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
return DeleteProjectItemResult{}, err
}
nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return DeleteProjectItemResult{}, err
}
return DeleteProjectItemResult{ProjectID: project.ID, DeletedItemStableID: deletedItem.ID, DeletedItemPath: deletedItemPath, Nodes: nodes}, nil
}
func (service *Service) moveProjectTreeItem(ctx context.Context, input MoveProjectItemInput, rootPath func(projectSlug string) string) (MoveProjectItemResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil {
return MoveProjectItemResult{}, err
}
currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return MoveProjectItemResult{}, err
}
currentItem, found := findProjectTreeNodeByPath(currentNodes, strings.TrimSpace(input.ItemPath))
if !found || currentItem.Kind != "item" {
return MoveProjectItemResult{}, ErrProjectItemNotFound
}
movedItemStableID := currentItem.ID
providedItemStableID := strings.TrimSpace(input.ItemStableID)
if providedItemStableID != "" && providedItemStableID != movedItemStableID {
return MoveProjectItemResult{}, ErrInvalidProjectItemMove
}
parentOrderID := ""
trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
providedParentStableID := strings.TrimSpace(input.ParentStableID)
if trimmedParentFolderPath != "" {
parentFolder, found := findProjectTreeFolderByPath(currentNodes, trimmedParentFolderPath)
if !found {
return MoveProjectItemResult{}, ErrProjectFolderNotFound
}
parentOrderID = parentFolder.ID
if providedParentStableID != "" && providedParentStableID != parentOrderID {
return MoveProjectItemResult{}, ErrInvalidProjectItemMove
}
} else if providedParentStableID != "" {
return MoveProjectItemResult{}, ErrInvalidProjectItemMove
}
previousItemPath, movedItemPath, err := service.moveProjectTreeItemOnDisk(project.Slug, input.ItemPath, input.ParentFolderPath)
if err != nil {
return MoveProjectItemResult{}, err
}
if err := service.rebuildProjection(ctx); err != nil {
return MoveProjectItemResult{}, err
}
nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return MoveProjectItemResult{}, err
}
movedItem, found := findProjectTreeNodeByPath(nodes, movedItemPath)
if !found || movedItem.Kind != "item" {
return MoveProjectItemResult{}, fmt.Errorf("moved project item missing from projection")
}
if previousItemPath != movedItemPath {
if _, found := findProjectTreeNodeByPath(nodes, previousItemPath); found {
return MoveProjectItemResult{}, fmt.Errorf("previous project item path still present in projection")
}
}
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
seedProjectTreeOrderParent(folderOrder, currentNodes, parentOrderID)
removeFolderOrderReference(folderOrder, movedItemStableID)
removeFolderOrderReference(folderOrder, movedItem.ID)
insertFolderOrder(folderOrder, parentOrderID, movedItem.ID, input.TargetIndex)
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
return MoveProjectItemResult{}, err
}
nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return MoveProjectItemResult{}, err
}
movedItem, found = findProjectTreeNodeByPath(nodes, movedItemPath)
if !found || movedItem.Kind != "item" {
return MoveProjectItemResult{}, fmt.Errorf("moved project item missing from ordered projection")
}
return MoveProjectItemResult{ProjectID: project.ID, PreviousItemStableID: movedItem.ID, PreviousItemPath: previousItemPath, MovedItem: movedItem, Nodes: nodes}, nil
}
+471
View File
@@ -0,0 +1,471 @@
// Path: Backend/internal/bootstrap/project_order.go
package bootstrap
import (
"fmt"
"path/filepath"
"sort"
"strings"
)
func (service *Service) readProjectFolderOrder(projectSlug, rootProjectionPath string) map[string][]string {
settingsPath := service.projectSettingsPath(projectSlug)
settingsPayload := readStructuredFileMap(settingsPath)
folderOrderPayload, _ := settingsPayload["folderOrder"].(map[string]any)
if folderOrderPayload == nil {
return map[string][]string{}
}
scopePayload, _ := folderOrderPayload[projectFolderOrderScope(rootProjectionPath)].(map[string]any)
if scopePayload == nil {
return map[string][]string{}
}
byParentPayload, _ := scopePayload["byParent"].(map[string]any)
if byParentPayload == nil {
return map[string][]string{}
}
order := make(map[string][]string, len(byParentPayload))
for key, raw := range byParentPayload {
for _, id := range stringSliceValue(raw) {
trimmedID := strings.TrimSpace(id)
if trimmedID == "" || slicesContains(order[key], trimmedID) {
continue
}
order[key] = append(order[key], trimmedID)
}
}
return order
}
func (service *Service) writeProjectFolderOrder(projectSlug, rootProjectionPath string, folderOrder map[string][]string) error {
settingsPath := service.projectSettingsPath(projectSlug)
settingsPayload := readStructuredFileMap(settingsPath)
if settingsPayload == nil {
settingsPayload = map[string]any{}
}
folderOrderPayload, _ := settingsPayload["folderOrder"].(map[string]any)
if folderOrderPayload == nil {
folderOrderPayload = map[string]any{}
}
scopeKey := projectFolderOrderScope(rootProjectionPath)
scopePayload, _ := folderOrderPayload[scopeKey].(map[string]any)
if scopePayload == nil {
scopePayload = map[string]any{}
}
byParentPayload := map[string]any{}
for key, ids := range folderOrder {
if len(ids) == 0 {
continue
}
copied := make([]string, 0, len(ids))
for _, id := range ids {
trimmedID := strings.TrimSpace(id)
if trimmedID == "" || slicesContains(copied, trimmedID) {
continue
}
copied = append(copied, trimmedID)
}
if len(copied) > 0 {
byParentPayload[key] = copied
}
}
scopePayload["byParent"] = byParentPayload
folderOrderPayload[scopeKey] = scopePayload
settingsPayload["folderOrder"] = folderOrderPayload
if err := writeCBORFile(settingsPath, settingsPayload); err != nil {
return fmt.Errorf("write project %s: %w", posixSettingsFileName, err)
}
return nil
}
func (service *Service) projectSettingsPath(projectSlug string) string {
return filepath.Join(strings.TrimSpace(service.posixRoot), "projects", slugDir("project", projectSlug), posixSettingsFileName)
}
func projectFolderOrderScope(rootProjectionPath string) string {
if strings.HasSuffix(rootProjectionPath, "/tree") {
return projectFolderOrderTree
}
return projectFolderOrderHierarchy
}
func applyProjectHierarchyFolderOrdering(folders []ProjectHierarchyFolderRecord, folderOrder map[string][]string) []ProjectHierarchyFolderRecord {
return applyProjectHierarchyFolderOrderingForParent(folders, "", folderOrder)
}
func applyProjectHierarchyFolderOrderingForParent(folders []ProjectHierarchyFolderRecord, parentID string, folderOrder map[string][]string) []ProjectHierarchyFolderRecord {
if len(folders) == 0 {
return folders
}
nextFolders := make([]ProjectHierarchyFolderRecord, len(folders))
copy(nextFolders, folders)
for index := range nextFolders {
nextFolders[index].Children = applyProjectHierarchyFolderOrderingForParent(nextFolders[index].Children, nextFolders[index].ID, folderOrder)
}
orderIDs := folderOrder[projectFolderOrderParentKey(parentID)]
if len(orderIDs) == 0 {
return nextFolders
}
rankByID := make(map[string]int, len(orderIDs))
for index, id := range orderIDs {
if _, exists := rankByID[id]; !exists {
rankByID[id] = index
}
}
sort.SliceStable(nextFolders, func(left, right int) bool {
leftRank, leftOrdered := rankByID[nextFolders[left].ID]
rightRank, rightOrdered := rankByID[nextFolders[right].ID]
if leftOrdered && rightOrdered {
return leftRank < rightRank
}
if leftOrdered != rightOrdered {
return leftOrdered
}
return false
})
return nextFolders
}
func removeFolderOrder(folderOrder map[string][]string, folderID string) {
removeFolderOrderReference(folderOrder, folderID)
trimmedFolderID := strings.TrimSpace(folderID)
if trimmedFolderID == "" {
return
}
delete(folderOrder, projectFolderOrderParentKey(trimmedFolderID))
}
func removeFolderOrderReference(folderOrder map[string][]string, folderID string) {
trimmedFolderID := strings.TrimSpace(folderID)
if trimmedFolderID == "" {
return
}
for key, ids := range folderOrder {
nextIDs := ids[:0]
for _, id := range ids {
if strings.TrimSpace(id) == trimmedFolderID {
continue
}
nextIDs = append(nextIDs, id)
}
if len(nextIDs) == 0 {
delete(folderOrder, key)
continue
}
folderOrder[key] = append([]string(nil), nextIDs...)
}
}
func insertFolderOrder(folderOrder map[string][]string, parentID, folderID string, index int) {
trimmedFolderID := strings.TrimSpace(folderID)
if trimmedFolderID == "" {
return
}
removeFolderOrderReference(folderOrder, trimmedFolderID)
parentKey := projectFolderOrderParentKey(parentID)
children := append([]string(nil), folderOrder[parentKey]...)
if index < 0 {
index = 0
}
if index > len(children) {
index = len(children)
}
children = slicesInsert(children, index, trimmedFolderID)
folderOrder[parentKey] = children
}
func seedFolderOrderParent(folderOrder map[string][]string, folders []ProjectHierarchyFolderRecord, parentID string) {
children := folders
trimmedParentID := strings.TrimSpace(parentID)
if trimmedParentID != "" {
parent, found := findProjectHierarchyFolder(folders, trimmedParentID)
if !found {
return
}
children = parent.Children
}
parentKey := projectFolderOrderParentKey(trimmedParentID)
seeded := make([]string, 0, len(children))
for _, child := range children {
childID := strings.TrimSpace(child.ID)
if childID == "" || slicesContains(seeded, childID) {
continue
}
seeded = append(seeded, childID)
}
if len(seeded) == 0 {
delete(folderOrder, parentKey)
return
}
folderOrder[parentKey] = seeded
}
func folderOrderChildren(folderOrder map[string][]string, parentID string) []string {
return append([]string(nil), folderOrder[projectFolderOrderParentKey(parentID)]...)
}
func projectFolderOrderParentKey(parentID string) string {
trimmedParentID := strings.TrimSpace(parentID)
if trimmedParentID == "" {
return projectFolderOrderRootKey
}
return trimmedParentID
}
func stringSliceValue(value any) []string {
items, ok := value.([]any)
if !ok {
if typed, ok := value.([]string); ok {
return typed
}
return nil
}
result := make([]string, 0, len(items))
for _, item := range items {
if text, ok := item.(string); ok {
result = append(result, text)
}
}
return result
}
func slicesContains(values []string, value string) bool {
for _, existing := range values {
if existing == value {
return true
}
}
return false
}
func slicesInsert(values []string, index int, value string) []string {
values = append(values, "")
copy(values[index+1:], values[index:])
values[index] = value
return values
}
func buildProjectHierarchyFolderTree(rows []projectHierarchyFolderRow, rootParentPath string) []ProjectHierarchyFolderRecord {
if len(rows) == 0 {
return nil
}
nodesByPath := make(map[string]*ProjectHierarchyFolderRecord, len(rows))
childrenByParent := make(map[string][]string)
for _, row := range rows {
folderID := strings.TrimSpace(row.ID)
if folderID == "" {
folderID = row.Path
}
label := strings.TrimSpace(row.Label)
if label == "" {
label = fallbackFolderLabel(row.Path)
}
nodesByPath[row.Path] = &ProjectHierarchyFolderRecord{ID: folderID, Path: row.Path, Label: label, Children: []ProjectHierarchyFolderRecord{}}
childrenByParent[row.ParentPath] = append(childrenByParent[row.ParentPath], row.Path)
}
var build func(parentPath string) []ProjectHierarchyFolderRecord
build = func(parentPath string) []ProjectHierarchyFolderRecord {
childPaths := childrenByParent[parentPath]
if len(childPaths) == 0 {
return nil
}
folders := make([]ProjectHierarchyFolderRecord, 0, len(childPaths))
for _, childPath := range childPaths {
node := nodesByPath[childPath]
if node == nil {
continue
}
folder := ProjectHierarchyFolderRecord{ID: node.ID, Path: node.Path, Label: node.Label, Children: build(filepath.ToSlash(filepath.Join(childPath, "children")))}
folders = append(folders, folder)
}
return folders
}
return build(rootParentPath)
}
func buildProjectTreeNodeTree(rows []projectTreeNodeRow, rootParentPath string) []ProjectTreeNodeRecord {
if len(rows) == 0 {
return nil
}
nodesByPath := make(map[string]*ProjectTreeNodeRecord, len(rows))
childrenByParent := make(map[string][]string)
for _, row := range rows {
nodeKind := normalizeProjectTreeNodeKind(row.Kind)
nodeID := strings.TrimSpace(row.ID)
if nodeID == "" {
nodeID = row.Path
}
label := strings.TrimSpace(row.Label)
if label == "" {
if nodeKind == "item" {
label = fallbackItemLabel(row.Path)
} else {
label = fallbackFolderLabel(row.Path)
}
}
nodesByPath[row.Path] = &ProjectTreeNodeRecord{ID: nodeID, Path: row.Path, Label: label, Kind: nodeKind, ItemType: normalizeProjectTreeItemType(row.ItemType), Children: []ProjectTreeNodeRecord{}}
parentKey := normalizeProjectTreeParentPath(nodeKind, row.ParentPath)
childrenByParent[parentKey] = append(childrenByParent[parentKey], row.Path)
}
var build func(parentPath string) []ProjectTreeNodeRecord
build = func(parentPath string) []ProjectTreeNodeRecord {
childPaths := childrenByParent[parentPath]
if len(childPaths) == 0 {
return nil
}
nodes := make([]ProjectTreeNodeRecord, 0, len(childPaths))
for _, childPath := range childPaths {
node := nodesByPath[childPath]
if node == nil {
continue
}
nextNode := ProjectTreeNodeRecord{ID: node.ID, Path: node.Path, Label: node.Label, Kind: node.Kind, ItemType: node.ItemType}
if node.Kind == "folder" {
nextNode.Children = build(node.Path)
}
nodes = append(nodes, nextNode)
}
return nodes
}
return build(rootParentPath)
}
func normalizeProjectTreeParentPath(kind, parentPath string) string {
if kind == "folder" && strings.HasSuffix(parentPath, "/children") {
return filepath.ToSlash(filepath.Dir(parentPath))
}
return parentPath
}
func normalizeProjectTreeNodeKind(kind string) string {
switch strings.TrimSpace(kind) {
case "item":
return "item"
case "folder", "hierarchy_folder":
return "folder"
default:
return "folder"
}
}
func applyProjectTreeNodeOrdering(nodes []ProjectTreeNodeRecord, folderOrder map[string][]string) []ProjectTreeNodeRecord {
return applyProjectTreeNodeOrderingForParent(nodes, "", folderOrder)
}
func applyProjectTreeNodeOrderingForParent(nodes []ProjectTreeNodeRecord, parentID string, folderOrder map[string][]string) []ProjectTreeNodeRecord {
if len(nodes) == 0 {
return nodes
}
nextNodes := make([]ProjectTreeNodeRecord, len(nodes))
copy(nextNodes, nodes)
for index := range nextNodes {
if nextNodes[index].Kind == "folder" {
nextNodes[index].Children = applyProjectTreeNodeOrderingForParent(nextNodes[index].Children, nextNodes[index].ID, folderOrder)
}
}
orderIDs := folderOrder[projectFolderOrderParentKey(parentID)]
if len(orderIDs) == 0 {
return nextNodes
}
rankByID := make(map[string]int, len(orderIDs))
for index, id := range orderIDs {
if _, exists := rankByID[id]; !exists {
rankByID[id] = index
}
}
sort.SliceStable(nextNodes, func(left, right int) bool {
leftRank, leftOrdered := rankByID[nextNodes[left].ID]
rightRank, rightOrdered := rankByID[nextNodes[right].ID]
if leftOrdered && rightOrdered {
return leftRank < rightRank
}
if leftOrdered != rightOrdered {
return leftOrdered
}
return false
})
return nextNodes
}
func findProjectHierarchyFolder(folders []ProjectHierarchyFolderRecord, folderID string) (ProjectHierarchyFolderRecord, bool) {
for _, folder := range folders {
if folder.ID == folderID {
return folder, true
}
if child, ok := findProjectHierarchyFolder(folder.Children, folderID); ok {
return child, true
}
}
return ProjectHierarchyFolderRecord{}, false
}
func findProjectHierarchyFolderByPath(folders []ProjectHierarchyFolderRecord, folderPath string) (ProjectHierarchyFolderRecord, bool) {
for _, folder := range folders {
if folder.Path == folderPath {
return folder, true
}
if child, ok := findProjectHierarchyFolderByPath(folder.Children, folderPath); ok {
return child, true
}
}
return ProjectHierarchyFolderRecord{}, false
}
func findProjectTreeNode(nodes []ProjectTreeNodeRecord, nodeID string) (ProjectTreeNodeRecord, bool) {
for _, node := range nodes {
if node.ID == nodeID {
return node, true
}
if child, ok := findProjectTreeNode(node.Children, nodeID); ok {
return child, true
}
}
return ProjectTreeNodeRecord{}, false
}
func findProjectTreeNodeByPath(nodes []ProjectTreeNodeRecord, nodePath string) (ProjectTreeNodeRecord, bool) {
for _, node := range nodes {
if node.Path == nodePath {
return node, true
}
if child, ok := findProjectTreeNodeByPath(node.Children, nodePath); ok {
return child, true
}
}
return ProjectTreeNodeRecord{}, false
}
func findProjectTreeFolderByPath(nodes []ProjectTreeNodeRecord, folderPath string) (ProjectTreeNodeRecord, bool) {
node, ok := findProjectTreeNodeByPath(nodes, folderPath)
if !ok || node.Kind != "folder" {
return ProjectTreeNodeRecord{}, false
}
return node, true
}
func seedProjectTreeOrderParent(folderOrder map[string][]string, nodes []ProjectTreeNodeRecord, parentID string) {
children := nodes
trimmedParentID := strings.TrimSpace(parentID)
if trimmedParentID != "" {
parent, found := findProjectTreeNode(nodes, trimmedParentID)
if !found || parent.Kind != "folder" {
return
}
children = parent.Children
}
parentKey := projectFolderOrderParentKey(parentID)
if len(folderOrder[parentKey]) > 0 {
return
}
orderedIDs := make([]string, 0, len(children))
for _, child := range children {
trimmedChildID := strings.TrimSpace(child.ID)
if trimmedChildID == "" || slicesContains(orderedIDs, trimmedChildID) {
continue
}
orderedIDs = append(orderedIDs, trimmedChildID)
}
if len(orderedIDs) > 0 {
folderOrder[parentKey] = orderedIDs
}
}
@@ -0,0 +1,346 @@
// Path: Backend/internal/bootstrap/project_queries.go
package bootstrap
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
)
func (service *Service) loadPrimaryOrganization(ctx context.Context) (*OrganizationRecord, error) {
var record OrganizationRecord
err := service.db.Pool.QueryRow(ctx, `
SELECT id::text, name, slug
FROM organizations
ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC
LIMIT 1;
`, primaryOrganizationSlug).Scan(&record.ID, &record.Name, &record.Slug)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &record, nil
}
func (service *Service) loadPrimaryDepartment(ctx context.Context) (*DepartmentRecord, error) {
var record DepartmentRecord
err := service.db.Pool.QueryRow(ctx, `
SELECT id::text, organization_id::text, name, slug
FROM departments
ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC
LIMIT 1;
`, primaryDepartmentSlug).Scan(&record.ID, &record.OrganizationID, &record.Name, &record.Slug)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &record, nil
}
func (service *Service) loadPrimaryTeam(ctx context.Context) (*TeamRecord, error) {
var record TeamRecord
err := service.db.Pool.QueryRow(ctx, `
SELECT id::text, organization_id::text, department_id::text, name, slug
FROM teams
ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC
LIMIT 1;
`, primaryTeamSlug).Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.Name, &record.Slug)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &record, nil
}
func (service *Service) loadPrimaryProject(ctx context.Context) (*ProjectRecord, error) {
var record ProjectRecord
err := service.db.Pool.QueryRow(ctx, `
SELECT id::text, organization_id::text, department_id::text, team_id::text, name, slug
FROM projects
ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC
LIMIT 1;
`, primaryProjectSlug).Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.TeamID, &record.Name, &record.Slug)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &record, nil
}
func (service *Service) listOrganizations(ctx context.Context) ([]OrganizationRecord, error) {
rows, err := service.db.Pool.Query(ctx, `
SELECT id::text, name, slug
FROM organizations
ORDER BY created_at ASC;
`)
if err != nil {
return nil, err
}
defer rows.Close()
var records []OrganizationRecord
for rows.Next() {
var record OrganizationRecord
if err := rows.Scan(&record.ID, &record.Name, &record.Slug); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (service *Service) listDepartments(ctx context.Context) ([]DepartmentRecord, error) {
rows, err := service.db.Pool.Query(ctx, `
SELECT id::text, organization_id::text, name, slug
FROM departments
ORDER BY created_at ASC;
`)
if err != nil {
return nil, err
}
defer rows.Close()
var records []DepartmentRecord
for rows.Next() {
var record DepartmentRecord
if err := rows.Scan(&record.ID, &record.OrganizationID, &record.Name, &record.Slug); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (service *Service) listTeams(ctx context.Context) ([]TeamRecord, error) {
rows, err := service.db.Pool.Query(ctx, `
SELECT id::text, organization_id::text, department_id::text, name, slug
FROM teams
ORDER BY created_at ASC;
`)
if err != nil {
return nil, err
}
defer rows.Close()
var records []TeamRecord
for rows.Next() {
var record TeamRecord
if err := rows.Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.Name, &record.Slug); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (service *Service) listProjects(ctx context.Context) ([]ProjectRecord, error) {
rows, err := service.db.Pool.Query(ctx, `
SELECT id::text, organization_id::text, department_id::text, team_id::text, name, slug
FROM projects
ORDER BY created_at ASC;
`)
if err != nil {
return nil, err
}
defer rows.Close()
var records []ProjectRecord
for rows.Next() {
var record ProjectRecord
if err := rows.Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.TeamID, &record.Name, &record.Slug); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (service *Service) loadProjectByID(ctx context.Context, projectID string) (*ProjectRecord, error) {
var record ProjectRecord
err := service.db.Pool.QueryRow(ctx, `
SELECT id::text, organization_id::text, department_id::text, team_id::text, name, slug
FROM projects
WHERE id = $1::uuid
LIMIT 1;
`, projectID).Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.TeamID, &record.Name, &record.Slug)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrProjectNotFound
}
return nil, err
}
return &record, nil
}
func (service *Service) GetProjectHierarchyFolders(ctx context.Context, projectID string) ([]ProjectHierarchyFolderRecord, error) {
return service.getProjectHierarchyFoldersByRootPath(ctx, projectID, projectHierarchyRootPath)
}
func (service *Service) GetProjectTreeFolders(ctx context.Context, projectID string) ([]ProjectHierarchyFolderRecord, error) {
return service.getProjectHierarchyFoldersByRootPath(ctx, projectID, projectTreeRootPath)
}
func (service *Service) GetProjectTreeNodes(ctx context.Context, projectID string) ([]ProjectTreeNodeRecord, error) {
return service.getProjectTreeNodesByRootPath(ctx, projectID, projectTreeRootPath)
}
func (service *Service) getProjectHierarchyFoldersByRootPath(
ctx context.Context,
projectID string,
rootPath func(projectSlug string) string,
) ([]ProjectHierarchyFolderRecord, error) {
project, err := service.loadProjectByID(ctx, projectID)
if err != nil {
return nil, err
}
rootParentPath := rootPath(project.Slug)
rows, err := service.db.Pool.Query(ctx, `
SELECT
COALESCE(folder_meta.resource_id, ''),
directories.path,
COALESCE(directories.parent_path, ''),
COALESCE(folder_meta.resource_name, directories.resource_name, '')
FROM posix_nodes AS directories
LEFT JOIN posix_nodes AS folder_meta
ON folder_meta.path = directories.path || '/folder.cbor'
AND folder_meta.node_kind = 'file'::posix_node_kind
WHERE directories.node_kind = 'directory'::posix_node_kind
AND directories.logical_type = 'hierarchy_folder'
AND directories.project_slug = $1
AND directories.path LIKE $2
ORDER BY directories.depth ASC, directories.path ASC;
`, project.Slug, rootParentPath+"/%")
if err != nil {
return nil, err
}
defer rows.Close()
var folderRows []projectHierarchyFolderRow
for rows.Next() {
var row projectHierarchyFolderRow
if err := rows.Scan(&row.ID, &row.Path, &row.ParentPath, &row.Label); err != nil {
return nil, err
}
folderRows = append(folderRows, row)
}
if err := rows.Err(); err != nil {
return nil, err
}
folders := buildProjectHierarchyFolderTree(folderRows, rootParentPath)
folderOrder := service.readProjectFolderOrder(project.Slug, rootParentPath)
return applyProjectHierarchyFolderOrdering(folders, folderOrder), nil
}
func (service *Service) getProjectTreeNodesByRootPath(
ctx context.Context,
projectID string,
rootPath func(projectSlug string) string,
) ([]ProjectTreeNodeRecord, error) {
project, err := service.loadProjectByID(ctx, projectID)
if err != nil {
return nil, err
}
rootParentPath := rootPath(project.Slug)
rows, err := service.db.Pool.Query(ctx, `
SELECT
COALESCE(node_meta.resource_id, ''),
directories.path,
COALESCE(directories.parent_path, ''),
COALESCE(node_meta.resource_name, directories.resource_name, ''),
directories.logical_type,
COALESCE(node_meta.content_json->>'type', directories.content_json->>'type', '')
FROM posix_nodes AS directories
LEFT JOIN posix_nodes AS node_meta
ON node_meta.node_kind = 'file'::posix_node_kind
AND (
(directories.logical_type = 'hierarchy_folder' AND node_meta.path = directories.path || '/folder.cbor')
OR (directories.logical_type = 'item' AND node_meta.path = directories.path || '/item.cbor')
)
WHERE directories.node_kind = 'directory'::posix_node_kind
AND directories.logical_type IN ('hierarchy_folder', 'item')
AND directories.project_slug = $1
AND directories.path LIKE $2
ORDER BY directories.depth ASC, directories.path ASC;
`, project.Slug, rootParentPath+"/%")
if err != nil {
return nil, err
}
defer rows.Close()
var nodeRows []projectTreeNodeRow
for rows.Next() {
var row projectTreeNodeRow
if err := rows.Scan(&row.ID, &row.Path, &row.ParentPath, &row.Label, &row.Kind, &row.ItemType); err != nil {
return nil, err
}
nodeRows = append(nodeRows, row)
}
if err := rows.Err(); err != nil {
return nil, err
}
nodes := buildProjectTreeNodeTree(nodeRows, rootParentPath)
folderOrder := service.readProjectFolderOrder(project.Slug, rootParentPath)
return applyProjectTreeNodeOrdering(nodes, folderOrder), nil
}
func (service *Service) listWorkspaces(ctx context.Context) ([]WorkspaceRecord, error) {
rows, err := service.db.Pool.Query(ctx, `
SELECT id::text, organization_id::text, name, slug, kind::text, department_id::text, team_id::text, project_id::text
FROM workspaces
ORDER BY created_at ASC;
`)
if err != nil {
return nil, err
}
defer rows.Close()
var records []WorkspaceRecord
for rows.Next() {
var record WorkspaceRecord
if err := rows.Scan(&record.ID, &record.OrganizationID, &record.Name, &record.Slug, &record.Kind, &record.DepartmentID, &record.TeamID, &record.ProjectID); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
+353
View File
@@ -0,0 +1,353 @@
// Path: Backend/internal/bootstrap/service.go
package bootstrap
import (
"context"
"errors"
"strings"
"github.com/jackc/pgx/v5"
)
func (service *Service) SaveInstance(ctx context.Context, input SaveInstanceInput) (InstallationRecord, error) {
row := service.db.Pool.QueryRow(ctx, `
INSERT INTO installations (singleton, name, mode, access, protocol, host)
VALUES (
TRUE,
COALESCE((SELECT name FROM installations WHERE singleton = TRUE LIMIT 1), ''),
COALESCE((SELECT mode FROM installations WHERE singleton = TRUE LIMIT 1), 'personal'::instance_mode),
$1::instance_access,
$2::instance_protocol,
$3
)
ON CONFLICT (singleton) DO UPDATE
SET
access = EXCLUDED.access,
protocol = EXCLUDED.protocol,
host = EXCLUDED.host,
updated_at = NOW()
RETURNING
id::text,
name,
mode::text,
access::text,
protocol::text,
host,
is_bootstrapped,
materialization_status::text,
materialization_error;
`, input.Access, input.Protocol, input.Host)
return scanInstallationRecord(row)
}
func (service *Service) SaveMode(ctx context.Context, input SaveModeInput) (InstallationRecord, error) {
row := service.db.Pool.QueryRow(ctx, `
INSERT INTO installations (singleton, name, mode, access, protocol, host)
VALUES (
TRUE,
$2,
$1::instance_mode,
COALESCE((SELECT access FROM installations WHERE singleton = TRUE LIMIT 1), 'local'::instance_access),
COALESCE((SELECT protocol FROM installations WHERE singleton = TRUE LIMIT 1), 'http'::instance_protocol),
COALESCE((SELECT host FROM installations WHERE singleton = TRUE LIMIT 1), $3)
)
ON CONFLICT (singleton) DO UPDATE
SET
name = EXCLUDED.name,
mode = EXCLUDED.mode,
updated_at = NOW()
RETURNING
id::text,
name,
mode::text,
access::text,
protocol::text,
host,
is_bootstrapped,
materialization_status::text,
materialization_error;
`, input.Mode, input.Name, defaultInstallationHost)
return scanInstallationRecord(row)
}
func (service *Service) SaveAdmin(ctx context.Context, input SaveAdminInput) (AdminRecord, error) {
tx, err := service.db.Pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return AdminRecord{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
UPDATE users
SET is_instance_admin = FALSE, updated_at = NOW()
WHERE is_instance_admin = TRUE;
`); err != nil {
return AdminRecord{}, err
}
var record AdminRecord
if err := tx.QueryRow(ctx, `
INSERT INTO users (email, display_name, password_hash, is_instance_admin)
VALUES ($1, $2, crypt($3, gen_salt('bf')), TRUE)
ON CONFLICT ((LOWER(email))) DO UPDATE
SET
email = EXCLUDED.email,
display_name = EXCLUDED.display_name,
password_hash = crypt($3, gen_salt('bf')),
is_instance_admin = TRUE,
updated_at = NOW()
RETURNING id::text, email, display_name, is_instance_admin;
`, input.Email, input.DisplayName, input.Password).Scan(
&record.ID,
&record.Email,
&record.DisplayName,
&record.IsInstanceAdmin,
); err != nil {
return AdminRecord{}, err
}
record.HomeTitle = personalHomeTitle(record.DisplayName)
if err := tx.QueryRow(ctx, `
INSERT INTO user_homes (user_id, title)
VALUES ($1::uuid, $2)
ON CONFLICT (user_id) DO UPDATE
SET title = EXCLUDED.title, updated_at = NOW()
RETURNING title;
`, record.ID, record.HomeTitle).Scan(&record.HomeTitle); err != nil {
return AdminRecord{}, err
}
if err := tx.Commit(ctx); err != nil {
return AdminRecord{}, err
}
return record, nil
}
// SaveStructure persists the bootstrap domain records synchronously, then hands the
// slow POSIX/projector materialization work to the background worker.
//
// This keeps the API request responsible for validation and durable relational writes,
// while the worker owns retryable filesystem/projection side effects.
func (service *Service) SaveStructure(ctx context.Context, input SaveStructureInput) (StructureRecord, error) {
tx, err := service.db.Pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return StructureRecord{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
prerequisites, err := service.loadBootstrapStructurePrerequisites(ctx, tx)
if err != nil {
return StructureRecord{}, err
}
organizationName := strings.TrimSpace(input.OrganizationName)
if organizationName == "" {
organizationName = defaultRootOrganizationName(
prerequisites.installation.Name,
prerequisites.installation.Mode,
prerequisites.installation.Host,
prerequisites.admin.DisplayName,
)
}
organization, err := upsertNamedRecord(ctx, tx, `
INSERT INTO organizations (name, slug, created_by_user_id)
VALUES ($1, $2, $3::uuid)
ON CONFLICT (slug) DO UPDATE
SET name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW()
RETURNING id::text, name, slug;
`, organizationName, primaryOrganizationSlug, prerequisites.admin.ID)
if err != nil {
return StructureRecord{}, err
}
if _, err := tx.Exec(ctx, `
INSERT INTO organization_memberships (organization_id, user_id, role)
VALUES ($1::uuid, $2::uuid, 'owner'::membership_role)
ON CONFLICT (organization_id, user_id) DO UPDATE
SET role = EXCLUDED.role;
`, organization.ID, prerequisites.admin.ID); err != nil {
return StructureRecord{}, err
}
department, err := upsertNamedRecord(ctx, tx, `
INSERT INTO departments (organization_id, name, slug, created_by_user_id)
VALUES ($1::uuid, $2, $3, $4::uuid)
ON CONFLICT (organization_id, slug) DO UPDATE
SET name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW()
RETURNING id::text, name, slug;
`, organization.ID, input.DepartmentName, primaryDepartmentSlug, prerequisites.admin.ID)
if err != nil {
return StructureRecord{}, err
}
team, err := upsertNamedRecord(ctx, tx, `
INSERT INTO teams (organization_id, department_id, name, slug, created_by_user_id)
VALUES ($1::uuid, $2::uuid, $3, $4, $5::uuid)
ON CONFLICT (organization_id, slug) DO UPDATE
SET department_id = EXCLUDED.department_id, name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW()
RETURNING id::text, name, slug;
`, organization.ID, department.ID, input.TeamName, primaryTeamSlug, prerequisites.admin.ID)
if err != nil {
return StructureRecord{}, err
}
if _, err := tx.Exec(ctx, `
INSERT INTO team_memberships (team_id, user_id, role)
VALUES ($1::uuid, $2::uuid, 'owner'::membership_role)
ON CONFLICT (team_id, user_id) DO UPDATE
SET role = EXCLUDED.role;
`, team.ID, prerequisites.admin.ID); err != nil {
return StructureRecord{}, err
}
project, err := upsertNamedRecord(ctx, tx, `
INSERT INTO projects (organization_id, department_id, team_id, name, slug, created_by_user_id)
VALUES ($1::uuid, $2::uuid, $3::uuid, $4, $5, $6::uuid)
ON CONFLICT (organization_id, slug) DO UPDATE
SET department_id = EXCLUDED.department_id, team_id = EXCLUDED.team_id, name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW()
RETURNING id::text, name, slug;
`, organization.ID, department.ID, team.ID, input.ProjectName, primaryProjectSlug, prerequisites.admin.ID)
if err != nil {
return StructureRecord{}, err
}
if _, err := tx.Exec(ctx, `
INSERT INTO project_memberships (project_id, user_id, role)
VALUES ($1::uuid, $2::uuid, 'owner'::membership_role)
ON CONFLICT (project_id, user_id) DO UPDATE
SET role = EXCLUDED.role;
`, project.ID, prerequisites.admin.ID); err != nil {
return StructureRecord{}, err
}
if err := upsertWorkspace(ctx, tx, organization.ID, organization.Name, organizationWorkspaceSlug, bootstrapWorkspaceKindOrg, prerequisites.admin.ID, nil, nil, nil); err != nil {
return StructureRecord{}, err
}
if err := upsertWorkspace(ctx, tx, organization.ID, department.Name, departmentWorkspaceSlug, bootstrapWorkspaceKindDept, prerequisites.admin.ID, &department.ID, nil, nil); err != nil {
return StructureRecord{}, err
}
if err := upsertWorkspace(ctx, tx, organization.ID, team.Name, teamWorkspaceSlug, bootstrapWorkspaceKindTeam, prerequisites.admin.ID, &department.ID, &team.ID, nil); err != nil {
return StructureRecord{}, err
}
if err := upsertWorkspace(ctx, tx, organization.ID, project.Name, projectWorkspaceSlug, bootstrapWorkspaceKindProject, prerequisites.admin.ID, &department.ID, &team.ID, &project.ID); err != nil {
return StructureRecord{}, err
}
installation, err := updateBootstrappedInstallation(ctx, tx)
if err != nil {
return StructureRecord{}, err
}
if err := tx.Commit(ctx); err != nil {
return StructureRecord{}, err
}
if err := service.enqueueBootstrapStructureMaterialization(ctx, &installation); err != nil {
return StructureRecord{}, err
}
return StructureRecord{
Installation: installation,
Organization: organization,
Department: department,
Team: team,
Project: project,
Admin: prerequisites.admin,
}, nil
}
func (service *Service) ResetDevelopmentState(ctx context.Context) error {
tx, err := service.db.Pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
TRUNCATE TABLE
project_memberships,
team_memberships,
organization_memberships,
workspaces,
projects,
teams,
departments,
user_homes,
users,
organizations,
background_jobs,
installations
RESTART IDENTITY;
`); err != nil {
return err
}
return tx.Commit(ctx)
}
func (service *Service) GetAdmin(ctx context.Context) (*AdminRecord, error) {
var record AdminRecord
err := service.db.Pool.QueryRow(ctx, `
SELECT
u.id::text,
u.email,
u.display_name,
u.is_instance_admin,
COALESCE(uh.title, '')
FROM users u
LEFT JOIN user_homes uh ON uh.user_id = u.id
WHERE u.is_instance_admin = TRUE
ORDER BY u.created_at ASC
LIMIT 1;
`).Scan(
&record.ID,
&record.Email,
&record.DisplayName,
&record.IsInstanceAdmin,
&record.HomeTitle,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &record, nil
}
func (service *Service) GetStructure(ctx context.Context) (BootstrapStructureState, error) {
workspaces, err := service.listWorkspaces(ctx)
if err != nil {
return BootstrapStructureState{}, err
}
organization, err := service.loadPrimaryOrganization(ctx)
if err != nil {
return BootstrapStructureState{}, err
}
department, err := service.loadPrimaryDepartment(ctx)
if err != nil {
return BootstrapStructureState{}, err
}
team, err := service.loadPrimaryTeam(ctx)
if err != nil {
return BootstrapStructureState{}, err
}
project, err := service.loadPrimaryProject(ctx)
if err != nil {
return BootstrapStructureState{}, err
}
return BootstrapStructureState{
Organization: organization,
Department: department,
Team: team,
Project: project,
Workspaces: workspaces,
}, nil
}
+695
View File
@@ -0,0 +1,695 @@
package bootstrap
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/fxamacker/cbor/v2"
"github.com/tailscale/hujson"
)
type fakeRow struct {
scan func(dest ...any) error
}
func (row fakeRow) Scan(dest ...any) error {
return row.scan(dest...)
}
func TestScanInstallationRecordDefaultsMaterializationStatus(t *testing.T) {
record, err := scanInstallationRecord(fakeRow{scan: func(dest ...any) error {
*(dest[0].(*string)) = "installation-1"
*(dest[1].(*string)) = "MangoPig"
*(dest[2].(*string)) = "personal"
*(dest[3].(*string)) = "local"
*(dest[4].(*string)) = "http"
*(dest[5].(*string)) = "localhost"
*(dest[6].(*bool)) = true
*(dest[7].(*string)) = ""
*(dest[8].(**string)) = nil
return nil
}})
if err != nil {
t.Fatalf("scanInstallationRecord: %v", err)
}
if record.MaterializationStatus != materializationNotStarted {
t.Fatalf("expected default materialization status %q, got %q", materializationNotStarted, record.MaterializationStatus)
}
if record.MaterializationError != nil {
t.Fatalf("expected nil materialization error, got %#v", record.MaterializationError)
}
}
func TestScanInstallationRecordPreservesMaterializationFields(t *testing.T) {
failure := "projection rebuild failed"
record, err := scanInstallationRecord(fakeRow{scan: func(dest ...any) error {
*(dest[0].(*string)) = "installation-2"
*(dest[1].(*string)) = "MangoPig"
*(dest[2].(*string)) = "personal"
*(dest[3].(*string)) = "local"
*(dest[4].(*string)) = "http"
*(dest[5].(*string)) = "localhost"
*(dest[6].(*bool)) = true
*(dest[7].(*string)) = materializationFailed
*(dest[8].(**string)) = &failure
return nil
}})
if err != nil {
t.Fatalf("scanInstallationRecord: %v", err)
}
if record.MaterializationStatus != materializationFailed {
t.Fatalf("expected materialization status %q, got %q", materializationFailed, record.MaterializationStatus)
}
if record.MaterializationError == nil || *record.MaterializationError != failure {
t.Fatalf("expected materialization error %q, got %#v", failure, record.MaterializationError)
}
}
func TestEnsureBootstrapPOSIXSkeletonInitializesEmptyRoot(t *testing.T) {
rootPath := filepath.Join(t.TempDir(), "POSIX")
t.Setenv("POSIX_ROOT", rootPath)
if _, err := os.Stat(rootPath); !os.IsNotExist(err) {
t.Fatalf("expected isolated POSIX root to start absent, got err=%v", err)
}
service := NewService(nil, os.Getenv("POSIX_ROOT"))
err := service.ensureBootstrapPOSIXSkeleton(
InstallationRecord{
ID: "installation-1",
Name: "MangoPig",
Mode: "personal",
Access: "local",
Protocol: "http",
Host: "localhost",
IsBootstrapped: true,
},
AdminSummary{
ID: "admin-1",
Email: "ronald@example.com",
DisplayName: "Ronald",
},
namedRecord{ID: "org-1", Name: "Primary Organization", Slug: "primary-organization"},
namedRecord{ID: "dept-1", Name: "Primary Department", Slug: "primary-department"},
namedRecord{ID: "team-1", Name: "Primary Team", Slug: "primary-team"},
namedRecord{ID: "project-1", Name: "Primary Project", Slug: "primary-project"},
)
if err != nil {
t.Fatalf("ensure bootstrap POSIX skeleton: %v", err)
}
requiredPaths := []string{
filepath.Join(rootPath, posixSettingsFileName),
filepath.Join(rootPath, posixLayoutFileName),
filepath.Join(rootPath, "catalog", "packs"),
filepath.Join(rootPath, "catalog", "standalone"),
filepath.Join(rootPath, "catalog", "packs", "pack-core", posixManifestFileName),
filepath.Join(rootPath, "catalog", "packs", "pack-core", "entries", "app-shell", posixManifestFileName),
filepath.Join(rootPath, "catalog", "standalone", "app-shell", posixManifestFileName),
filepath.Join(rootPath, "departments", "department-primary-department", posixSettingsFileName),
filepath.Join(rootPath, "departments", "department-primary-department", posixUsersFileName),
filepath.Join(rootPath, "departments", "department-primary-department", "teams", "team-primary-team", posixSettingsFileName),
filepath.Join(rootPath, "departments", "department-primary-department", "teams", "team-primary-team", posixUsersFileName),
filepath.Join(rootPath, "projects", "project-primary-project", posixSettingsFileName),
filepath.Join(rootPath, "projects", "project-primary-project", posixHomeFileName),
filepath.Join(rootPath, "projects", "project-primary-project", posixACLFileName),
filepath.Join(rootPath, "projects", "project-primary-project", "children"),
filepath.Join(rootPath, "projects", "project-primary-project", "tree"),
filepath.Join(rootPath, "users", posixSettingsFileName),
filepath.Join(rootPath, "users", posixDataFileName),
filepath.Join(rootPath, "users", "personals"),
filepath.Join(rootPath, "users", "personals", "personal-ronald", posixSettingsFileName),
filepath.Join(rootPath, "users", "personals", "personal-ronald", posixLayoutFileName),
filepath.Join(rootPath, "users", "personals", "personal-ronald", posixHomeFileName),
filepath.Join(rootPath, "users", "personals", "personal-ronald", "tree"),
}
for _, path := range requiredPaths {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected path to exist %s: %v", path, err)
}
}
settingsPayload := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, posixSettingsFileName))
installationPayload, ok := settingsPayload["installation"].(map[string]any)
if !ok {
t.Fatalf("%s missing installation object: %#v", posixSettingsFileName, settingsPayload)
}
if installationPayload["name"] != "MangoPig" {
t.Fatalf("expected installation name MangoPig, got %#v", installationPayload["name"])
}
if installationPayload["isBootstrapped"] != true {
t.Fatalf("expected installation to be bootstrapped, got %#v", installationPayload["isBootstrapped"])
}
layoutPayload := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, posixLayoutFileName))
homePayload, ok := layoutPayload["home"].(map[string]any)
if !ok {
t.Fatalf("%s missing home object: %#v", posixLayoutFileName, layoutPayload)
}
if homePayload["defaultProjectSlug"] != "primary-project" {
t.Fatalf("expected default project slug primary-project, got %#v", homePayload["defaultProjectSlug"])
}
packManifest := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, "catalog", "packs", "pack-core", posixManifestFileName))
if packManifest["type"] != "pack" {
t.Fatalf("expected pack manifest type pack, got %#v", packManifest["type"])
}
if packManifest["slug"] != "core" {
t.Fatalf("expected pack manifest slug core, got %#v", packManifest["slug"])
}
entryManifest := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, "catalog", "packs", "pack-core", "entries", "app-shell", posixManifestFileName))
runtimePayload, ok := entryManifest["runtime"].(map[string]any)
if !ok {
t.Fatalf("%s missing runtime object: %#v", posixManifestFileName, entryManifest)
}
if runtimePayload["path"] != "/v1/app-shell" {
t.Fatalf("expected app-shell runtime path /v1/app-shell, got %#v", runtimePayload["path"])
}
standaloneManifest := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, "catalog", "standalone", "app-shell", posixManifestFileName))
if standaloneManifest["source"] != "standalone" {
t.Fatalf("expected standalone manifest source standalone, got %#v", standaloneManifest["source"])
}
projectSettings := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, "projects", "project-primary-project", posixSettingsFileName))
if projectSettings["type"] != "project" {
t.Fatalf("expected project settings type project, got %#v", projectSettings["type"])
}
projectACL := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, "projects", "project-primary-project", posixACLFileName))
if projectACL["inherits"] != true {
t.Fatalf("expected project acl to inherit by default, got %#v", projectACL["inherits"])
}
usersSettings := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, "users", posixSettingsFileName))
if usersSettings["primaryAdminId"] != "admin-1" {
t.Fatalf("expected primary admin id admin-1, got %#v", usersSettings["primaryAdminId"])
}
personalSettings := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, "users", "personals", "personal-ronald", posixSettingsFileName))
if personalSettings["type"] != "personal" {
t.Fatalf("expected personal settings type personal, got %#v", personalSettings["type"])
}
if personalSettings["name"] != "Ronald" {
t.Fatalf("expected personal name Ronald, got %#v", personalSettings["name"])
}
if personalSettings["slug"] != "ronald" {
t.Fatalf("expected personal slug ronald, got %#v", personalSettings["slug"])
}
personalHome := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, "users", "personals", "personal-ronald", posixHomeFileName))
if personalHome["type"] != "personal-home" {
t.Fatalf("expected personal home type personal-home, got %#v", personalHome["type"])
}
if personalHome["title"] != "Ronald's Home" {
t.Fatalf("expected personal home title Ronald's Home, got %#v", personalHome["title"])
}
}
func TestCreateProjectHierarchyFolderOnDiskCreatesExpectedFolderShape(t *testing.T) {
rootPath := filepath.Join(t.TempDir(), "POSIX")
service := NewService(nil, rootPath)
err := service.ensureBootstrapPOSIXSkeleton(
InstallationRecord{ID: "installation-1", Name: "MangoPig", Mode: "personal", Access: "local", Protocol: "http", Host: "localhost", IsBootstrapped: true},
AdminSummary{ID: "admin-1", Email: "ronald@example.com", DisplayName: "Ronald"},
namedRecord{ID: "org-1", Name: "Primary Organization", Slug: "primary-organization"},
namedRecord{ID: "dept-1", Name: "Primary Department", Slug: "primary-department"},
namedRecord{ID: "team-1", Name: "Primary Team", Slug: "primary-team"},
namedRecord{ID: "project-1", Name: "Primary Project", Slug: "primary-project"},
)
if err != nil {
t.Fatalf("ensure bootstrap POSIX skeleton: %v", err)
}
createdPath, createdSlug, err := service.createProjectHierarchyFolderOnDisk("primary-project", "", "Design System")
if err != nil {
t.Fatalf("createProjectHierarchyFolderOnDisk root folder: %v", err)
}
if createdPath != "projects/project-primary-project/children/folder-design-system" {
t.Fatalf("unexpected created path: %s", createdPath)
}
if createdSlug != "design-system" {
t.Fatalf("unexpected created slug: %s", createdSlug)
}
createdFolderPath := filepath.Join(rootPath, "projects", "project-primary-project", "children", "folder-design-system")
for _, path := range []string{
filepath.Join(createdFolderPath, posixFolderFileName),
filepath.Join(createdFolderPath, posixACLFileName),
filepath.Join(createdFolderPath, "children"),
} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected path to exist %s: %v", path, err)
}
}
folderPayload := readStructuredFileForTest[map[string]any](t, filepath.Join(createdFolderPath, posixFolderFileName))
if strings.TrimSpace(asStringForTest(folderPayload["id"])) == "" {
t.Fatalf("expected created folder to have stable id, got %#v", folderPayload["id"])
}
if folderPayload["name"] != "Design System" {
t.Fatalf("expected folder name Design System, got %#v", folderPayload["name"])
}
if folderPayload["slug"] != "design-system" {
t.Fatalf("expected folder slug design-system, got %#v", folderPayload["slug"])
}
nestedPath, nestedSlug, err := service.createProjectHierarchyFolderOnDisk("primary-project", createdPath, "Research")
if err != nil {
t.Fatalf("createProjectHierarchyFolderOnDisk nested folder: %v", err)
}
if nestedPath != "projects/project-primary-project/children/folder-design-system/children/folder-research" {
t.Fatalf("unexpected nested path: %s", nestedPath)
}
if nestedSlug != "research" {
t.Fatalf("unexpected nested slug: %s", nestedSlug)
}
}
func TestCreateProjectTreeFolderOnDiskCreatesExpectedFolderShape(t *testing.T) {
rootPath := filepath.Join(t.TempDir(), "POSIX")
service := NewService(nil, rootPath)
err := service.ensureBootstrapPOSIXSkeleton(
InstallationRecord{ID: "installation-1", Name: "MangoPig", Mode: "personal", Access: "local", Protocol: "http", Host: "localhost", IsBootstrapped: true},
AdminSummary{ID: "admin-1", Email: "ronald@example.com", DisplayName: "Ronald"},
namedRecord{ID: "org-1", Name: "Primary Organization", Slug: "primary-organization"},
namedRecord{ID: "dept-1", Name: "Primary Department", Slug: "primary-department"},
namedRecord{ID: "team-1", Name: "Primary Team", Slug: "primary-team"},
namedRecord{ID: "project-1", Name: "Primary Project", Slug: "primary-project"},
)
if err != nil {
t.Fatalf("ensure bootstrap POSIX skeleton: %v", err)
}
createdPath, createdSlug, err := service.createProjectTreeFolderOnDisk("primary-project", "", "Docs")
if err != nil {
t.Fatalf("createProjectTreeFolderOnDisk root folder: %v", err)
}
if createdPath != "projects/project-primary-project/tree/folder-docs" {
t.Fatalf("unexpected created path: %s", createdPath)
}
if createdSlug != "docs" {
t.Fatalf("unexpected created slug: %s", createdSlug)
}
createdFolderPath := filepath.Join(rootPath, "projects", "project-primary-project", "tree", "folder-docs")
for _, path := range []string{
filepath.Join(createdFolderPath, posixFolderFileName),
filepath.Join(createdFolderPath, posixACLFileName),
filepath.Join(createdFolderPath, "children"),
} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected path to exist %s: %v", path, err)
}
}
nestedPath, nestedSlug, err := service.createProjectTreeFolderOnDisk("primary-project", createdPath, "Research")
if err != nil {
t.Fatalf("createProjectTreeFolderOnDisk nested folder: %v", err)
}
if nestedPath != "projects/project-primary-project/tree/folder-docs/children/folder-research" {
t.Fatalf("unexpected nested path: %s", nestedPath)
}
if nestedSlug != "research" {
t.Fatalf("unexpected nested slug: %s", nestedSlug)
}
}
func TestRenameProjectHierarchyFolderOnDiskRenamesFolderShape(t *testing.T) {
rootPath := filepath.Join(t.TempDir(), "POSIX")
service := NewService(nil, rootPath)
err := service.ensureBootstrapPOSIXSkeleton(
InstallationRecord{ID: "installation-1", Name: "MangoPig", Mode: "personal", Access: "local", Protocol: "http", Host: "localhost", IsBootstrapped: true},
AdminSummary{ID: "admin-1", Email: "ronald@example.com", DisplayName: "Ronald"},
namedRecord{ID: "org-1", Name: "Primary Organization", Slug: "primary-organization"},
namedRecord{ID: "dept-1", Name: "Primary Department", Slug: "primary-department"},
namedRecord{ID: "team-1", Name: "Primary Team", Slug: "primary-team"},
namedRecord{ID: "project-1", Name: "Primary Project", Slug: "primary-project"},
)
if err != nil {
t.Fatalf("ensure bootstrap POSIX skeleton: %v", err)
}
createdPath, _, err := service.createProjectHierarchyFolderOnDisk("primary-project", "", "Design System")
if err != nil {
t.Fatalf("createProjectHierarchyFolderOnDisk root folder: %v", err)
}
nestedPath, _, err := service.createProjectHierarchyFolderOnDisk("primary-project", createdPath, "Research")
if err != nil {
t.Fatalf("createProjectHierarchyFolderOnDisk nested folder: %v", err)
}
previousPath, renamedPath, err := service.renameProjectHierarchyFolderOnDisk("primary-project", createdPath, "Platform Design")
if err != nil {
t.Fatalf("renameProjectHierarchyFolderOnDisk: %v", err)
}
if previousPath != createdPath {
t.Fatalf("expected previous path %s, got %s", createdPath, previousPath)
}
if renamedPath != "projects/project-primary-project/children/folder-platform-design" {
t.Fatalf("unexpected renamed path: %s", renamedPath)
}
if _, err := os.Stat(filepath.Join(rootPath, filepath.FromSlash(createdPath))); !os.IsNotExist(err) {
t.Fatalf("expected previous folder path to be gone, got err=%v", err)
}
renamedFolderPath := filepath.Join(rootPath, filepath.FromSlash(renamedPath))
if _, err := os.Stat(filepath.Join(renamedFolderPath, "children", filepath.Base(nestedPath))); err != nil {
t.Fatalf("expected nested child folder to move with renamed parent: %v", err)
}
folderPayload := readStructuredFileForTest[map[string]any](t, filepath.Join(renamedFolderPath, posixFolderFileName))
if strings.TrimSpace(asStringForTest(folderPayload["id"])) == "" {
t.Fatalf("expected renamed folder to preserve stable id, got %#v", folderPayload["id"])
}
if folderPayload["name"] != "Platform Design" {
t.Fatalf("expected renamed folder name Platform Design, got %#v", folderPayload["name"])
}
if folderPayload["slug"] != "platform-design" {
t.Fatalf("expected renamed folder slug platform-design, got %#v", folderPayload["slug"])
}
if folderPayload["type"] != "folder" {
t.Fatalf("expected renamed folder type folder, got %#v", folderPayload["type"])
}
}
func TestRenameProjectTreeFolderOnDiskRenamesFolderShape(t *testing.T) {
rootPath := filepath.Join(t.TempDir(), "POSIX")
service := NewService(nil, rootPath)
err := service.ensureBootstrapPOSIXSkeleton(
InstallationRecord{ID: "installation-1", Name: "MangoPig", Mode: "personal", Access: "local", Protocol: "http", Host: "localhost", IsBootstrapped: true},
AdminSummary{ID: "admin-1", Email: "ronald@example.com", DisplayName: "Ronald"},
namedRecord{ID: "org-1", Name: "Primary Organization", Slug: "primary-organization"},
namedRecord{ID: "dept-1", Name: "Primary Department", Slug: "primary-department"},
namedRecord{ID: "team-1", Name: "Primary Team", Slug: "primary-team"},
namedRecord{ID: "project-1", Name: "Primary Project", Slug: "primary-project"},
)
if err != nil {
t.Fatalf("ensure bootstrap POSIX skeleton: %v", err)
}
createdPath, _, err := service.createProjectTreeFolderOnDisk("primary-project", "", "Docs")
if err != nil {
t.Fatalf("createProjectTreeFolderOnDisk root folder: %v", err)
}
previousPath, renamedPath, err := service.renameProjectTreeFolderOnDisk("primary-project", createdPath, "Specifications")
if err != nil {
t.Fatalf("renameProjectTreeFolderOnDisk: %v", err)
}
if previousPath != createdPath {
t.Fatalf("expected previous path %s, got %s", createdPath, previousPath)
}
if renamedPath != "projects/project-primary-project/tree/folder-specifications" {
t.Fatalf("unexpected renamed path: %s", renamedPath)
}
folderPayload := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, filepath.FromSlash(renamedPath), posixFolderFileName))
if folderPayload["name"] != "Specifications" {
t.Fatalf("expected renamed folder name Specifications, got %#v", folderPayload["name"])
}
if folderPayload["slug"] != "specifications" {
t.Fatalf("expected renamed folder slug specifications, got %#v", folderPayload["slug"])
}
}
func TestMoveProjectHierarchyFolderOnDiskMovesFolderToNewParent(t *testing.T) {
rootPath := filepath.Join(t.TempDir(), "POSIX")
service := NewService(nil, rootPath)
err := service.ensureBootstrapPOSIXSkeleton(
InstallationRecord{ID: "installation-1", Name: "MangoPig", Mode: "personal", Access: "local", Protocol: "http", Host: "localhost", IsBootstrapped: true},
AdminSummary{ID: "admin-1", Email: "ronald@example.com", DisplayName: "Ronald"},
namedRecord{ID: "org-1", Name: "Primary Organization", Slug: "primary-organization"},
namedRecord{ID: "dept-1", Name: "Primary Department", Slug: "primary-department"},
namedRecord{ID: "team-1", Name: "Primary Team", Slug: "primary-team"},
namedRecord{ID: "project-1", Name: "Primary Project", Slug: "primary-project"},
)
if err != nil {
t.Fatalf("ensure bootstrap POSIX skeleton: %v", err)
}
designPath, _, err := service.createProjectHierarchyFolderOnDisk("primary-project", "", "Design")
if err != nil {
t.Fatalf("create design folder: %v", err)
}
operationsPath, _, err := service.createProjectHierarchyFolderOnDisk("primary-project", "", "Operations")
if err != nil {
t.Fatalf("create operations folder: %v", err)
}
researchPath, _, err := service.createProjectHierarchyFolderOnDisk("primary-project", designPath, "Research")
if err != nil {
t.Fatalf("create research folder: %v", err)
}
nestedPath, _, err := service.createProjectHierarchyFolderOnDisk("primary-project", researchPath, "Interview Notes")
if err != nil {
t.Fatalf("create nested folder: %v", err)
}
previousPath, movedPath, err := service.moveProjectHierarchyFolderOnDisk("primary-project", researchPath, operationsPath)
if err != nil {
t.Fatalf("moveProjectHierarchyFolderOnDisk: %v", err)
}
if previousPath != researchPath {
t.Fatalf("expected previous path %s, got %s", researchPath, previousPath)
}
if movedPath != "projects/project-primary-project/children/folder-operations/children/folder-research" {
t.Fatalf("unexpected moved path: %s", movedPath)
}
if _, err := os.Stat(filepath.Join(rootPath, filepath.FromSlash(researchPath))); !os.IsNotExist(err) {
t.Fatalf("expected previous folder path to be gone, got err=%v", err)
}
movedFolderPath := filepath.Join(rootPath, filepath.FromSlash(movedPath))
if _, err := os.Stat(filepath.Join(movedFolderPath, "children", filepath.Base(nestedPath))); err != nil {
t.Fatalf("expected nested child folder to move with moved parent: %v", err)
}
folderPayload := readStructuredFileForTest[map[string]any](t, filepath.Join(movedFolderPath, posixFolderFileName))
if strings.TrimSpace(asStringForTest(folderPayload["id"])) == "" {
t.Fatalf("expected moved folder to preserve stable id, got %#v", folderPayload["id"])
}
if folderPayload["name"] != "Research" {
t.Fatalf("expected moved folder name Research, got %#v", folderPayload["name"])
}
if folderPayload["slug"] != "research" {
t.Fatalf("expected moved folder slug research, got %#v", folderPayload["slug"])
}
if folderPayload["type"] != "folder" {
t.Fatalf("expected moved folder type folder, got %#v", folderPayload["type"])
}
}
func TestMoveProjectTreeFolderOnDiskMovesFolderToNewParent(t *testing.T) {
rootPath := filepath.Join(t.TempDir(), "POSIX")
service := NewService(nil, rootPath)
err := service.ensureBootstrapPOSIXSkeleton(
InstallationRecord{ID: "installation-1", Name: "MangoPig", Mode: "personal", Access: "local", Protocol: "http", Host: "localhost", IsBootstrapped: true},
AdminSummary{ID: "admin-1", Email: "ronald@example.com", DisplayName: "Ronald"},
namedRecord{ID: "org-1", Name: "Primary Organization", Slug: "primary-organization"},
namedRecord{ID: "dept-1", Name: "Primary Department", Slug: "primary-department"},
namedRecord{ID: "team-1", Name: "Primary Team", Slug: "primary-team"},
namedRecord{ID: "project-1", Name: "Primary Project", Slug: "primary-project"},
)
if err != nil {
t.Fatalf("ensure bootstrap POSIX skeleton: %v", err)
}
docsPath, _, err := service.createProjectTreeFolderOnDisk("primary-project", "", "Docs")
if err != nil {
t.Fatalf("create docs folder: %v", err)
}
archivePath, _, err := service.createProjectTreeFolderOnDisk("primary-project", "", "Archive")
if err != nil {
t.Fatalf("create archive folder: %v", err)
}
previousPath, movedPath, err := service.moveProjectTreeFolderOnDisk("primary-project", docsPath, archivePath)
if err != nil {
t.Fatalf("moveProjectTreeFolderOnDisk: %v", err)
}
if previousPath != docsPath {
t.Fatalf("expected previous path %s, got %s", docsPath, previousPath)
}
if movedPath != "projects/project-primary-project/tree/folder-archive/children/folder-docs" {
t.Fatalf("unexpected moved path: %s", movedPath)
}
folderPayload := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, filepath.FromSlash(movedPath), posixFolderFileName))
if folderPayload["name"] != "Docs" {
t.Fatalf("expected moved folder name Docs, got %#v", folderPayload["name"])
}
if folderPayload["slug"] != "docs" {
t.Fatalf("expected moved folder slug docs, got %#v", folderPayload["slug"])
}
}
func TestMoveProjectHierarchyFolderOnDiskRejectsDescendantTarget(t *testing.T) {
rootPath := filepath.Join(t.TempDir(), "POSIX")
service := NewService(nil, rootPath)
err := service.ensureBootstrapPOSIXSkeleton(
InstallationRecord{ID: "installation-1", Name: "MangoPig", Mode: "personal", Access: "local", Protocol: "http", Host: "localhost", IsBootstrapped: true},
AdminSummary{ID: "admin-1", Email: "ronald@example.com", DisplayName: "Ronald"},
namedRecord{ID: "org-1", Name: "Primary Organization", Slug: "primary-organization"},
namedRecord{ID: "dept-1", Name: "Primary Department", Slug: "primary-department"},
namedRecord{ID: "team-1", Name: "Primary Team", Slug: "primary-team"},
namedRecord{ID: "project-1", Name: "Primary Project", Slug: "primary-project"},
)
if err != nil {
t.Fatalf("ensure bootstrap POSIX skeleton: %v", err)
}
parentPath, _, err := service.createProjectHierarchyFolderOnDisk("primary-project", "", "Parent")
if err != nil {
t.Fatalf("create parent folder: %v", err)
}
childPath, _, err := service.createProjectHierarchyFolderOnDisk("primary-project", parentPath, "Child")
if err != nil {
t.Fatalf("create child folder: %v", err)
}
_, _, err = service.moveProjectHierarchyFolderOnDisk("primary-project", parentPath, childPath)
if !errors.Is(err, ErrInvalidProjectFolderMove) {
t.Fatalf("expected ErrInvalidProjectFolderMove, got %v", err)
}
}
func TestBuildProjectHierarchyFolderTreeBuildsNestedStructure(t *testing.T) {
rows := []projectHierarchyFolderRow{
{ID: "folder-design-id", Path: "projects/project-primary-project/children/folder-design", ParentPath: projectHierarchyRootPath("primary-project"), Label: "Design"},
{ID: "folder-research-id", Path: "projects/project-primary-project/children/folder-design/children/folder-research", ParentPath: "projects/project-primary-project/children/folder-design/children", Label: "Research"},
{ID: "folder-ops-id", Path: "projects/project-primary-project/children/folder-ops", ParentPath: projectHierarchyRootPath("primary-project"), Label: "Ops"},
}
folders := buildProjectHierarchyFolderTree(rows, projectHierarchyRootPath("primary-project"))
if len(folders) != 2 {
t.Fatalf("expected 2 top-level folders, got %d", len(folders))
}
if folders[0].Label != "Design" || folders[1].Label != "Ops" {
t.Fatalf("unexpected top-level folder labels: %#v", folders)
}
if len(folders[0].Children) != 1 || folders[0].Children[0].Label != "Research" {
t.Fatalf("unexpected nested folder structure: %#v", folders[0].Children)
}
if folders[0].ID != "folder-design-id" || folders[0].Path != "projects/project-primary-project/children/folder-design" {
t.Fatalf("expected design folder to retain stable id/path, got %#v", folders[0])
}
if folders[0].Children[0].ID != "folder-research-id" || folders[1].ID != "folder-ops-id" {
t.Fatalf("expected nested/top-level folder ids to be preserved, got %#v / %#v", folders[0].Children[0], folders[1])
}
}
func TestApplyProjectHierarchyFolderOrderingOrdersRootAndChildrenByStableID(t *testing.T) {
folders := []ProjectHierarchyFolderRecord{
{
ID: "folder-design-id",
Path: "projects/project-primary-project/children/folder-design",
Label: "Design",
Children: []ProjectHierarchyFolderRecord{
{ID: "folder-research-id", Path: "projects/project-primary-project/children/folder-design/children/folder-research", Label: "Research"},
{ID: "folder-assets-id", Path: "projects/project-primary-project/children/folder-design/children/folder-assets", Label: "Assets"},
},
},
{ID: "folder-ops-id", Path: "projects/project-primary-project/children/folder-ops", Label: "Ops"},
{ID: "folder-qa-id", Path: "projects/project-primary-project/children/folder-qa", Label: "QA"},
}
ordered := applyProjectHierarchyFolderOrdering(folders, map[string][]string{
projectFolderOrderRootKey: {"folder-qa-id", "folder-design-id"},
"folder-design-id": {"folder-assets-id", "folder-research-id"},
})
if len(ordered) != 3 {
t.Fatalf("expected 3 ordered root folders, got %d", len(ordered))
}
if ordered[0].ID != "folder-qa-id" || ordered[1].ID != "folder-design-id" || ordered[2].ID != "folder-ops-id" {
t.Fatalf("unexpected ordered root ids: %#v", ordered)
}
if len(ordered[1].Children) != 2 {
t.Fatalf("expected design folder children to be preserved, got %#v", ordered[1].Children)
}
if ordered[1].Children[0].ID != "folder-assets-id" || ordered[1].Children[1].ID != "folder-research-id" {
t.Fatalf("unexpected ordered child ids: %#v", ordered[1].Children)
}
}
func TestInsertFolderOrderReordersWithinSameParent(t *testing.T) {
folderOrder := map[string][]string{
projectFolderOrderRootKey: {"folder-a", "folder-b", "folder-c"},
}
insertFolderOrder(folderOrder, "", "folder-c", 0)
got := folderOrder[projectFolderOrderRootKey]
if len(got) != 3 || got[0] != "folder-c" || got[1] != "folder-a" || got[2] != "folder-b" {
t.Fatalf("unexpected reordered root children: %#v", got)
}
}
func asStringForTest(value any) string {
text, _ := value.(string)
return text
}
func readStructuredFileForTest[T any](t *testing.T, path string) T {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var raw any
switch strings.ToLower(filepath.Ext(path)) {
case ".cbor":
if err := cbor.Unmarshal(data, &raw); err != nil {
t.Fatalf("unmarshal %s: %v", path, err)
}
case ".json":
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatalf("unmarshal %s: %v", path, err)
}
case ".jsonc":
ast, err := hujson.Parse(data)
if err != nil {
t.Fatalf("parse jsonc %s: %v", path, err)
}
ast.Standardize()
if err := json.Unmarshal(ast.Pack(), &raw); err != nil {
t.Fatalf("unmarshal standardized %s: %v", path, err)
}
default:
t.Fatalf("unsupported structured test file %s", path)
}
normalizedBytes, err := json.Marshal(normalizeStructuredValue(raw))
if err != nil {
t.Fatalf("normalize %s: %v", path, err)
}
var payload T
if err := json.Unmarshal(normalizedBytes, &payload); err != nil {
t.Fatalf("decode normalized %s: %v", path, err)
}
return payload
}
+2
View File
@@ -17,6 +17,7 @@ type Config struct {
APIPort string APIPort string
PostgresURL string PostgresURL string
ValkeyURL string ValkeyURL string
POSIXRoot string
ShutdownTimeout time.Duration ShutdownTimeout time.Duration
} }
@@ -29,6 +30,7 @@ func Load() *Config {
APIPort: getEnv("BACKEND_API_PORT", "8081"), APIPort: getEnv("BACKEND_API_PORT", "8081"),
PostgresURL: getEnv("DATABASE_URL", "postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable"), PostgresURL: getEnv("DATABASE_URL", "postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable"),
ValkeyURL: getEnv("VALKEY_URL", "redis://localhost:6379/0"), ValkeyURL: getEnv("VALKEY_URL", "redis://localhost:6379/0"),
POSIXRoot: getEnv("POSIX_ROOT", "../POSIX"),
ShutdownTimeout: getDurationEnv("BACKEND_SHUTDOWN_TIMEOUT", 10*time.Second), ShutdownTimeout: getDurationEnv("BACKEND_SHUTDOWN_TIMEOUT", 10*time.Second),
} }
} }
@@ -0,0 +1,396 @@
// Path: Backend/internal/httpx/api_bootstrap_routes.go
package httpx
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
bootstrapservice "moku-backend/internal/bootstrap"
)
type bootstrapInstanceStepRequest struct {
Protocol string `json:"protocol"`
Access string `json:"access"`
Host string `json:"host"`
}
type bootstrapModeStepRequest struct {
Mode string `json:"mode"`
Name string `json:"name"`
}
type bootstrapAdminStepRequest struct {
DisplayName string `json:"displayName"`
Email string `json:"email"`
Password string `json:"password"`
}
type bootstrapStructureStepRequest struct {
OrganizationName string `json:"organizationName"`
DepartmentName string `json:"departmentName"`
TeamName string `json:"teamName"`
ProjectName string `json:"projectName"`
}
func (routes apiRoutes) handleBootstrapOverview(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"resource": "bootstrap",
"status": "persisted",
"steps": []map[string]string{
{
"id": "instance",
"method": http.MethodPost,
"path": "/v1/bootstrap/steps/instance",
},
{
"id": "mode",
"method": http.MethodPost,
"path": "/v1/bootstrap/steps/mode",
},
{
"id": "admin",
"method": http.MethodPost,
"path": "/v1/bootstrap/steps/admin",
},
{
"id": "structure",
"method": http.MethodPost,
"path": "/v1/bootstrap/steps/structure",
},
{
"id": "installation",
"method": http.MethodGet,
"path": "/v1/bootstrap/installation",
},
{
"id": "admin-state",
"method": http.MethodGet,
"path": "/v1/bootstrap/admin",
},
{
"id": "structure-state",
"method": http.MethodGet,
"path": "/v1/bootstrap/structure",
},
{
"id": "bootstrap-state",
"method": http.MethodGet,
"path": "/v1/bootstrap/state",
},
{
"id": "app-shell",
"method": http.MethodGet,
"path": "/v1/app-shell",
},
},
},
})
}
func (routes apiRoutes) handleBootstrapInstallation(w http.ResponseWriter, r *http.Request) {
record, err := routes.bootstrapService().GetInstallation(r.Context())
if err != nil {
routes.writeBootstrapPersistenceError(w, r, err)
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": record,
"meta": map[string]any{
"resource": "bootstrap-installation",
},
})
}
func (routes apiRoutes) handleBootstrapAdmin(w http.ResponseWriter, r *http.Request) {
record, err := routes.bootstrapService().GetAdmin(r.Context())
if err != nil {
routes.writeBootstrapPersistenceError(w, r, err)
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": record,
"meta": map[string]any{
"resource": "bootstrap-admin",
},
})
}
func (routes apiRoutes) handleBootstrapStructure(w http.ResponseWriter, r *http.Request) {
record, err := routes.bootstrapService().GetStructure(r.Context())
if err != nil {
routes.writeBootstrapPersistenceError(w, r, err)
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": record,
"meta": map[string]any{
"resource": "bootstrap-structure",
},
})
}
func (routes apiRoutes) handleBootstrapState(w http.ResponseWriter, r *http.Request) {
record, err := routes.bootstrapService().GetState(r.Context())
if err != nil {
routes.writeBootstrapPersistenceError(w, r, err)
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": record,
"meta": map[string]any{
"resource": "bootstrap-state",
},
})
}
func (routes apiRoutes) handleAppShellState(w http.ResponseWriter, r *http.Request) {
record, err := routes.bootstrapService().GetAppShellState(r.Context())
if err != nil {
routes.writeBootstrapPersistenceError(w, r, err)
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": record,
"meta": map[string]any{
"resource": "app-shell",
},
})
}
func (routes apiRoutes) handleDevelopmentBootstrapReset(w http.ResponseWriter, r *http.Request) {
if !routes.cfg.Config.IsDevelopment() {
WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", "The requested endpoint does not exist.")
return
}
if err := routes.bootstrapService().ResetDevelopmentState(r.Context()); err != nil {
routes.writeBootstrapPersistenceError(w, r, err)
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"reset": true,
},
"meta": map[string]any{
"resource": "development-bootstrap-reset",
"developmentOnly": true,
},
})
}
func (routes apiRoutes) handleBootstrapInstanceStep(w http.ResponseWriter, r *http.Request) {
payload, ok := decodeBootstrapRequest[bootstrapInstanceStepRequest](w, r)
if !ok {
return
}
payload.Protocol = strings.ToLower(strings.TrimSpace(payload.Protocol))
payload.Access = strings.ToLower(strings.TrimSpace(payload.Access))
payload.Host = strings.TrimSpace(payload.Host)
if payload.Protocol != "http" && payload.Protocol != "https" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Protocol must be either 'http' or 'https'.")
return
}
if payload.Access != "local" && payload.Access != "remote" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Access must be either 'local' or 'remote'.")
return
}
if payload.Host == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Host is required.")
return
}
record, err := routes.bootstrapService().SaveInstance(r.Context(), bootstrapservice.SaveInstanceInput{
Protocol: payload.Protocol,
Access: payload.Access,
Host: payload.Host,
})
if err != nil {
routes.writeBootstrapPersistenceError(w, r, err)
return
}
routes.writeBootstrapStepResponse(w, http.StatusOK, "instance", map[string]any{
"request": payload,
"installation": record,
})
}
func (routes apiRoutes) handleBootstrapModeStep(w http.ResponseWriter, r *http.Request) {
payload, ok := decodeBootstrapRequest[bootstrapModeStepRequest](w, r)
if !ok {
return
}
payload.Mode = strings.ToLower(strings.TrimSpace(payload.Mode))
payload.Name = strings.TrimSpace(payload.Name)
if payload.Mode != "personal" && payload.Mode != "organizational" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Mode must be either 'personal' or 'organizational'.")
return
}
if payload.Name == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Name is required.")
return
}
record, err := routes.bootstrapService().SaveMode(r.Context(), bootstrapservice.SaveModeInput{Mode: payload.Mode, Name: payload.Name})
if err != nil {
routes.writeBootstrapPersistenceError(w, r, err)
return
}
routes.writeBootstrapStepResponse(w, http.StatusOK, "mode", map[string]any{
"request": payload,
"installation": record,
})
}
func (routes apiRoutes) handleBootstrapAdminStep(w http.ResponseWriter, r *http.Request) {
payload, ok := decodeBootstrapRequest[bootstrapAdminStepRequest](w, r)
if !ok {
return
}
payload.DisplayName = strings.TrimSpace(payload.DisplayName)
payload.Email = strings.ToLower(strings.TrimSpace(payload.Email))
if payload.DisplayName == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Display name is required.")
return
}
if payload.Email == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Email is required.")
return
}
if strings.TrimSpace(payload.Password) == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Password is required.")
return
}
record, err := routes.bootstrapService().SaveAdmin(r.Context(), bootstrapservice.SaveAdminInput{
DisplayName: payload.DisplayName,
Email: payload.Email,
Password: payload.Password,
})
if err != nil {
routes.writeBootstrapPersistenceError(w, r, err)
return
}
routes.writeBootstrapStepResponse(w, http.StatusOK, "admin", map[string]any{
"request": payload,
"admin": record,
})
}
func (routes apiRoutes) handleBootstrapStructureStep(w http.ResponseWriter, r *http.Request) {
payload, ok := decodeBootstrapRequest[bootstrapStructureStepRequest](w, r)
if !ok {
return
}
payload.OrganizationName = strings.TrimSpace(payload.OrganizationName)
payload.DepartmentName = strings.TrimSpace(payload.DepartmentName)
payload.TeamName = strings.TrimSpace(payload.TeamName)
payload.ProjectName = strings.TrimSpace(payload.ProjectName)
if payload.DepartmentName == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Department name is required.")
return
}
if payload.TeamName == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Team name is required.")
return
}
if payload.ProjectName == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project name is required.")
return
}
record, err := routes.bootstrapService().SaveStructure(r.Context(), bootstrapservice.SaveStructureInput{
OrganizationName: payload.OrganizationName,
DepartmentName: payload.DepartmentName,
TeamName: payload.TeamName,
ProjectName: payload.ProjectName,
})
if err != nil {
routes.writeBootstrapPersistenceError(w, r, err)
return
}
routes.writeBootstrapStepResponse(w, http.StatusOK, "structure", map[string]any{
"request": payload,
"structure": record,
})
}
func (routes apiRoutes) bootstrapService() *bootstrapservice.Service {
return bootstrapservice.NewService(routes.cfg.Database, routes.cfg.Config.POSIXRoot)
}
func (routes apiRoutes) writeBootstrapStepResponse(w http.ResponseWriter, status int, step string, payload any) {
WriteJSON(w, status, map[string]any{
"data": map[string]any{
"step": step,
"result": payload,
},
"meta": map[string]any{
"resource": "bootstrap-step",
"persisted": true,
},
})
}
func (routes apiRoutes) writeBootstrapPersistenceError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, bootstrapservice.ErrInstallationNotConfigured), errors.Is(err, bootstrapservice.ErrAdminNotConfigured):
WriteError(w, http.StatusConflict, RequestIDFromContext(r.Context()), "bootstrap_prerequisite_missing", err.Error())
default:
routes.cfg.Logger.Error("persist bootstrap step", "error", err, "path", r.URL.Path)
message := "Failed to persist bootstrap data."
if routes.cfg.Config.IsDevelopment() {
message = message + " " + err.Error()
}
WriteError(w, http.StatusInternalServerError, RequestIDFromContext(r.Context()), "bootstrap_persist_failed", message)
}
}
func decodeBootstrapRequest[T any](w http.ResponseWriter, r *http.Request) (T, bool) {
var payload T
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil {
if errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body is required and must be valid JSON for this bootstrap step.")
return payload, false
}
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON for this bootstrap step.")
return payload, false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must contain a single JSON object.")
return payload, false
}
return payload, true
}
@@ -0,0 +1,127 @@
// Path: Backend/internal/httpx/api_project_decode.go
package httpx
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
)
type createProjectFolderRequest struct {
Name string `json:"name"`
ParentFolderPath string `json:"parentFolderId"`
}
type renameProjectFolderRequest struct {
FolderPath string `json:"folderId"`
Name string `json:"name"`
}
type deleteProjectFolderRequest struct {
FolderPath string `json:"folderId"`
}
// Keep the existing JSON contract for the frontend, but use clearer path-vs-stable-ID
// names internally so the move flow is easier to reason about.
type moveProjectFolderRequest struct {
FolderPath string `json:"folderId"`
FolderStableID string `json:"folderNodeId"`
ParentFolderPath string `json:"parentFolderId"`
ParentStableID string `json:"parentNodeId"`
TargetIndex int `json:"targetIndex"`
}
type createProjectItemRequest struct {
Name string `json:"name"`
ParentFolderPath string `json:"parentFolderId"`
ItemType string `json:"itemType"`
}
type deleteProjectItemRequest struct {
ItemPath string `json:"itemId"`
}
type moveProjectItemRequest struct {
ItemPath string `json:"itemId"`
ItemStableID string `json:"itemNodeId"`
ParentFolderPath string `json:"parentFolderId"`
ParentStableID string `json:"parentNodeId"`
TargetIndex int `json:"targetIndex"`
}
func decodeMoveProjectFolderRequest(w http.ResponseWriter, r *http.Request) (moveProjectFolderRequest, bool) {
var payload moveProjectFolderRequest
if !decodeJSONObjectBody(w, r, &payload) {
return payload, false
}
return payload, true
}
func decodeDeleteProjectFolderRequest(r *http.Request) deleteProjectFolderRequest {
return deleteProjectFolderRequest{
FolderPath: strings.TrimSpace(r.URL.Query().Get("folderId")),
}
}
func decodeDeleteProjectItemRequest(r *http.Request) deleteProjectItemRequest {
return deleteProjectItemRequest{
ItemPath: strings.TrimSpace(r.URL.Query().Get("itemId")),
}
}
func decodeRenameProjectFolderRequest(w http.ResponseWriter, r *http.Request) (renameProjectFolderRequest, bool) {
var payload renameProjectFolderRequest
if !decodeJSONObjectBody(w, r, &payload) {
return payload, false
}
return payload, true
}
func decodeProjectFolderRequest(w http.ResponseWriter, r *http.Request) (createProjectFolderRequest, bool) {
var payload createProjectFolderRequest
if !decodeJSONObjectBody(w, r, &payload) {
return payload, false
}
return payload, true
}
func decodeProjectItemRequest(w http.ResponseWriter, r *http.Request) (createProjectItemRequest, bool) {
var payload createProjectItemRequest
if !decodeJSONObjectBody(w, r, &payload) {
return payload, false
}
return payload, true
}
func decodeMoveProjectItemRequest(w http.ResponseWriter, r *http.Request) (moveProjectItemRequest, bool) {
var payload moveProjectItemRequest
if !decodeJSONObjectBody(w, r, &payload) {
return payload, false
}
return payload, true
}
func decodeJSONObjectBody(w http.ResponseWriter, r *http.Request, target any) bool {
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
if errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body is required and must be valid JSON.")
return false
}
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.")
return false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must contain a single JSON object.")
return false
}
return true
}
@@ -0,0 +1,42 @@
// Path: Backend/internal/httpx/api_project_errors.go
package httpx
import (
"errors"
"net/http"
bootstrapservice "moku-backend/internal/bootstrap"
)
func (routes apiRoutes) writeProjectFolderError(w http.ResponseWriter, r *http.Request, err error, operation string) {
switch {
case errors.Is(err, bootstrapservice.ErrProjectNotFound), errors.Is(err, bootstrapservice.ErrProjectFolderNotFound):
WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", err.Error())
case errors.Is(err, bootstrapservice.ErrInvalidProjectFolderMove):
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", err.Error())
default:
routes.cfg.Logger.Error(operation+" project folder", "error", err, "path", r.URL.Path)
message := "Failed to " + operation + " project folder."
if routes.cfg.Config.IsDevelopment() {
message = message + " " + err.Error()
}
WriteError(w, http.StatusInternalServerError, RequestIDFromContext(r.Context()), "project_folder_"+operation+"_failed", message)
}
}
func (routes apiRoutes) writeProjectTreeError(w http.ResponseWriter, r *http.Request, err error, operation string) {
switch {
case errors.Is(err, bootstrapservice.ErrProjectNotFound), errors.Is(err, bootstrapservice.ErrProjectFolderNotFound), errors.Is(err, bootstrapservice.ErrProjectItemNotFound):
WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", err.Error())
case errors.Is(err, bootstrapservice.ErrInvalidProjectFolderMove), errors.Is(err, bootstrapservice.ErrInvalidProjectItemMove):
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", err.Error())
default:
routes.cfg.Logger.Error(operation+" project tree", "error", err, "path", r.URL.Path)
message := "Failed to " + operation + " project tree."
if routes.cfg.Config.IsDevelopment() {
message = message + " " + err.Error()
}
WriteError(w, http.StatusInternalServerError, RequestIDFromContext(r.Context()), "project_tree_"+operation+"_failed", message)
}
}
@@ -0,0 +1,510 @@
// Path: Backend/internal/httpx/api_project_routes.go
package httpx
import (
"net/http"
"strings"
bootstrapservice "moku-backend/internal/bootstrap"
"github.com/go-chi/chi/v5"
)
func (routes apiRoutes) handleProjectFolders(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
return
}
folders, err := routes.bootstrapService().GetProjectHierarchyFolders(r.Context(), projectID)
if err != nil {
routes.writeProjectFolderError(w, r, err, "load")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"projectId": projectID,
"folders": folders,
},
"meta": map[string]any{
"resource": "project-folders",
},
})
}
func (routes apiRoutes) handleCreateProjectFolder(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
return
}
payload, ok := decodeProjectFolderRequest(w, r)
if !ok {
return
}
payload.Name = strings.TrimSpace(payload.Name)
payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath)
if payload.Name == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.")
return
}
result, err := routes.bootstrapService().CreateProjectFolder(r.Context(), bootstrapservice.CreateProjectFolderInput{
ProjectID: projectID,
ParentFolderPath: payload.ParentFolderPath,
Name: payload.Name,
})
if err != nil {
routes.writeProjectFolderError(w, r, err, "persist")
return
}
WriteJSON(w, http.StatusCreated, map[string]any{
"data": result,
"meta": map[string]any{
"resource": "project-folder-create",
"persisted": true,
},
})
}
func (routes apiRoutes) handleDeleteProjectFolder(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
return
}
payload := decodeDeleteProjectFolderRequest(r)
if strings.TrimSpace(payload.FolderPath) == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return
}
result, err := routes.bootstrapService().DeleteProjectFolder(r.Context(), bootstrapservice.DeleteProjectFolderInput{
ProjectID: projectID,
FolderPath: payload.FolderPath,
})
if err != nil {
routes.writeProjectFolderError(w, r, err, "delete")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": result,
"meta": map[string]any{
"resource": "project-folder-delete",
"persisted": true,
},
})
}
func (routes apiRoutes) handleRenameProjectFolder(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
return
}
payload, ok := decodeRenameProjectFolderRequest(w, r)
if !ok {
return
}
payload.FolderPath = strings.TrimSpace(payload.FolderPath)
payload.Name = strings.TrimSpace(payload.Name)
if payload.FolderPath == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return
}
if payload.Name == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.")
return
}
result, err := routes.bootstrapService().RenameProjectFolder(r.Context(), bootstrapservice.RenameProjectFolderInput{
ProjectID: projectID,
FolderPath: payload.FolderPath,
Name: payload.Name,
})
if err != nil {
routes.writeProjectFolderError(w, r, err, "rename")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": result,
"meta": map[string]any{
"resource": "project-folder-rename",
"persisted": true,
},
})
}
func (routes apiRoutes) handleMoveProjectFolder(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
return
}
payload, ok := decodeMoveProjectFolderRequest(w, r)
if !ok {
return
}
payload.FolderPath = strings.TrimSpace(payload.FolderPath)
payload.FolderStableID = strings.TrimSpace(payload.FolderStableID)
payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath)
payload.ParentStableID = strings.TrimSpace(payload.ParentStableID)
if payload.FolderPath == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return
}
result, err := routes.bootstrapService().MoveProjectFolder(r.Context(), bootstrapservice.MoveProjectFolderInput{
ProjectID: projectID,
FolderPath: payload.FolderPath,
FolderStableID: payload.FolderStableID,
ParentFolderPath: payload.ParentFolderPath,
ParentStableID: payload.ParentStableID,
TargetIndex: payload.TargetIndex,
})
if err != nil {
routes.writeProjectFolderError(w, r, err, "move")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": result,
"meta": map[string]any{
"resource": "project-folder-move",
"persisted": true,
},
})
}
func (routes apiRoutes) handleProjectTreeFolders(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
return
}
folders, err := routes.bootstrapService().GetProjectTreeFolders(r.Context(), projectID)
if err != nil {
routes.writeProjectFolderError(w, r, err, "load")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"projectId": projectID,
"folders": folders,
},
"meta": map[string]any{
"resource": "project-tree-folders",
},
})
}
func (routes apiRoutes) handleProjectTree(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
return
}
nodes, err := routes.bootstrapService().GetProjectTreeNodes(r.Context(), projectID)
if err != nil {
routes.writeProjectTreeError(w, r, err, "load")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"projectId": projectID,
"nodes": nodes,
},
"meta": map[string]any{
"resource": "project-tree",
},
})
}
func (routes apiRoutes) handleCreateProjectTreeFolder(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
return
}
payload, ok := decodeProjectFolderRequest(w, r)
if !ok {
return
}
payload.Name = strings.TrimSpace(payload.Name)
payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath)
if payload.Name == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.")
return
}
result, err := routes.bootstrapService().CreateProjectTreeFolder(r.Context(), bootstrapservice.CreateProjectFolderInput{
ProjectID: projectID,
ParentFolderPath: payload.ParentFolderPath,
Name: payload.Name,
})
if err != nil {
routes.writeProjectFolderError(w, r, err, "persist")
return
}
WriteJSON(w, http.StatusCreated, map[string]any{
"data": result,
"meta": map[string]any{
"resource": "project-tree-folder-create",
"persisted": true,
},
})
}
func (routes apiRoutes) handleDeleteProjectTreeFolder(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
return
}
payload := decodeDeleteProjectFolderRequest(r)
if strings.TrimSpace(payload.FolderPath) == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return
}
result, err := routes.bootstrapService().DeleteProjectTreeFolder(r.Context(), bootstrapservice.DeleteProjectFolderInput{
ProjectID: projectID,
FolderPath: payload.FolderPath,
})
if err != nil {
routes.writeProjectFolderError(w, r, err, "delete")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": result,
"meta": map[string]any{
"resource": "project-tree-folder-delete",
"persisted": true,
},
})
}
func (routes apiRoutes) handleRenameProjectTreeFolder(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
return
}
payload, ok := decodeRenameProjectFolderRequest(w, r)
if !ok {
return
}
payload.FolderPath = strings.TrimSpace(payload.FolderPath)
payload.Name = strings.TrimSpace(payload.Name)
if payload.FolderPath == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return
}
if payload.Name == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.")
return
}
result, err := routes.bootstrapService().RenameProjectTreeFolder(r.Context(), bootstrapservice.RenameProjectFolderInput{
ProjectID: projectID,
FolderPath: payload.FolderPath,
Name: payload.Name,
})
if err != nil {
routes.writeProjectFolderError(w, r, err, "rename")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": result,
"meta": map[string]any{
"resource": "project-tree-folder-rename",
"persisted": true,
},
})
}
func (routes apiRoutes) handleMoveProjectTreeFolder(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
return
}
payload, ok := decodeMoveProjectFolderRequest(w, r)
if !ok {
return
}
payload.FolderPath = strings.TrimSpace(payload.FolderPath)
payload.FolderStableID = strings.TrimSpace(payload.FolderStableID)
payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath)
payload.ParentStableID = strings.TrimSpace(payload.ParentStableID)
if payload.FolderPath == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return
}
result, err := routes.bootstrapService().MoveProjectTreeFolder(r.Context(), bootstrapservice.MoveProjectFolderInput{
ProjectID: projectID,
FolderPath: payload.FolderPath,
FolderStableID: payload.FolderStableID,
ParentFolderPath: payload.ParentFolderPath,
ParentStableID: payload.ParentStableID,
TargetIndex: payload.TargetIndex,
})
if err != nil {
routes.writeProjectFolderError(w, r, err, "move")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": result,
"meta": map[string]any{
"resource": "project-tree-folder-move",
"persisted": true,
},
})
}
func (routes apiRoutes) handleCreateProjectTreeItem(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
return
}
payload, ok := decodeProjectItemRequest(w, r)
if !ok {
return
}
payload.Name = strings.TrimSpace(payload.Name)
payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath)
payload.ItemType = strings.TrimSpace(payload.ItemType)
if payload.Name == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Item name is required.")
return
}
if payload.ItemType == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Item type is required.")
return
}
result, err := routes.bootstrapService().CreateProjectTreeItem(r.Context(), bootstrapservice.CreateProjectItemInput{
ProjectID: projectID,
ParentFolderPath: payload.ParentFolderPath,
Name: payload.Name,
ItemType: payload.ItemType,
})
if err != nil {
routes.writeProjectTreeError(w, r, err, "persist")
return
}
WriteJSON(w, http.StatusCreated, map[string]any{
"data": result,
"meta": map[string]any{
"resource": "project-tree-item-create",
"persisted": true,
},
})
}
func (routes apiRoutes) handleDeleteProjectTreeItem(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
return
}
payload := decodeDeleteProjectItemRequest(r)
if strings.TrimSpace(payload.ItemPath) == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Item ID is required.")
return
}
result, err := routes.bootstrapService().DeleteProjectTreeItem(r.Context(), bootstrapservice.DeleteProjectItemInput{
ProjectID: projectID,
ItemPath: payload.ItemPath,
})
if err != nil {
routes.writeProjectTreeError(w, r, err, "delete")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": result,
"meta": map[string]any{
"resource": "project-tree-item-delete",
"persisted": true,
},
})
}
func (routes apiRoutes) handleMoveProjectTreeItem(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
return
}
payload, ok := decodeMoveProjectItemRequest(w, r)
if !ok {
return
}
payload.ItemPath = strings.TrimSpace(payload.ItemPath)
payload.ItemStableID = strings.TrimSpace(payload.ItemStableID)
payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath)
payload.ParentStableID = strings.TrimSpace(payload.ParentStableID)
if payload.ItemPath == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Item ID is required.")
return
}
result, err := routes.bootstrapService().MoveProjectTreeItem(r.Context(), bootstrapservice.MoveProjectItemInput{
ProjectID: projectID,
ItemPath: payload.ItemPath,
ItemStableID: payload.ItemStableID,
ParentFolderPath: payload.ParentFolderPath,
ParentStableID: payload.ParentStableID,
TargetIndex: payload.TargetIndex,
})
if err != nil {
routes.writeProjectTreeError(w, r, err, "move")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": result,
"meta": map[string]any{
"resource": "project-tree-item-move",
"persisted": true,
},
})
}
+32
View File
@@ -19,8 +19,40 @@ func newAPIRoutes(cfg RouterConfig) routeRegistrar {
func (routes apiRoutes) Register(router chi.Router) { func (routes apiRoutes) Register(router chi.Router) {
router.Route("/v1", func(apiRouter chi.Router) { router.Route("/v1", func(apiRouter chi.Router) {
apiRouter.Get("/", routes.handleIndex) apiRouter.Get("/", routes.handleIndex)
apiRouter.Get("/bootstrap", routes.handleBootstrapOverview)
apiRouter.Get("/bootstrap/installation", routes.handleBootstrapInstallation)
apiRouter.Get("/bootstrap/admin", routes.handleBootstrapAdmin)
apiRouter.Get("/bootstrap/structure", routes.handleBootstrapStructure)
apiRouter.Get("/bootstrap/state", routes.handleBootstrapState)
apiRouter.Route("/bootstrap/steps", func(bootstrapRouter chi.Router) {
bootstrapRouter.Post("/instance", routes.handleBootstrapInstanceStep)
bootstrapRouter.Post("/mode", routes.handleBootstrapModeStep)
bootstrapRouter.Post("/admin", routes.handleBootstrapAdminStep)
bootstrapRouter.Post("/structure", routes.handleBootstrapStructureStep)
})
apiRouter.Get("/app-shell", routes.handleAppShellState)
apiRouter.Get("/organizations", routes.handleOrganizations) apiRouter.Get("/organizations", routes.handleOrganizations)
apiRouter.Get("/workspaces", routes.handleWorkspaces) apiRouter.Get("/workspaces", routes.handleWorkspaces)
apiRouter.Route("/projects/{projectId}", func(projectRouter chi.Router) {
projectRouter.Get("/folders", routes.handleProjectFolders)
projectRouter.Post("/folders", routes.handleCreateProjectFolder)
projectRouter.Patch("/folders", routes.handleRenameProjectFolder)
projectRouter.Patch("/folders/move", routes.handleMoveProjectFolder)
projectRouter.Delete("/folders", routes.handleDeleteProjectFolder)
projectRouter.Get("/tree", routes.handleProjectTree)
projectRouter.Get("/tree/folders", routes.handleProjectTreeFolders)
projectRouter.Post("/tree/folders", routes.handleCreateProjectTreeFolder)
projectRouter.Patch("/tree/folders", routes.handleRenameProjectTreeFolder)
projectRouter.Patch("/tree/folders/move", routes.handleMoveProjectTreeFolder)
projectRouter.Delete("/tree/folders", routes.handleDeleteProjectTreeFolder)
projectRouter.Post("/tree/items", routes.handleCreateProjectTreeItem)
projectRouter.Patch("/tree/items/move", routes.handleMoveProjectTreeItem)
projectRouter.Delete("/tree/items", routes.handleDeleteProjectTreeItem)
})
if routes.cfg.Config.IsDevelopment() {
apiRouter.Post("/dev/bootstrap/reset", routes.handleDevelopmentBootstrapReset)
}
}) })
} }
+198
View File
@@ -0,0 +1,198 @@
// Path: Backend/internal/jobs/store.go
package jobs
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/jackc/pgx/v5"
"moku-backend/internal/database"
)
const (
KindBootstrapStructureMaterialize = "bootstrap.structure.materialize"
)
type Status string
const (
StatusPending Status = "pending"
StatusRunning Status = "running"
StatusSucceeded Status = "succeeded"
StatusFailed Status = "failed"
)
type BootstrapStructureMaterializePayload struct {
InstallationID string `json:"installationId"`
}
type Job struct {
ID string
Kind string
Status Status
Payload json.RawMessage
Attempts int
MaxAttempts int
AvailableAt time.Time
StartedAt *time.Time
FinishedAt *time.Time
LastError *string
CreatedAt time.Time
UpdatedAt time.Time
}
type EnqueueInput struct {
Kind string
Payload any
AvailableAt time.Time
MaxAttempts int
}
type Store struct {
db *database.DB
}
func NewStore(db *database.DB) *Store {
return &Store{db: db}
}
func (store *Store) Enqueue(ctx context.Context, input EnqueueInput) (Job, error) {
payload := json.RawMessage([]byte(`{}`))
if input.Payload != nil {
encoded, err := json.Marshal(input.Payload)
if err != nil {
return Job{}, err
}
payload = encoded
}
availableAt := input.AvailableAt
if availableAt.IsZero() {
availableAt = time.Now().UTC()
}
maxAttempts := input.MaxAttempts
if maxAttempts < 1 {
maxAttempts = 1
}
return scanJob(store.db.Pool.QueryRow(ctx, `
INSERT INTO background_jobs (kind, status, payload, attempts, max_attempts, available_at)
VALUES ($1, 'pending'::background_job_status, $2::jsonb, 0, $3, $4)
RETURNING
id::text,
kind,
status::text,
payload,
attempts,
max_attempts,
available_at,
started_at,
finished_at,
last_error,
created_at,
updated_at;
`, strings.TrimSpace(input.Kind), payload, maxAttempts, availableAt))
}
func (store *Store) ClaimNext(ctx context.Context) (*Job, error) {
job, err := scanJob(store.db.Pool.QueryRow(ctx, `
WITH next_job AS (
SELECT id
FROM background_jobs
WHERE status = 'pending'::background_job_status
AND available_at <= NOW()
ORDER BY created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
UPDATE background_jobs AS jobs
SET
status = 'running'::background_job_status,
attempts = jobs.attempts + 1,
started_at = NOW(),
finished_at = NULL,
last_error = NULL,
updated_at = NOW()
FROM next_job
WHERE jobs.id = next_job.id
RETURNING
jobs.id::text,
jobs.kind,
jobs.status::text,
jobs.payload,
jobs.attempts,
jobs.max_attempts,
jobs.available_at,
jobs.started_at,
jobs.finished_at,
jobs.last_error,
jobs.created_at,
jobs.updated_at;
`))
if err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
return nil, err
}
return &job, nil
}
func (store *Store) MarkSucceeded(ctx context.Context, jobID string) error {
_, err := store.db.Pool.Exec(ctx, `
UPDATE background_jobs
SET
status = 'succeeded'::background_job_status,
finished_at = NOW(),
last_error = NULL,
updated_at = NOW()
WHERE id = $1::uuid;
`, strings.TrimSpace(jobID))
return err
}
func (store *Store) MarkFailed(ctx context.Context, jobID, failure string) error {
_, err := store.db.Pool.Exec(ctx, `
UPDATE background_jobs
SET
status = 'failed'::background_job_status,
finished_at = NOW(),
last_error = $2,
updated_at = NOW()
WHERE id = $1::uuid;
`, strings.TrimSpace(jobID), strings.TrimSpace(failure))
return err
}
func scanJob(row pgx.Row) (Job, error) {
var job Job
var status string
if err := row.Scan(
&job.ID,
&job.Kind,
&status,
&job.Payload,
&job.Attempts,
&job.MaxAttempts,
&job.AvailableAt,
&job.StartedAt,
&job.FinishedAt,
&job.LastError,
&job.CreatedAt,
&job.UpdatedAt,
); err != nil {
return Job{}, err
}
job.Status = Status(status)
return job, nil
}
+104
View File
@@ -0,0 +1,104 @@
// Path: Backend/internal/posixproj/projector.go
package posixproj
import (
"context"
"fmt"
"strings"
"moku-backend/internal/database"
)
func NewProjector(db *database.DB, root string) *Projector {
return &Projector{db: db, root: strings.TrimSpace(root)}
}
func (projector *Projector) Rebuild(ctx context.Context) error {
_, err := projector.RebuildWithSummary(ctx)
return err
}
func (projector *Projector) RebuildWithSummary(ctx context.Context) (RebuildSummary, error) {
if projector == nil || projector.db == nil || projector.db.Pool == nil {
return RebuildSummary{}, nil
}
nodes, err := ScanRoot(projector.root)
if err != nil {
return RebuildSummary{}, err
}
summary := summarizeNodes(nodes)
tx, err := projector.db.Pool.Begin(ctx)
if err != nil {
return RebuildSummary{}, err
}
defer func() {
_ = tx.Rollback(ctx)
}()
if _, err := tx.Exec(ctx, `DELETE FROM posix_nodes;`); err != nil {
return RebuildSummary{}, fmt.Errorf("clear posix_nodes: %w", err)
}
for _, node := range nodes {
if _, err := tx.Exec(ctx, `
INSERT INTO posix_nodes (
path,
parent_path,
name,
depth,
node_kind,
logical_type,
file_role,
resource_id,
resource_name,
resource_slug,
installation_id,
organization_id,
organization_slug,
department_slug,
team_slug,
project_slug,
personal_slug,
content_json,
size_bytes,
checksum
) VALUES (
$1, $2, $3, $4, $5::posix_node_kind, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17, $18::jsonb, $19, $20
);
`,
node.Path,
node.ParentPath,
node.Name,
node.Depth,
string(node.NodeKind),
node.LogicalType,
node.FileRole,
node.ResourceID,
node.ResourceName,
node.ResourceSlug,
node.InstallationID,
node.OrganizationID,
node.OrganizationSlug,
node.DepartmentSlug,
node.TeamSlug,
node.ProjectSlug,
node.PersonalSlug,
node.ContentJSON,
node.SizeBytes,
node.Checksum,
); err != nil {
return RebuildSummary{}, fmt.Errorf("insert posix node %s: %w", node.Path, err)
}
}
if err := tx.Commit(ctx); err != nil {
return RebuildSummary{}, err
}
return summary, nil
}
@@ -0,0 +1,227 @@
// Path: Backend/internal/posixproj/projector_classify.go
package posixproj
import (
"path/filepath"
"strings"
)
func deriveScope(relPath string, rootScope Scope) Scope {
scope := rootScope
parts := strings.Split(relPath, "/")
for _, part := range parts {
switch {
case strings.HasPrefix(part, "department-"):
scope.DepartmentSlug = strings.TrimPrefix(part, "department-")
case strings.HasPrefix(part, "team-"):
scope.TeamSlug = strings.TrimPrefix(part, "team-")
case strings.HasPrefix(part, "project-"):
scope.ProjectSlug = strings.TrimPrefix(part, "project-")
case strings.HasPrefix(part, "personal-"):
scope.PersonalSlug = strings.TrimPrefix(part, "personal-")
}
}
return scope
}
func classifyPath(relPath string, isDir bool) (logicalType, fileRole string) {
parts := strings.Split(relPath, "/")
name := parts[len(parts)-1]
ext := strings.ToLower(filepath.Ext(name))
structuredRole := strings.TrimSuffix(name, filepath.Ext(name))
if !isDir {
fileRole = structuredRole
}
hasChildrenAncestor := pathContainsSegment(parts, "children")
hasTreeAncestor := pathContainsSegment(parts, "tree")
parentName := ""
if len(parts) >= 2 {
parentName = parts[len(parts)-2]
}
switch {
case !isDir && structuredRole == "settings" && len(parts) == 1 && ext == ".cbor":
return "tenant", "settings"
case !isDir && structuredRole == "layout" && len(parts) == 1 && ext == ".cbor":
return "tenant", "layout"
case len(parts) >= 1 && parts[0] == "catalog":
if isDir {
if len(parts) == 1 {
return "catalog", ""
}
if len(parts) >= 2 && parts[1] == "packs" {
if len(parts) == 2 {
return "catalog_packs", ""
}
if len(parts) == 3 {
return "catalog_pack", ""
}
if len(parts) >= 4 && parts[3] == "entries" {
return "catalog_pack_entries", ""
}
return "catalog_entry", ""
}
if len(parts) >= 2 && parts[1] == "standalone" {
if len(parts) == 2 {
return "catalog_standalone", ""
}
return "catalog_entry", ""
}
}
return "catalog", fileRole
case len(parts) >= 2 && parts[0] == "departments" && strings.HasPrefix(parts[1], "department-"):
if isDir {
if len(parts) == 2 {
return "department", ""
}
if len(parts) == 3 && parts[2] == "teams" {
return "department_teams", ""
}
if len(parts) >= 4 && strings.HasPrefix(parts[3], "team-") {
return "team", ""
}
}
if len(parts) >= 4 && strings.HasPrefix(parts[3], "team-") {
return "team", fileRole
}
return "department", fileRole
case len(parts) >= 2 && parts[0] == "projects" && strings.HasPrefix(parts[1], "project-"):
if isDir {
if strings.HasPrefix(name, "project-") {
return "project", ""
}
if name == "children" {
return "project_children", ""
}
if name == "tree" {
return "project_tree", ""
}
if hasChildrenAncestor && strings.HasPrefix(name, "folder-") {
return "hierarchy_folder", ""
}
if hasTreeAncestor && strings.HasPrefix(name, "folder-") {
return "hierarchy_folder", ""
}
if hasTreeAncestor && strings.HasPrefix(name, "item-") {
return "item", ""
}
}
if hasChildrenAncestor && strings.HasPrefix(parentName, "folder-") {
return "hierarchy_folder", fileRole
}
if hasTreeAncestor {
if strings.HasPrefix(parentName, "item-") {
return "item", fileRole
}
if strings.HasPrefix(parentName, "folder-") {
return "hierarchy_folder", fileRole
}
}
return "project", fileRole
case len(parts) >= 1 && parts[0] == "users":
if isDir {
if len(parts) == 1 {
return "users", ""
}
if len(parts) == 2 && parts[1] == "personals" {
return "personals", ""
}
if len(parts) >= 3 && parts[1] == "personals" && strings.HasPrefix(parts[2], "personal-") {
return "personal", ""
}
if strings.Contains(relPath, "/tree/") || strings.HasSuffix(relPath, "/tree") {
if strings.HasPrefix(name, "folder-") {
return "folder", ""
}
if strings.HasPrefix(name, "item-") {
return "item", ""
}
}
}
if len(parts) >= 3 && parts[1] == "personals" && strings.HasPrefix(parts[2], "personal-") {
if strings.Contains(relPath, "/tree/") {
if strings.HasPrefix(parts[len(parts)-2], "item-") {
return "item", fileRole
}
if strings.HasPrefix(parts[len(parts)-2], "folder-") {
return "folder", fileRole
}
}
return "personal", fileRole
}
return "users", fileRole
default:
if isDir {
return "directory", ""
}
return "file", fileRole
}
}
func pathContainsSegment(parts []string, target string) bool {
for _, part := range parts {
if part == target {
return true
}
}
return false
}
func projectionParentPath(relPath string) *string {
if relPath == "" || relPath == rootProjectionPath {
return nil
}
parent := filepath.ToSlash(filepath.Dir(relPath))
if parent == "." || parent == "" {
root := rootProjectionPath
return &root
}
return &parent
}
func inferredResourceSlug(logicalType string, scope Scope) string {
switch logicalType {
case "department":
return scope.DepartmentSlug
case "team":
return scope.TeamSlug
case "project":
return scope.ProjectSlug
case "personal":
return scope.PersonalSlug
default:
return ""
}
}
func parentEntityName(logicalType string, scope Scope) string {
switch logicalType {
case "department":
return scope.DepartmentSlug
case "team":
return scope.TeamSlug
case "project":
return scope.ProjectSlug
case "personal":
return scope.PersonalSlug
default:
return ""
}
}
func stringValue(value any) string {
stringValue, _ := value.(string)
return strings.TrimSpace(stringValue)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
@@ -0,0 +1,277 @@
// Path: Backend/internal/posixproj/projector_scan.go
package posixproj
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/fxamacker/cbor/v2"
"github.com/tailscale/hujson"
)
const (
settingsCBORPath = "settings.cbor"
)
func ScanRoot(root string) ([]Node, error) {
rootPath := strings.TrimSpace(root)
if rootPath == "" {
return nil, nil
}
info, err := os.Stat(rootPath)
if err != nil {
return nil, fmt.Errorf("stat POSIX root: %w", err)
}
if !info.IsDir() {
return nil, fmt.Errorf("POSIX root is not a directory: %s", rootPath)
}
rootScope, err := loadRootScope(rootPath)
if err != nil {
return nil, err
}
nodes := []Node{{
Path: rootProjectionPath,
ParentPath: nil,
Name: filepath.Base(rootPath),
Depth: 0,
NodeKind: NodeKindDirectory,
LogicalType: "tenant_root",
InstallationID: rootScope.InstallationID,
OrganizationID: rootScope.OrganizationID,
OrganizationSlug: rootScope.OrganizationSlug,
}}
err = filepath.WalkDir(rootPath, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if path == rootPath {
return nil
}
relPath, err := filepath.Rel(rootPath, path)
if err != nil {
return err
}
relPath = filepath.ToSlash(relPath)
if relPath == "." {
return nil
}
node, err := buildNode(rootPath, relPath, entry, rootScope)
if err != nil {
return err
}
nodes = append(nodes, node)
return nil
})
if err != nil {
return nil, fmt.Errorf("scan POSIX root: %w", err)
}
return nodes, nil
}
func loadRootScope(rootPath string) (Scope, error) {
settingsPath := filepath.Join(rootPath, settingsCBORPath)
content, err := os.ReadFile(settingsPath)
if err != nil {
if errorsIsNotExist(err) {
return Scope{}, nil
}
return Scope{}, fmt.Errorf("read root %s: %w", filepath.Base(settingsPath), err)
}
payload, err := decodeStructuredMap(settingsPath, content)
if err != nil {
return Scope{}, fmt.Errorf("decode root %s: %w", filepath.Base(settingsPath), err)
}
if len(payload) == 0 {
return Scope{}, nil
}
installation, _ := payload["installation"].(map[string]any)
organization, _ := payload["organization"].(map[string]any)
return Scope{
InstallationID: stringValue(installation["id"]),
OrganizationID: stringValue(organization["id"]),
OrganizationSlug: stringValue(organization["slug"]),
}, nil
}
func buildNode(rootPath, relPath string, entry fs.DirEntry, rootScope Scope) (Node, error) {
scope := deriveScope(relPath, rootScope)
parentPath := projectionParentPath(relPath)
logicalType, fileRole := classifyPath(relPath, entry.IsDir())
node := Node{
Path: relPath,
ParentPath: parentPath,
Name: entry.Name(),
Depth: strings.Count(relPath, "/") + 1,
NodeKind: NodeKindDirectory,
LogicalType: logicalType,
FileRole: fileRole,
InstallationID: scope.InstallationID,
OrganizationID: scope.OrganizationID,
OrganizationSlug: scope.OrganizationSlug,
DepartmentSlug: scope.DepartmentSlug,
TeamSlug: scope.TeamSlug,
ProjectSlug: scope.ProjectSlug,
PersonalSlug: scope.PersonalSlug,
}
if entry.IsDir() {
return node, nil
}
absPath := filepath.Join(rootPath, filepath.FromSlash(relPath))
content, err := os.ReadFile(absPath)
if err != nil {
return Node{}, fmt.Errorf("read POSIX file %s: %w", relPath, err)
}
hash := sha256.Sum256(content)
node.NodeKind = NodeKindFile
node.SizeBytes = int64(len(content))
node.Checksum = hex.EncodeToString(hash[:])
if isStructuredProjectionFile(entry.Name()) {
payload, err := decodeStructuredMap(absPath, content)
if err == nil {
jsonContent, err := json.Marshal(payload)
if err != nil {
return Node{}, fmt.Errorf("remarshal POSIX file %s: %w", relPath, err)
}
node.ContentJSON = jsonContent
node.ResourceID = stringValue(payload["id"])
node.ResourceName = stringValue(payload["name"])
node.ResourceSlug = stringValue(payload["slug"])
if node.ResourceID == "" && fileRole == "settings" && logicalType == "tenant" {
installation, _ := payload["installation"].(map[string]any)
organization, _ := payload["organization"].(map[string]any)
node.ResourceID = stringValue(installation["id"])
node.ResourceName = stringValue(installation["name"])
node.InstallationID = stringValue(installation["id"])
node.OrganizationID = stringValue(organization["id"])
node.OrganizationSlug = firstNonEmpty(node.OrganizationSlug, stringValue(organization["slug"]))
}
if node.ResourceID == "" && fileRole == "users" {
node.ResourceName = firstNonEmpty(node.ResourceName, parentEntityName(logicalType, scope))
}
}
}
if node.ResourceSlug == "" {
node.ResourceSlug = inferredResourceSlug(logicalType, scope)
}
return node, nil
}
func isStructuredProjectionFile(name string) bool {
switch strings.ToLower(filepath.Ext(name)) {
case ".json", ".jsonc", ".cbor":
return true
default:
return false
}
}
func decodeStructuredMap(path string, content []byte) (map[string]any, error) {
var payload any
switch strings.ToLower(filepath.Ext(path)) {
case ".cbor":
if err := cbor.Unmarshal(content, &payload); err != nil {
return nil, err
}
case ".json":
if err := json.Unmarshal(content, &payload); err != nil {
return nil, err
}
case ".jsonc":
decoded, err := decodeJSONCToAny(content)
if err != nil {
return nil, err
}
payload = decoded
default:
return nil, fmt.Errorf("unsupported structured file extension %q", filepath.Ext(path))
}
if payload == nil {
return map[string]any{}, nil
}
normalized, ok := normalizeStructuredValue(payload).(map[string]any)
if !ok || normalized == nil {
return map[string]any{}, nil
}
return normalized, nil
}
func decodeJSONCToAny(content []byte) (any, error) {
ast, err := hujson.Parse(content)
if err != nil {
return nil, err
}
ast.Standardize()
standardized := ast.Pack()
var payload any
if err := json.Unmarshal(standardized, &payload); err != nil {
return nil, err
}
return payload, nil
}
func normalizeStructuredValue(value any) any {
switch typed := value.(type) {
case map[string]any:
normalized := make(map[string]any, len(typed))
for key, child := range typed {
normalized[key] = normalizeStructuredValue(child)
}
return normalized
case map[any]any:
normalized := make(map[string]any, len(typed))
for key, child := range typed {
normalized[fmt.Sprint(key)] = normalizeStructuredValue(child)
}
return normalized
case []any:
normalized := make([]any, len(typed))
for index, child := range typed {
normalized[index] = normalizeStructuredValue(child)
}
return normalized
default:
return value
}
}
func summarizeNodes(nodes []Node) RebuildSummary {
summary := RebuildSummary{TotalNodes: len(nodes)}
for _, node := range nodes {
switch node.NodeKind {
case NodeKindDirectory:
summary.DirectoryCount++
case NodeKindFile:
summary.FileCount++
}
}
return summary
}
func errorsIsNotExist(err error) bool {
return err != nil && os.IsNotExist(err)
}
@@ -0,0 +1,316 @@
// Path: Backend/internal/posixproj/projector_test.go
package posixproj
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/fxamacker/cbor/v2"
)
const (
testSettingsFileName = "settings.cbor"
testLayoutFileName = "layout.cbor"
testHomeFileName = "home.cbor"
testUsersFileName = "users.cbor"
testACLFileName = "acl.cbor"
testFolderFileName = "folder.cbor"
testItemFileName = "item.cbor"
testDataFileName = "data.cbor"
testSchemaFileName = "schema.json"
testManifestFileName = "manifest.jsonc"
)
func TestScanRootBuildsProjectedNodesFromBootstrapShape(t *testing.T) {
root := filepath.Join(t.TempDir(), "POSIX")
mustMkdirAll(t, filepath.Join(root, "catalog", "packs"))
mustMkdirAll(t, filepath.Join(root, "catalog", "standalone"))
mustMkdirAll(t, filepath.Join(root, "catalog", "packs", "pack-core", "entries", "app-shell"))
mustMkdirAll(t, filepath.Join(root, "catalog", "standalone", "app-shell"))
mustMkdirAll(t, filepath.Join(root, "departments", "department-primary-department", "teams", "team-primary-team"))
mustMkdirAll(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "children", "project-web", "children"))
mustMkdirAll(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "children", "project-web", "tree"))
mustMkdirAll(t, filepath.Join(root, "projects", "project-primary-project", "tree"))
mustMkdirAll(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", "children"))
mustMkdirAll(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", "item-roadmap"))
mustMkdirAll(t, filepath.Join(root, "users", "personals"))
mustWriteStructured(t, filepath.Join(root, testSettingsFileName), map[string]any{
"installation": map[string]any{
"id": "installation-1",
"name": "MangoPig",
"isBootstrapped": true,
},
"organization": map[string]any{
"id": "org-1",
"name": "Primary Organization",
"slug": "primary-organization",
},
})
mustWriteStructured(t, filepath.Join(root, testLayoutFileName), map[string]any{
"type": "tenant-layout",
"home": map[string]any{"defaultProjectSlug": "primary-project"},
})
mustWriteStructured(t, filepath.Join(root, "catalog", "packs", "pack-core", testManifestFileName), map[string]any{
"id": "pack-core",
"slug": "core",
"type": "pack",
"name": "Core Pack",
"entries": []map[string]any{{"slug": "app-shell", "path": "entries/app-shell"}},
})
mustWriteStructured(t, filepath.Join(root, "catalog", "packs", "pack-core", "entries", "app-shell", testManifestFileName), map[string]any{
"id": "app-shell",
"slug": "app-shell",
"type": "app",
"runtime": map[string]any{
"kind": "route",
"path": "/v1/app-shell",
},
})
mustWriteStructured(t, filepath.Join(root, "catalog", "standalone", "app-shell", testManifestFileName), map[string]any{
"id": "app-shell",
"slug": "app-shell",
"type": "app",
"source": "standalone",
})
mustWriteStructured(t, filepath.Join(root, "departments", "department-primary-department", testSettingsFileName), map[string]any{
"id": "dept-1",
"name": "Primary Department",
"slug": "primary-department",
"type": "department",
})
mustWriteStructured(t, filepath.Join(root, "departments", "department-primary-department", testUsersFileName), map[string]any{
"users": []map[string]any{{"id": "admin-1"}},
})
mustWriteStructured(t, filepath.Join(root, "departments", "department-primary-department", "teams", "team-primary-team", testSettingsFileName), map[string]any{
"id": "team-1",
"name": "Primary Team",
"slug": "primary-team",
"type": "team",
})
mustWriteStructured(t, filepath.Join(root, "projects", "project-primary-project", testSettingsFileName), map[string]any{
"id": "project-1",
"name": "Primary Project",
"slug": "primary-project",
"type": "project",
})
mustWriteStructured(t, filepath.Join(root, "projects", "project-primary-project", testHomeFileName), map[string]any{
"type": "project-home",
"project": "primary-project",
})
mustWriteStructured(t, filepath.Join(root, "projects", "project-primary-project", testACLFileName), map[string]any{
"inherits": true,
"rules": []any{},
})
mustWriteStructured(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", testFolderFileName), map[string]any{
"name": "Design",
"slug": "design",
})
mustWriteStructured(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", testACLFileName), map[string]any{
"inherits": true,
"rules": []any{},
})
mustWriteStructured(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "children", "project-web", testSettingsFileName), map[string]any{
"id": "project-2",
"name": "Web Project",
"slug": "web",
"type": "project",
})
mustWriteStructured(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "children", "project-web", testHomeFileName), map[string]any{
"type": "project-home",
"project": "web",
})
mustWriteStructured(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "children", "project-web", testACLFileName), map[string]any{
"inherits": true,
"rules": []any{},
})
mustWriteStructured(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", testFolderFileName), map[string]any{
"name": "Docs",
"slug": "docs",
})
mustWriteStructured(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", "item-roadmap", testItemFileName), map[string]any{
"id": "item-1",
"name": "Roadmap",
"slug": "roadmap",
"type": "board",
})
mustWriteStructured(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", "item-roadmap", testSchemaFileName), map[string]any{
"type": "object",
})
mustWriteStructured(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", "item-roadmap", testDataFileName), map[string]any{
"title": "Roadmap",
})
mustWriteStructured(t, filepath.Join(root, "users", testSettingsFileName), map[string]any{
"primaryAdminId": "admin-1",
})
mustWriteStructured(t, filepath.Join(root, "users", testDataFileName), map[string]any{
"users": []map[string]any{{"id": "admin-1", "email": "ronald@example.com"}},
})
nodes, err := ScanRoot(root)
if err != nil {
t.Fatalf("ScanRoot() error = %v", err)
}
index := make(map[string]Node, len(nodes))
for _, node := range nodes {
index[node.Path] = node
}
rootNode, ok := index[rootProjectionPath]
if !ok {
t.Fatalf("expected synthetic root node")
}
if rootNode.LogicalType != "tenant_root" {
t.Fatalf("expected root logical type tenant_root, got %q", rootNode.LogicalType)
}
if rootNode.OrganizationSlug != "primary-organization" {
t.Fatalf("expected root organization slug primary-organization, got %q", rootNode.OrganizationSlug)
}
tenantSettings := index[testSettingsFileName]
if tenantSettings.LogicalType != "tenant" || tenantSettings.FileRole != "settings" {
t.Fatalf("unexpected tenant settings classification: %#v", tenantSettings)
}
if tenantSettings.InstallationID != "installation-1" {
t.Fatalf("expected installation id installation-1, got %q", tenantSettings.InstallationID)
}
packManifest := index[filepath.ToSlash(filepath.Join("catalog", "packs", "pack-core", testManifestFileName))]
if packManifest.LogicalType != "catalog" || packManifest.FileRole != "manifest" {
t.Fatalf("unexpected pack manifest classification: %#v", packManifest)
}
if packManifest.ResourceSlug != "core" {
t.Fatalf("expected pack manifest resource slug core, got %q", packManifest.ResourceSlug)
}
entryManifest := index[filepath.ToSlash(filepath.Join("catalog", "packs", "pack-core", "entries", "app-shell", testManifestFileName))]
if entryManifest.LogicalType != "catalog" || entryManifest.FileRole != "manifest" {
t.Fatalf("unexpected catalog entry manifest classification: %#v", entryManifest)
}
if entryManifest.ResourceID != "app-shell" {
t.Fatalf("expected catalog entry manifest resource id app-shell, got %q", entryManifest.ResourceID)
}
standaloneManifest := index[filepath.ToSlash(filepath.Join("catalog", "standalone", "app-shell", testManifestFileName))]
if standaloneManifest.LogicalType != "catalog" || standaloneManifest.FileRole != "manifest" {
t.Fatalf("unexpected standalone manifest classification: %#v", standaloneManifest)
}
if standaloneManifest.ResourceSlug != "app-shell" {
t.Fatalf("expected standalone manifest resource slug app-shell, got %q", standaloneManifest.ResourceSlug)
}
deptSettings := index[filepath.ToSlash(filepath.Join("departments", "department-primary-department", testSettingsFileName))]
if deptSettings.DepartmentSlug != "primary-department" {
t.Fatalf("expected department slug primary-department, got %q", deptSettings.DepartmentSlug)
}
if deptSettings.ResourceID != "dept-1" {
t.Fatalf("expected department resource id dept-1, got %q", deptSettings.ResourceID)
}
teamSettings := index[filepath.ToSlash(filepath.Join("departments", "department-primary-department", "teams", "team-primary-team", testSettingsFileName))]
if teamSettings.TeamSlug != "primary-team" {
t.Fatalf("expected team slug primary-team, got %q", teamSettings.TeamSlug)
}
projectSettings := index[filepath.ToSlash(filepath.Join("projects", "project-primary-project", testSettingsFileName))]
if projectSettings.ProjectSlug != "primary-project" {
t.Fatalf("expected project slug primary-project, got %q", projectSettings.ProjectSlug)
}
if projectSettings.ResourceName != "Primary Project" {
t.Fatalf("expected project resource name Primary Project, got %q", projectSettings.ResourceName)
}
projectTree := index["projects/project-primary-project/tree"]
if projectTree.LogicalType != "project_tree" || projectTree.NodeKind != NodeKindDirectory {
t.Fatalf("unexpected project tree node: %#v", projectTree)
}
projectChildren := index["projects/project-primary-project/children"]
if projectChildren.LogicalType != "project_children" || projectChildren.NodeKind != NodeKindDirectory {
t.Fatalf("unexpected project children node: %#v", projectChildren)
}
hierarchyFolder := index["projects/project-primary-project/children/folder-design"]
if hierarchyFolder.LogicalType != "hierarchy_folder" || hierarchyFolder.ProjectSlug != "primary-project" {
t.Fatalf("unexpected hierarchy folder node: %#v", hierarchyFolder)
}
hierarchyFolderACL := index[filepath.ToSlash(filepath.Join("projects", "project-primary-project", "children", "folder-design", testACLFileName))]
if hierarchyFolderACL.LogicalType != "hierarchy_folder" || hierarchyFolderACL.FileRole != "acl" {
t.Fatalf("unexpected hierarchy folder acl classification: %#v", hierarchyFolderACL)
}
childProjectSettings := index[filepath.ToSlash(filepath.Join("projects", "project-primary-project", "children", "folder-design", "children", "project-web", testSettingsFileName))]
if childProjectSettings.LogicalType != "project" || childProjectSettings.ProjectSlug != "web" {
t.Fatalf("unexpected child project classification: %#v", childProjectSettings)
}
treeFolder := index["projects/project-primary-project/tree/folder-docs"]
if treeFolder.LogicalType != "hierarchy_folder" || treeFolder.ProjectSlug != "primary-project" {
t.Fatalf("unexpected tree folder node: %#v", treeFolder)
}
treeFolderACL := index[filepath.ToSlash(filepath.Join("projects", "project-primary-project", "tree", "folder-docs", testFolderFileName))]
if treeFolderACL.LogicalType != "hierarchy_folder" || treeFolderACL.FileRole != "folder" {
t.Fatalf("unexpected tree folder file classification: %#v", treeFolderACL)
}
treeItem := index[filepath.ToSlash(filepath.Join("projects", "project-primary-project", "tree", "folder-docs", "item-roadmap", testItemFileName))]
if treeItem.LogicalType != "item" || treeItem.FileRole != "item" {
t.Fatalf("unexpected tree item classification: %#v", treeItem)
}
if treeItem.ResourceSlug != "roadmap" {
t.Fatalf("expected tree item resource slug roadmap, got %#v", treeItem.ResourceSlug)
}
usersData := index[filepath.ToSlash(filepath.Join("users", testDataFileName))]
if usersData.LogicalType != "users" || usersData.FileRole != "data" {
t.Fatalf("unexpected users data classification: %#v", usersData)
}
if usersData.Checksum == "" || usersData.SizeBytes == 0 {
t.Fatalf("expected users/%s checksum and size to be populated: %#v", testDataFileName, usersData)
}
}
func mustMkdirAll(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("MkdirAll(%q) error = %v", path, err)
}
}
func mustWriteStructured(t *testing.T, path string, payload any) {
t.Helper()
var (
bytes []byte
err error
)
switch filepath.Ext(path) {
case ".cbor":
bytes, err = cbor.Marshal(payload)
case ".json":
bytes, err = json.MarshalIndent(payload, "", " ")
if err == nil {
bytes = append(bytes, '\n')
}
case ".jsonc":
bytes, err = json.MarshalIndent(payload, "", " ")
if err == nil {
bytes = append([]byte("// Fixture JSONC with comments and trailing commas enabled.\n"), bytes...)
bytes = append(bytes[:len(bytes)-2], []byte(",\n}\n")...)
}
default:
t.Fatalf("unsupported structured fixture file %q", path)
}
if err != nil {
t.Fatalf("marshal %q error = %v", path, err)
}
if err := os.WriteFile(path, bytes, 0o644); err != nil {
t.Fatalf("WriteFile(%q) error = %v", path, err)
}
}
@@ -0,0 +1,58 @@
// Path: Backend/internal/posixproj/projector_types.go
package posixproj
import "moku-backend/internal/database"
const rootProjectionPath = "/"
type Projector struct {
db *database.DB
root string
}
type RebuildSummary struct {
TotalNodes int
DirectoryCount int
FileCount int
}
type NodeKind string
const (
NodeKindDirectory NodeKind = "directory"
NodeKindFile NodeKind = "file"
)
type Scope struct {
InstallationID string
OrganizationID string
OrganizationSlug string
DepartmentSlug string
TeamSlug string
ProjectSlug string
PersonalSlug string
}
type Node struct {
Path string
ParentPath *string
Name string
Depth int
NodeKind NodeKind
LogicalType string
FileRole string
ResourceID string
ResourceName string
ResourceSlug string
InstallationID string
OrganizationID string
OrganizationSlug string
DepartmentSlug string
TeamSlug string
ProjectSlug string
PersonalSlug string
ContentJSON []byte
SizeBytes int64
Checksum string
}
+126
View File
@@ -0,0 +1,126 @@
// Path: Backend/internal/worker/runner.go
package worker
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"moku-backend/internal/jobs"
)
type JobStore interface {
ClaimNext(ctx context.Context) (*jobs.Job, error)
MarkSucceeded(ctx context.Context, jobID string) error
MarkFailed(ctx context.Context, jobID, failure string) error
}
type Handler func(ctx context.Context, job jobs.Job) error
type Runner struct {
store JobStore
logger *slog.Logger
pollInterval time.Duration
handlers map[string]Handler
}
func NewRunner(store JobStore, logger *slog.Logger, pollInterval time.Duration) *Runner {
interval := pollInterval
if interval <= 0 {
interval = time.Second
}
return &Runner{
store: store,
logger: logger,
pollInterval: interval,
handlers: make(map[string]Handler),
}
}
func (runner *Runner) Register(kind string, handler Handler) {
runner.handlers[strings.TrimSpace(kind)] = handler
}
func (runner *Runner) Run(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return nil
default:
}
job, err := runner.store.ClaimNext(ctx)
if err != nil {
if ctx.Err() != nil {
return nil
}
runner.logger.Error("worker claim failed", "error", err)
if err := waitForNextPoll(ctx, runner.pollInterval); err != nil {
return nil
}
continue
}
if job == nil {
if err := waitForNextPoll(ctx, runner.pollInterval); err != nil {
return nil
}
continue
}
handler, ok := runner.handlers[job.Kind]
if !ok {
failure := fmt.Sprintf("no handler registered for job kind %q", job.Kind)
if err := runner.store.MarkFailed(ctx, job.ID, failure); err != nil {
return err
}
runner.logger.Error("worker job failed", "jobID", job.ID, "kind", job.Kind, "error", failure)
continue
}
if err := handler(ctx, *job); err != nil {
if ctx.Err() != nil {
return nil
}
failure := strings.TrimSpace(err.Error())
if failure == "" {
failure = "job handler returned an empty error"
}
if markErr := runner.store.MarkFailed(ctx, job.ID, failure); markErr != nil {
return markErr
}
runner.logger.Error("worker job failed", "jobID", job.ID, "kind", job.Kind, "error", failure)
continue
}
if err := runner.store.MarkSucceeded(ctx, job.ID); err != nil {
return err
}
runner.logger.Info("worker job succeeded", "jobID", job.ID, "kind", job.Kind)
}
}
func waitForNextPoll(ctx context.Context, interval time.Duration) error {
timer := time.NewTimer(interval)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
+211
View File
@@ -0,0 +1,211 @@
// Path: Backend/internal/worker/runner_test.go
package worker
import (
"context"
"errors"
"io"
"log/slog"
"strings"
"sync"
"testing"
"time"
"moku-backend/internal/jobs"
)
func TestRunnerProcessesRegisteredJob(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
store := &fakeJobStore{
job: &jobs.Job{
ID: "job-1",
Kind: jobs.KindBootstrapStructureMaterialize,
Payload: []byte(`{"installationId":"installation-1"}`),
},
cancel: cancel,
}
runner := NewRunner(store, slog.New(slog.NewTextHandler(io.Discard, nil)), 0)
handlerCalled := false
runner.Register(jobs.KindBootstrapStructureMaterialize, func(ctx context.Context, job jobs.Job) error {
handlerCalled = true
if job.ID != "job-1" {
t.Fatalf("expected job id job-1, got %s", job.ID)
}
return nil
})
if err := runner.Run(ctx); err != nil {
t.Fatalf("runner returned error: %v", err)
}
if !handlerCalled {
t.Fatal("expected handler to be called")
}
if len(store.succeeded) != 1 || store.succeeded[0] != "job-1" {
t.Fatalf("expected job to be marked succeeded once, got %#v", store.succeeded)
}
if len(store.failed) != 0 {
t.Fatalf("expected no failed jobs, got %#v", store.failed)
}
}
func TestRunnerMarksFailedWhenHandlerErrors(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
store := &fakeJobStore{
job: &jobs.Job{
ID: "job-2",
Kind: jobs.KindBootstrapStructureMaterialize,
},
cancel: cancel,
}
runner := NewRunner(store, slog.New(slog.NewTextHandler(io.Discard, nil)), 0)
runner.Register(jobs.KindBootstrapStructureMaterialize, func(ctx context.Context, job jobs.Job) error {
return errors.New("boom")
})
if err := runner.Run(ctx); err != nil {
t.Fatalf("runner returned error: %v", err)
}
if len(store.succeeded) != 0 {
t.Fatalf("expected no succeeded jobs, got %#v", store.succeeded)
}
if len(store.failed) != 1 {
t.Fatalf("expected one failed job, got %#v", store.failed)
}
if store.failed[0].jobID != "job-2" {
t.Fatalf("expected failed job id job-2, got %#v", store.failed[0])
}
if !strings.Contains(store.failed[0].failure, "boom") {
t.Fatalf("expected failure to mention handler error, got %#v", store.failed[0])
}
}
func TestRunnerMarksFailedWhenHandlerMissing(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
store := &fakeJobStore{
job: &jobs.Job{
ID: "job-3",
Kind: "unknown.kind",
},
cancel: cancel,
}
runner := NewRunner(store, slog.New(slog.NewTextHandler(io.Discard, nil)), 0)
if err := runner.Run(ctx); err != nil {
t.Fatalf("runner returned error: %v", err)
}
if len(store.failed) != 1 {
t.Fatalf("expected one failed job, got %#v", store.failed)
}
if !strings.Contains(store.failed[0].failure, "no handler registered") {
t.Fatalf("expected missing handler failure, got %#v", store.failed[0])
}
}
func TestRunnerRetriesClaimErrors(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
store := &fakeJobStore{
claimErrors: []error{errors.New("relation \"background_jobs\" does not exist")},
job: &jobs.Job{
ID: "job-4",
Kind: jobs.KindBootstrapStructureMaterialize,
},
cancel: cancel,
}
runner := NewRunner(store, slog.New(slog.NewTextHandler(io.Discard, nil)), time.Millisecond)
handlerCalled := false
runner.Register(jobs.KindBootstrapStructureMaterialize, func(ctx context.Context, job jobs.Job) error {
handlerCalled = true
return nil
})
if err := runner.Run(ctx); err != nil {
t.Fatalf("runner returned error: %v", err)
}
if !handlerCalled {
t.Fatal("expected handler to be called after claim retry")
}
if store.claimAttempts < 2 {
t.Fatalf("expected at least two claim attempts, got %d", store.claimAttempts)
}
if len(store.succeeded) != 1 || store.succeeded[0] != "job-4" {
t.Fatalf("expected job to be marked succeeded once, got %#v", store.succeeded)
}
if len(store.failed) != 0 {
t.Fatalf("expected no failed jobs, got %#v", store.failed)
}
}
type fakeJobStore struct {
mu sync.Mutex
job *jobs.Job
claimed bool
claimErrors []error
claimAttempts int
succeeded []string
failed []fakeFailure
cancel context.CancelFunc
}
type fakeFailure struct {
jobID string
failure string
}
func (store *fakeJobStore) ClaimNext(ctx context.Context) (*jobs.Job, error) {
store.mu.Lock()
defer store.mu.Unlock()
store.claimAttempts++
if len(store.claimErrors) > 0 {
err := store.claimErrors[0]
store.claimErrors = store.claimErrors[1:]
return nil, err
}
if store.claimed || store.job == nil {
return nil, nil
}
store.claimed = true
job := *store.job
return &job, nil
}
func (store *fakeJobStore) MarkSucceeded(ctx context.Context, jobID string) error {
store.mu.Lock()
store.succeeded = append(store.succeeded, jobID)
store.mu.Unlock()
if store.cancel != nil {
store.cancel()
}
return nil
}
func (store *fakeJobStore) MarkFailed(ctx context.Context, jobID, failure string) error {
store.mu.Lock()
store.failed = append(store.failed, fakeFailure{jobID: jobID, failure: failure})
store.mu.Unlock()
if store.cancel != nil {
store.cancel()
}
return nil
}
+19 -6
View File
@@ -1,26 +1,39 @@
project_root := justfile_directory() project_root := justfile_directory()
backend_dir := project_root + "/Backend" backend_dir := project_root + "/Backend"
common_sh := project_root + "/Commands/Local/scripts/common.sh"
posix_root := project_root + "/POSIX"
# Apply embedded database migrations. # Apply embedded database migrations.
migrate-up: migrate-up:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"'
cd '{{backend_dir}}' && go run ./cmd/migrate up cd '{{backend_dir}}' && go run ./cmd/migrate up
# Roll back the most recent embedded database migration. # Roll back the most recent embedded database migration (confirmation required).
migrate-down: migrate-down:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"; confirm_destructive_action "This will roll back the most recent embedded database migration. Continue?"'
cd '{{backend_dir}}' && go run ./cmd/migrate down cd '{{backend_dir}}' && go run ./cmd/migrate down
# Reset all embedded database migrations and reapply from scratch. # Reset all embedded database migrations (confirmation required).
migrate-reset: migrate-reset:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"; confirm_destructive_action "This will reset all embedded database migrations. Continue?"'
cd '{{backend_dir}}' && go run ./cmd/migrate reset cd '{{backend_dir}}' && go run ./cmd/migrate reset
# Reset embedded database migrations and apply them again from scratch (confirmation required).
migrate-rebuild:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"; confirm_destructive_action "This will reset all embedded database migrations and reapply them from scratch. Continue?"'
cd '{{backend_dir}}' && go run ./cmd/migrate reset && go run ./cmd/migrate up
# Show the embedded database migration status. # Show the embedded database migration status.
migrate-status: migrate-status:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"'
cd '{{backend_dir}}' && go run ./cmd/migrate status cd '{{backend_dir}}' && go run ./cmd/migrate status
# Rebuild the POSIX-to-DB projection from the current POSIX root.
posix-rebuild:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"'
cd '{{backend_dir}}' && go run ./cmd/posix rebuild
# Format backend Go source files. # Format backend Go source files.
fmt: fmt:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"'
cd '{{backend_dir}}' && gofmt -w ./cmd ./db ./internal cd '{{backend_dir}}' && gofmt -w ./cmd ./db ./internal
# Run backend test suite.
test:
cd '{{backend_dir}}' && go test ./...
+5 -1
View File
@@ -2,13 +2,17 @@ project_root := justfile_directory()
local_compose := project_root + "/Docker/docker-compose.local.dev.yaml" local_compose := project_root + "/Docker/docker-compose.local.dev.yaml"
frontend_dir := project_root + "/Frontend" frontend_dir := project_root + "/Frontend"
node_modules_volume := "moku_work_frontend_node_modules" node_modules_volume := "moku_work_frontend_node_modules"
common_sh := project_root + "/Commands/Local/scripts/common.sh"
posix_root := project_root + "/POSIX"
# Recreate the frontend node_modules Docker volume. # Recreate the frontend node_modules Docker volume (confirmation required).
node_modules: node_modules:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"; confirm_destructive_action "This will recreate the frontend node_modules Docker volume. Continue?"'
docker compose -f '{{local_compose}}' rm -sf frontend >/dev/null 2>&1 || true docker compose -f '{{local_compose}}' rm -sf frontend >/dev/null 2>&1 || true
docker volume rm -f '{{node_modules_volume}}' >/dev/null 2>&1 || true docker volume rm -f '{{node_modules_volume}}' >/dev/null 2>&1 || true
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate frontend docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate frontend
# Run the frontend TypeScript check. # Run the frontend TypeScript check.
tsc: tsc:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"'
cd '{{frontend_dir}}' && pnpm typecheck cd '{{frontend_dir}}' && pnpm typecheck
+2 -2
View File
@@ -18,7 +18,7 @@ start:
# Alias for the main full local development flow. # Alias for the main full local development flow.
dev: up dev: up
# Stop and remove the local development stack. # Stop and remove the local development stack (confirmation required).
down: down:
bash '{{stack_runner}}' down bash '{{stack_runner}}' down
@@ -34,6 +34,6 @@ logs:
restart: restart:
bash '{{stack_runner}}' restart bash '{{stack_runner}}' restart
# Stop the local development stack and remove local images, volumes, and backend dev state. # Stop the local development stack and remove local images, volumes, backend dev state, and the local POSIX folder (confirmation required).
clean: clean:
bash '{{stack_runner}}' clean bash '{{stack_runner}}' clean
+19 -5
View File
@@ -7,6 +7,7 @@ action=${1:-up}
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
project_root=$(cd -- "$script_dir/../../../.." && pwd) project_root=$(cd -- "$script_dir/../../../.." && pwd)
posix_root="$project_root/POSIX"
backend_dir="$project_root/Backend" backend_dir="$project_root/Backend"
backend_bake="$backend_dir/docker-bake.hcl" backend_bake="$backend_dir/docker-bake.hcl"
env_dir="$project_root/Env" env_dir="$project_root/Env"
@@ -15,11 +16,14 @@ runtime_dir="$backend_dir/tmp/dev"
backend_image="moku/work-backend:dev" backend_image="moku/work-backend:dev"
backend_go_pkg_volume="moku_work_backend_go_pkg" backend_go_pkg_volume="moku_work_backend_go_pkg"
backend_go_build_volume="moku_work_backend_go_build" backend_go_build_volume="moku_work_backend_go_build"
local_uid=$(id -u)
local_gid=$(id -g)
services=(web api worker) services=(web api worker)
source "$script_dir/docker.sh" source "$script_dir/docker.sh"
source "$script_dir/env.sh" source "$script_dir/env.sh"
source "$project_root/Commands/Local/scripts/common.sh"
build_backend() { build_backend() {
cd "$backend_dir" cd "$backend_dir"
@@ -27,20 +31,20 @@ build_backend() {
} }
up_backend() { up_backend() {
docker compose -f "$compose_file" up -d --remove-orphans --force-recreate "${services[@]}" LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" up -d --remove-orphans --force-recreate "${services[@]}"
} }
down_backend() { down_backend() {
docker compose -f "$compose_file" stop "${services[@]}" >/dev/null 2>&1 || true LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" stop "${services[@]}" >/dev/null 2>&1 || true
docker compose -f "$compose_file" rm -f "${services[@]}" >/dev/null 2>&1 || true LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" rm -f "${services[@]}" >/dev/null 2>&1 || true
} }
restart_backend() { restart_backend() {
docker compose -f "$compose_file" restart "${services[@]}" LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" restart "${services[@]}"
} }
follow_logs() { follow_logs() {
docker compose -f "$compose_file" logs -f "${services[@]}" LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" logs -f "${services[@]}"
} }
clean_runtime() { clean_runtime() {
@@ -50,34 +54,44 @@ clean_runtime() {
case "$action" in case "$action" in
check) check)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_posix_root "$project_root" "$posix_root"
;; ;;
build) build)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_posix_root "$project_root" "$posix_root"
build_backend build_backend
;; ;;
up) up)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_local_env_file "$env_dir" ensure_local_env_file "$env_dir"
ensure_posix_root "$project_root" "$posix_root"
up_backend up_backend
run_compose_api_migrations "$compose_file" "api"
;; ;;
down) down)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_local_env_file "$env_dir" ensure_local_env_file "$env_dir"
ensure_posix_root "$project_root" "$posix_root"
confirm_destructive_action 'This will stop and remove the local backend dev containers. Continue?'
down_backend down_backend
;; ;;
restart) restart)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_local_env_file "$env_dir" ensure_local_env_file "$env_dir"
ensure_posix_root "$project_root" "$posix_root"
restart_backend restart_backend
;; ;;
logs) logs)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_local_env_file "$env_dir" ensure_local_env_file "$env_dir"
ensure_posix_root "$project_root" "$posix_root"
follow_logs follow_logs
;; ;;
clean) clean)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_local_env_file "$env_dir" ensure_local_env_file "$env_dir"
ensure_posix_root "$project_root" "$posix_root"
confirm_destructive_action 'This will remove the local backend dev containers, images, volumes, and runtime state. Continue?'
down_backend down_backend
remove_docker_image_if_present "$backend_image" remove_docker_image_if_present "$backend_image"
remove_docker_volume_if_present "$backend_go_pkg_volume" remove_docker_volume_if_present "$backend_go_pkg_volume"
+19 -5
View File
@@ -7,12 +7,15 @@ action=${1:-up}
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
project_root=$(cd -- "$script_dir/../../../.." && pwd) project_root=$(cd -- "$script_dir/../../../.." && pwd)
posix_root="$project_root/POSIX"
frontend_dir="$project_root/Frontend" frontend_dir="$project_root/Frontend"
frontend_bake="$frontend_dir/docker-bake.hcl" frontend_bake="$frontend_dir/docker-bake.hcl"
backend_dir="$project_root/Backend" backend_dir="$project_root/Backend"
backend_bake="$backend_dir/docker-bake.hcl" backend_bake="$backend_dir/docker-bake.hcl"
env_dir="$project_root/Env" env_dir="$project_root/Env"
compose_file="$project_root/Docker/docker-compose.local.dev.yaml" compose_file="$project_root/Docker/docker-compose.local.dev.yaml"
local_uid=$(id -u)
local_gid=$(id -g)
frontend_image="moku/work-frontend:dev" frontend_image="moku/work-frontend:dev"
backend_image="moku/work-backend:dev" backend_image="moku/work-backend:dev"
frontend_volume="moku_work_frontend_node_modules" frontend_volume="moku_work_frontend_node_modules"
@@ -22,6 +25,7 @@ backend_runtime_dir="$backend_dir/tmp/dev"
source "$script_dir/docker.sh" source "$script_dir/docker.sh"
source "$script_dir/env.sh" source "$script_dir/env.sh"
source "$project_root/Commands/Local/scripts/common.sh"
build_frontend() { build_frontend() {
cd "$frontend_dir" cd "$frontend_dir"
@@ -39,19 +43,19 @@ build_images() {
} }
up_stack() { up_stack() {
docker compose -f "$compose_file" up -d --remove-orphans --force-recreate LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" up -d --remove-orphans --force-recreate
} }
down_stack() { down_stack() {
docker compose -f "$compose_file" down --remove-orphans --volumes LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" down --remove-orphans --volumes
} }
follow_logs() { follow_logs() {
docker compose -f "$compose_file" logs -f LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" logs -f
} }
clean_stack() { clean_stack() {
docker compose -f "$compose_file" down --remove-orphans --volumes >/dev/null 2>&1 || true LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" down --remove-orphans --volumes >/dev/null 2>&1 || true
remove_docker_image_if_present "$frontend_image" remove_docker_image_if_present "$frontend_image"
remove_docker_image_if_present "$backend_image" remove_docker_image_if_present "$backend_image"
remove_docker_volume_if_present "$frontend_volume" remove_docker_volume_if_present "$frontend_volume"
@@ -63,37 +67,47 @@ clean_stack() {
start_stack() { start_stack() {
build_images build_images
up_stack up_stack
run_compose_api_migrations "$compose_file" "api"
} }
case "$action" in case "$action" in
build) build)
ensure_docker 'docker is required for the local development stack. Install Docker first.' ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_posix_root "$project_root" "$posix_root"
build_images build_images
;; ;;
up|start|rebuild) up|start|rebuild)
ensure_docker 'docker is required for the local development stack. Install Docker first.' ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_local_env_file "$env_dir" ensure_local_env_file "$env_dir"
ensure_posix_root "$project_root" "$posix_root"
start_stack start_stack
;; ;;
down) down)
ensure_docker 'docker is required for the local development stack. Install Docker first.' ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_local_env_file "$env_dir" ensure_local_env_file "$env_dir"
ensure_posix_root "$project_root" "$posix_root"
confirm_destructive_action 'This will stop the local development stack and remove its containers and volumes. Continue?'
down_stack down_stack
;; ;;
restart) restart)
ensure_docker 'docker is required for the local development stack. Install Docker first.' ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_local_env_file "$env_dir" ensure_local_env_file "$env_dir"
docker compose -f "$compose_file" restart ensure_posix_root "$project_root" "$posix_root"
LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" restart
;; ;;
logs) logs)
ensure_docker 'docker is required for the local development stack. Install Docker first.' ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_local_env_file "$env_dir" ensure_local_env_file "$env_dir"
ensure_posix_root "$project_root" "$posix_root"
follow_logs follow_logs
;; ;;
clean) clean)
ensure_docker 'docker is required for the local development stack. Install Docker first.' ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_local_env_file "$env_dir" ensure_local_env_file "$env_dir"
ensure_posix_root "$project_root" "$posix_root"
confirm_destructive_action 'This will remove the local development stack, local images, volumes, backend dev state, and the local POSIX folder. Continue?'
clean_stack clean_stack
rm -rf "$posix_root"
;; ;;
*) *)
printf 'Unsupported dev stack action: %s\n' "$action" >&2 printf 'Unsupported dev stack action: %s\n' "$action" >&2
+24 -8
View File
@@ -1,37 +1,53 @@
project_root := justfile_directory() project_root := justfile_directory()
proxy_bake := project_root + "/Proxy/docker-bake.hcl" proxy_bake := project_root + "/Proxy/docker-bake.hcl"
backend_bake := project_root + "/Backend/docker-bake.hcl"
local_compose := project_root + "/Docker/docker-compose.local.prod.yaml" local_compose := project_root + "/Docker/docker-compose.local.prod.yaml"
proxy_image := "moku/work-proxy:local-prod" proxy_image := "moku/work-proxy:local-prod"
backend_api_image := "moku/work-backend:local-prod-api"
backend_worker_image := "moku/work-backend:local-prod-worker"
common_sh := project_root + "/Commands/Local/scripts/common.sh"
posix_root := project_root + "/POSIX"
# Build the local production proxy image locally. # Build the local production proxy image locally.
build: build:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"'
cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' prod cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' prod
cd '{{project_root}}' && docker buildx bake -f '{{backend_bake}}' prod-api prod-worker
# Start the local production stack in the background using the current image. # Start the local production stack in the background using the current image.
up: up:
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"'
LOCAL_UID="$$(id -u)" LOCAL_GID="$$(id -g)" docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
# Build first, then start the local production stack in the background. # Build first, then start the local production stack in the background.
start: build up start: build up
# Rebuild the local production proxy image locally. # Rebuild the local production proxy image locally.
rebuild: rebuild:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"'
cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' --set '*.no-cache=true' prod cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' --set '*.no-cache=true' prod
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate cd '{{project_root}}' && docker buildx bake -f '{{backend_bake}}' --set '*.no-cache=true' prod-api prod-worker
LOCAL_UID="$$(id -u)" LOCAL_GID="$$(id -g)" docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
# Stop and remove the local production stack. # Stop and remove the local production stack (confirmation required).
down: down:
docker compose -f '{{local_compose}}' down --remove-orphans --volumes bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"; confirm_destructive_action "This will stop the local production stack and remove its containers and volumes. Continue?"'
LOCAL_UID="$$(id -u)" LOCAL_GID="$$(id -g)" docker compose -f '{{local_compose}}' down --remove-orphans --volumes
# Follow logs for the local production stack. # Follow logs for the local production stack.
logs: logs:
docker compose -f '{{local_compose}}' logs -f bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"'
LOCAL_UID="$$(id -u)" LOCAL_GID="$$(id -g)" docker compose -f '{{local_compose}}' logs -f
# Restart the local production stack. # Restart the local production stack.
restart: restart:
docker compose -f '{{local_compose}}' restart bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"'
LOCAL_UID="$$(id -u)" LOCAL_GID="$$(id -g)" docker compose -f '{{local_compose}}' restart
# Stop the local production stack and remove local images. # Stop the local production stack and remove local images (confirmation required).
clean: clean:
docker compose -f '{{local_compose}}' down --remove-orphans --volumes bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"; confirm_destructive_action "This will remove the local production stack, its volumes, and the local production images. Continue?"'
LOCAL_UID="$$(id -u)" LOCAL_GID="$$(id -g)" docker compose -f '{{local_compose}}' down --remove-orphans --volumes
docker image rm -f '{{proxy_image}}' >/dev/null 2>&1 || true docker image rm -f '{{proxy_image}}' >/dev/null 2>&1 || true
docker image rm -f '{{backend_api_image}}' >/dev/null 2>&1 || true
docker image rm -f '{{backend_worker_image}}' >/dev/null 2>&1 || true
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
ensure_posix_root() {
local project_root=${1:?project root is required}
local posix_root=${2:-"$project_root/POSIX"}
mkdir -p "$posix_root"
}
confirm_destructive_action() {
local prompt=${1:-Are you sure you want to continue?}
local response_input=""
if [[ "${JUST_YES:-0}" == "1" ]]; then
return 0
fi
if [[ -t 0 ]]; then
printf '%s [y/N] ' "$prompt"
IFS= read -r response_input
elif [[ -r /dev/tty ]]; then
printf '%s [y/N] ' "$prompt" > /dev/tty
IFS= read -r response_input < /dev/tty
else
printf 'Refusing destructive action without interactive confirmation. Re-run with JUST_YES=1 to continue.\n' >&2
exit 1
fi
case "$response_input" in
y|Y|yes|YES|Yes)
return 0
;;
*)
printf 'Aborted.\n' >&2
exit 1
;;
esac
}
run_compose_api_migrations() {
local compose_file=${1:?compose file is required}
local service_name=${2:-api}
local max_attempts=${3:-20}
local sleep_seconds=${4:-2}
local attempt=1
local output=""
while (( attempt <= max_attempts )); do
if output=$(docker compose -f "$compose_file" exec -T "$service_name" sh -lc 'export PATH="$PATH:/usr/local/go/bin"; go run ./cmd/migrate up' 2>&1); then
printf '%s\n' "$output"
return 0
fi
if (( attempt == max_attempts )); then
printf 'Failed to apply database migrations automatically after %d attempts.\n' "$max_attempts" >&2
printf '%s\n' "$output" >&2
return 1
fi
sleep "$sleep_seconds"
attempt=$((attempt + 1))
done
}
+11
View File
@@ -0,0 +1,11 @@
project_root := justfile_directory()
backend_dir := project_root + "/Backend"
# Run the full backend test suite.
[default]
all:
cd '{{backend_dir}}' && go test ./...
# Run the isolated POSIX bootstrap smoke test.
posix-bootstrap:
cd '{{backend_dir}}' && go test ./internal/bootstrap -run TestEnsureBootstrapPOSIXSkeletonInitializesEmptyRoot -count=1 -v
+1
View File
@@ -0,0 +1 @@
mod backend
+8 -2
View File
@@ -1,11 +1,16 @@
x-backend-service: &backend-service x-backend-service: &backend-service
image: moku/work-backend:dev image: moku/work-backend:dev
restart: unless-stopped restart: unless-stopped
user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}"
env_file: env_file:
- ../Env/.env.local - ../Env/.env.local
environment: environment:
DATABASE_URL: postgres://moku:moku_dev_password@postgres:5432/moku?sslmode=disable DATABASE_URL: postgres://moku:moku_dev_password@postgres:5432/moku?sslmode=disable
VALKEY_URL: redis://valkey:6379/0 VALKEY_URL: redis://valkey:6379/0
POSIX_ROOT: /posix
HOME: /tmp/home
GOMODCACHE: /tmp/go/pkg/mod
GOCACHE: /tmp/go-build
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -13,8 +18,9 @@ x-backend-service: &backend-service
condition: service_healthy condition: service_healthy
volumes: volumes:
- ../Backend:/app - ../Backend:/app
- moku_work_backend_go_pkg:/go/pkg/mod - ../POSIX:/posix
- moku_work_backend_go_build:/root/.cache/go-build - moku_work_backend_go_pkg:/tmp/go/pkg/mod
- moku_work_backend_go_build:/tmp/go-build
services: services:
postgres: postgres:
+64
View File
@@ -1,7 +1,71 @@
x-backend-service: &backend-service
restart: unless-stopped
user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}"
env_file:
- ../Env/.env.local
environment:
DATABASE_URL: postgres://moku:moku_dev_password@postgres:5432/moku?sslmode=disable
VALKEY_URL: redis://valkey:6379/0
POSIX_ROOT: /posix
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_healthy
volumes:
- ../POSIX:/posix
services: services:
postgres:
image: postgres:17-alpine
container_name: moku-work-postgres-prod
restart: unless-stopped
environment:
POSTGRES_DB: moku
POSTGRES_USER: moku
POSTGRES_PASSWORD: moku_dev_password
volumes:
- moku_work_postgres_prod_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U moku -d moku"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
valkey:
image: valkey/valkey:8-alpine
container_name: moku-work-valkey-prod
restart: unless-stopped
volumes:
- moku_work_valkey_prod_data:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
api:
<<: *backend-service
image: moku/work-backend:local-prod-api
container_name: moku-work-backend-api-prod
worker:
<<: *backend-service
image: moku/work-backend:local-prod-worker
container_name: moku-work-backend-worker-prod
proxy: proxy:
image: moku/work-proxy:local-prod image: moku/work-proxy:local-prod
container_name: moku-work-proxy-local container_name: moku-work-proxy-local
restart: unless-stopped restart: unless-stopped
depends_on:
api:
condition: service_started
ports: ports:
- "8080:80" - "8080:80"
volumes:
moku_work_postgres_prod_data:
moku_work_valkey_prod_data:
+81
View File
@@ -0,0 +1,81 @@
# POSIX Structure
Note: regenerate the external filetree link if you want the hosted visual tree to reflect the new `.cbor` and `.jsonc` paths.
``` markdown
Personal or Organization (server)/
├── settings.cbor
├── catalog/
│ ├── packs/
│ │ └── pack-<slug>/
│ │ ├── manifest.jsonc
│ │ └── entries/
│ │ └── app-<slug>/
│ │ └── manifest.jsonc
│ └── standalone/
│ └── app-<slug>/
│ └── manifest.jsonc
├── departments/
│ └── department-<slug>/
│ ├── settings.cbor
│ ├── users.cbor
│ └── teams/
│ └── team-<slug>/
│ ├── settings.cbor
│ └── users.cbor
├── projects/
│ └── project-<slug>/
│ ├── settings.cbor
│ ├── home.cbor
│ ├── acl.cbor
│ ├── children/
│ │ └── folder-<slug>/
│ │ ├── folder.cbor
│ │ ├── acl.cbor
│ │ └── children/
│ │ └── project-<slug>/
│ │ ├── settings.cbor
│ │ ├── home.cbor
│ │ ├── acl.cbor
│ │ ├── children/
│ │ └── tree/
│ └── tree/
│ ├── item-<slug>/
│ │ ├── item.cbor
│ │ ├── schema.json
│ │ └── data.cbor
│ └── folder-<slug>/
│ ├── folder.cbor
│ └── item-<slug>/
│ ├── item.cbor
│ ├── schema.json
│ └── data.cbor
└── users/
├── settings.cbor
├── data.cbor
└── personals/
└── personal-<slug>/
├── layout.cbor
├── settings.cbor
├── home.cbor
└── tree/
```
## File Responsibilities
- `settings.cbor` — Machine-owned metadata and presentation config for the thing, such as display name, icon, description, and simple settings.
- `layout.cbor` — Machine-owned layout configuration for the current server or personal space.
- `home.cbor` — Machine-owned home surface configuration, such as widgets, sections, and how they are arranged.
- `folder.cbor` — Machine-owned metadata for a folder node in a tree.
- `item.cbor` — Machine-owned instance metadata for a created item, including what it is and how it should behave.
- `schema.json` — The structure expected by that item's data.
- `data.cbor` — The machine-owned content or state data for that item.
- `manifest.jsonc` — Human-facing catalog definition metadata, including versioning, description, capabilities, and comments for reusable apps or entries.
- `users.cbor` — Machine-owned user membership or assignment data for departments and teams.
- `acl.cbor` — Machine-owned access control data for projects and folders.
## Format Rules
- Use `*.jsonc` for catalog manifests that may be authored or reviewed by humans.
- Use `*.cbor` for machine-owned POSIX state and metadata files where parse speed, compactness, and backupability matter more than direct editability.
- Keep `schema.json` as JSON so it remains compatible with JSON Schema tooling and validation flows.
+112 -50
View File
@@ -4,87 +4,149 @@
### Version 0.1.0 ### Version 0.1.0
**Goal:** Barebone frontend with a real backend core. **Goal:** Finish the base application shell, auth, and platform foundations.
#### Architecture #### Architecture and Delivery
- [ ] Project-Structure - [x] Project-Structure
- [ ] Stack-Decisions - [x] Stack-Decisions
- [ ] Proxy - [x] Proxy
- [ ] Local-Prod-NGINX-Proxy - [x] Local-Dev-Vite-Proxy
- [ ] Static-Frontend-Serving - [x] Local-Prod-NGINX-Proxy
- [ ] First-Request-Web-Loader - [x] First-Request-Web-Loader
- [ ] Bootstrap-Document - [x] Bootstrap-Document
- [ ] Route-Intent-Handoff - [x] Route-Intent-Handoff
- [ ] Tiny-First-Paint-Budget - [x] Tiny-First-Paint-Budget
- [ ] Dev-and-Prod-Builds - [x] Dev-and-Prod-Builds
- [x] Local-Dev-Just-Commands - [x] Local-Dev-Just-Commands
- [x] Local-Dev-Docker-Compose - [x] Local-Dev-Docker-Compose
- [ ] Local-Prod-Just-Commands - [x] Local-Prod-Just-Commands
- [ ] Local-Prod-Docker-Compose - [x] Local-Prod-Docker-Compose
- [ ] Frontend-Production-Dockerfile - [x] Frontend-Production-Dockerfile
- [ ] Frontend-docker-bake - [x] Frontend-docker-bake
#### Backend #### Backend — Done Foundations
- [x] Bootstrap-Persistence
- [x] Installation-Step
- [x] Mode-Step
- [x] Admin-Step
- [x] Structure-Step
- [x] Bootstrap-State-Authority
- [x] Development-Bootstrap-Reset
- [x] Base-Schema
- [x] Installations
- [x] Users
- [x] User-Homes
- [x] Organizations
- [x] Departments
- [x] Teams
- [x] Projects
- [x] Workspaces
- [x] Membership-Tables
- [x] App-Shell-Read-API
- [x] App-Shell-State-Endpoint
- [x] Bootstrap-Read-Endpoints
- [x] Shell-Tree-Hydration
- [x] Web-Route-Scaffolds
- [x] Session-Endpoint-Scaffold
- [x] Bootstrap-Endpoint-Scaffold
- [x] Current-User-Endpoint-Scaffold
#### Backend — Remaining for 0.1.0
- [ ] Auth - [ ] Auth
- [ ] Session-Flow - [ ] Session-Flow
- [ ] Login-Logout-Foundation - [ ] Login-Logout-Foundation
- [ ] Authentication - [ ] Authentication
- [ ] User - [ ] Current-User-Implementation
- [ ] Base-Model - [ ] POSIX-Lite-File-Persistence-Foundation
- [ ] Mounted-Storage-Root-Config
- [ ] Project-Folder-Creation-On-Backend
- [ ] manifest.jsonc
- [ ] Item-Folder-Creation
- [ ] item.cbor
- [ ] schema.json
- [ ] data.cbor
- [ ] DB-To-Files-Write-Flow
- [ ] User-and-Workspace-Domain-Readiness
- [ ] Base-Workspace - [ ] Base-Workspace
- [ ] Folders-and-Subfolders
- [ ] Boards - [ ] Boards
- [ ] Dashboard - [ ] Dashboard
- [ ] Organization
- [ ] Base-Model
- [ ] Access-Rules-and-Membership
- [ ] Workspace
- [ ] Folders-and-Subfolders
- [ ] API - [ ] API
- [ ] Real-Organizations-Read-Endpoint
- [ ] Real-Workspaces-Read-Endpoint
- [ ] Tree-Mutation-Endpoints
- [ ] Project-Creation-Endpoint
#### Frontend #### Frontend — Done Foundations
- [x] Foundation - [x] Foundation
- [x] Typography - [x] Typography
- [x] Icons - [x] Icons
- [ ] App Shell - [x] App-Shell
- [x] Left-Rail
- [x] Top-Bar
- [x] Server-Dock
- [x] Department-Selector
- [x] Theme-Toggle
- [x] Notifications-Menu
- [x] Profile-Menu
- [x] Responsive-Shell
- [x] Collapsible-Shell
- [x] Mobile-Bottom-Nav
- [x] Mobile-Workspace-Browser
- [x] Mobile-Workspace-Views
- [x] Context-Menus
- [x] Workspace-Context-Menu
- [x] Project-Context-Menu
- [x] Bootstrap-Workspace-Home
- [x] Bootstrap-Wizard
- [x] Bootstrap-Step-Submission
- [x] App-Shell-Reload-After-Bootstrap
- [x] Project-Menu
- [x] Folders-and-Subfolders
- [x] Rooted-From-Department
- [x] Long-Press-Drag-and-Drop
- [x] Workspace-Tree
- [x] Folders-and-Subfolders
- [x] Long-Press-Drag-and-Drop
- [x] App-Shell-Hydration
#### Frontend — Remaining for 0.1.0
- [ ] Primitives - [ ] Primitives
- [ ] Button - [ ] Button
- [ ] IconButton - [ ] IconButton
- [ ] Input - [ ] Input
- [ ] Surface - [ ] Surface
- [ ] Nav-Bar
- [ ] Workspace-Switching - [ ] Workspace-Switching
- [ ] Workspace-Home - [ ] Real-Workspace-Home
- [ ] Real-Workspace-Tree-Hydration
- [ ] Create-Project-Flow
- [ ] Persist-Tree-Mutations
- [ ] Connect-Tree-Interactions-To-Backend-Data
### Version 0.2.0 ### Version 0.2.0
**Goal:** First real work surface. **Goal:** Build the plugin app system on top of the base platform. And core app plugins like calendar, board, docs and text channels
- [ ] Table
- [ ] CVA
- [ ] Storyboard
- [ ] Theme-System
- [ ] Theme-Registry
- [ ] Built-In-Theme-Presets
- [ ] Active-Theme-Persistence
- [ ] Theme-Switcher
- [ ] Theme-JSON-Upload
- [ ] Theme-JSON-Import-Validation
- [ ] Community-Theme-Readiness
### Version 0.3.0 ### Version 0.3.0
**Goal:** Documents and system hardening. **Goal:** Communications and Collaboration (Email System, Reminder System, and Live Collaboration on Documents)
- [ ] Document
- [ ] Accessibility-Rules
- [ ] Motion-Foundation
### Version 0.4.0 ### Version 0.4.0
- [ ] Gantt-Board **Goal:** Introduce the POSIX-based file system drive direction with OnlyOffice + S3 blob storage + Per File Versioning
- [ ] Calendar
- [ ] Timeline ### Version 0.5.0
**Goal:** File Sharing and Per File Permissions
### Version 0.6.0
**Goal:** Git as a core plugin
### Version 0.7.0
**Goal:** Full Automation System (Extensive)
+7
View File
@@ -10,3 +10,10 @@ BACKEND_SHUTDOWN_TIMEOUT=10s
DATABASE_URL=postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable DATABASE_URL=postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable
VALKEY_URL=redis://localhost:6379/0 VALKEY_URL=redis://localhost:6379/0
POSIX_ROOT=../POSIX
VITE_API_BASE_URL=/v1
# Local frontend dev uses the Vite proxy for /v1 requests.
# Override only if the browser must call the API directly:
# VITE_API_BASE_URL=http://localhost:8081/v1
+2 -1
View File
@@ -1,8 +1,9 @@
// Path: Frontend/src/app.tsx // Path: Frontend/src/app.tsx
import type { JSX } from "solid-js"; import type { JSX } from "solid-js";
import { AppShell } from "./components/shell/AppShell/AppShell"; import { AppShell } from "./components/app-shell/AppShell/AppShell";
import "./styles/main.scss"; import "./styles/main.scss";
import "./styles/user-overrides.scss";
const App = (): JSX.Element => { const App = (): JSX.Element => {
return <AppShell />; return <AppShell />;
@@ -1,28 +1,32 @@
// Path: Frontend/src/components/shell/AppShell/AppShell.tsx // Path: Frontend/src/components/app-shell/AppShell/AppShell.tsx
import { createSignal, onCleanup, onMount, Show, type JSX } from "solid-js"; import { createSignal, onCleanup, onMount, Show, type JSX } from "solid-js";
import { getDocumentTheme, setTheme, type Theme } from "../../../theme/runtime"; import { getDocumentTheme, setTheme, type Theme } from "../../../helper/themeRuntime";
import { WorkspaceHome } from "../../workspace-home/WorkspaceHome/WorkspaceHome"; import { BootstrapWizard } from "../../bootstrap/BootstrapWizard/BootstrapWizard";
import { LeftRail } from "../LeftRail/LeftRail"; import { WorkspaceHome } from "../../workspace-home/WorkspaceHome";
import { AppShellDataProvider, useAppShellData } from "../data/app-shell.context";
import { LeftRail } from "../../workspace-navigation/LeftRail/LeftRail";
import { MobileBottomNav } from "../MobileBottomNav/MobileBottomNav"; import { MobileBottomNav } from "../MobileBottomNav/MobileBottomNav";
import { MobileWorkspaceBrowser } from "../MobileWorkspaceBrowser/MobileWorkspaceBrowser"; import { MobileWorkspaceBrowser } from "../../workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser";
import { ServerDock } from "../ServerDock/ServerDock"; import { ServerDock } from "../ServerDock/ServerDock";
import { NotificationsMenu } from "../TopBar/NotificationsMenu"; import { NotificationsMenu } from "../../top-bar/TopBar/NotificationsMenu";
import { ProfileMenu } from "../TopBar/ProfileMenu"; import { ProfileMenu } from "../../top-bar/TopBar/ProfileMenu";
import { TopBar } from "../TopBar/TopBar"; import { TopBar } from "../../top-bar/TopBar/TopBar";
import { WorkspaceSidebar } from "../WorkspaceSidebar/WorkspaceSidebar"; import { WorkspaceTopBar } from "../../workspace-navigation/WorkspaceTopBar/WorkspaceTopBar";
import { WorkspaceSidebar } from "../../workspace-navigation/WorkspaceSidebar/WorkspaceSidebar";
import styles from "./AppShell.module.scss"; import styles from "./AppShell.module.scss";
type MobileWorkspaceView = "notifications" | "profile" | null; type MobileWorkspaceView = "notifications" | "profile" | null;
const MOBILE_VIEWPORT_QUERY = "(max-width: 48rem)"; const MOBILE_VIEWPORT_QUERY = "(max-width: 48rem)";
export const AppShell = (): JSX.Element => { const AppShellContent = (): JSX.Element => {
const [themeState, setThemeState] = createSignal<Theme>("light"); const [themeState, setThemeState] = createSignal<Theme>("light");
const [isRailCollapsed, setIsRailCollapsed] = createSignal(false); const [isRailCollapsed, setIsRailCollapsed] = createSignal(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = createSignal(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = createSignal(false);
const [isMobileViewport, setIsMobileViewport] = createSignal(false); const [isMobileViewport, setIsMobileViewport] = createSignal(false);
const [isMobileWorkspaceBrowserOpen, setIsMobileWorkspaceBrowserOpen] = createSignal(false); const [isMobileWorkspaceBrowserOpen, setIsMobileWorkspaceBrowserOpen] = createSignal(false);
const [activeMobileWorkspaceView, setActiveMobileWorkspaceView] = createSignal<MobileWorkspaceView>(null); const [activeMobileWorkspaceView, setActiveMobileWorkspaceView] = createSignal<MobileWorkspaceView>(null);
const appShellData = useAppShellData();
onMount((): void => { onMount((): void => {
setThemeState(getDocumentTheme()); setThemeState(getDocumentTheme());
@@ -78,8 +82,10 @@ export const AppShell = (): JSX.Element => {
setActiveMobileWorkspaceView(null); setActiveMobileWorkspaceView(null);
}; };
const workspaceBreadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`;
return ( return (
<div class={styles.shell}> <div class={styles.shell} data-ui="app-shell" data-app-shell-status={appShellData.status()}>
<TopBar <TopBar
theme={themeState()} theme={themeState()}
onToggleTheme={toggleTheme} onToggleTheme={toggleTheme}
@@ -96,15 +102,18 @@ export const AppShell = (): JSX.Element => {
[styles.bodyRailCollapsed]: isRailCollapsed(), [styles.bodyRailCollapsed]: isRailCollapsed(),
[styles.bodySidebarCollapsed]: isSidebarCollapsed(), [styles.bodySidebarCollapsed]: isSidebarCollapsed(),
}} }}
data-slot="shell-body"
data-rail-collapsed={isRailCollapsed() ? "true" : "false"}
data-sidebar-collapsed={isSidebarCollapsed() ? "true" : "false"}
> >
{/* Left server rail */} {/* Left server rail */}
<div class={styles.railColumn}> <div class={styles.railColumn} data-slot="rail-column">
<LeftRail collapsed={isRailCollapsed()} /> <LeftRail collapsed={isRailCollapsed()} />
</div> </div>
{/* Sidebar + main workspace frame */} {/* Sidebar + main workspace frame */}
<div class={styles.workspaceRegion}> <div class={styles.workspaceRegion} data-slot="workspace-region">
<div class={styles.sidebarColumn}> <div class={styles.sidebarColumn} data-slot="sidebar-column">
<WorkspaceSidebar <WorkspaceSidebar
collapsed={isSidebarCollapsed()} collapsed={isSidebarCollapsed()}
railCollapsed={isRailCollapsed()} railCollapsed={isRailCollapsed()}
@@ -114,20 +123,24 @@ export const AppShell = (): JSX.Element => {
/> />
</div> </div>
<div class={styles.workspaceMain}> <div class={styles.workspaceMain} data-slot="workspace-main">
{/* On mobile, top-bar menus become full workspace views instead of popovers. */} {/* On mobile, top-bar menus become full workspace views instead of popovers. */}
<Show <Show
when={isMobileViewport() && activeMobileWorkspaceView() !== null} when={isMobileViewport() && activeMobileWorkspaceView() !== null}
fallback={ fallback={
<WorkspaceHome <>
<WorkspaceTopBar
sidebarCollapsed={isSidebarCollapsed()} sidebarCollapsed={isSidebarCollapsed()}
breadcrumb={workspaceBreadcrumb()}
onToggleSidebarCollapse={(): void => { onToggleSidebarCollapse={(): void => {
setIsSidebarCollapsed((collapsed) => !collapsed); setIsSidebarCollapsed((collapsed) => !collapsed);
}} }}
/> />
<WorkspaceHome />
</>
} }
> >
<div class={styles.mobileWorkspaceView}> <div class={styles.mobileWorkspaceView} data-slot="mobile-workspace-view" data-view={activeMobileWorkspaceView() ?? undefined}>
<Show when={activeMobileWorkspaceView() === "notifications"}> <Show when={activeMobileWorkspaceView() === "notifications"}>
<NotificationsMenu id="mobile-workspace-notifications" onSelect={closeMobileWorkspaceView} variant="workspace" /> <NotificationsMenu id="mobile-workspace-notifications" onSelect={closeMobileWorkspaceView} variant="workspace" />
</Show> </Show>
@@ -140,7 +153,7 @@ export const AppShell = (): JSX.Element => {
</div> </div>
{/* Floating server dock overlay */} {/* Floating server dock overlay */}
<div class={styles.sidebarDock}> <div class={styles.sidebarDock} data-slot="sidebar-dock">
<ServerDock /> <ServerDock />
</div> </div>
</div> </div>
@@ -157,6 +170,15 @@ export const AppShell = (): JSX.Element => {
setIsMobileWorkspaceBrowserOpen(false); setIsMobileWorkspaceBrowserOpen(false);
}} }}
/> />
<BootstrapWizard />
</div> </div>
); );
}; };
export const AppShell = (): JSX.Element => {
return (
<AppShellDataProvider>
<AppShellContent />
</AppShellDataProvider>
);
};
@@ -1,7 +1,8 @@
// Path: Frontend/src/components/shell/MobileBottomNav/MobileBottomNav.tsx // Path: Frontend/src/components/app-shell/MobileBottomNav/MobileBottomNav.tsx
import { For, type JSX } from "solid-js"; import { For, type JSX } from "solid-js";
import { activeProject, activeServer, mobileBottomNavItems, type MobileBottomNavItem } from "../data/shell.data"; import { useAppShellData } from "../data/app-shell.context";
import { mobileBottomNavItems, type MobileBottomNavItem } from "../data/shell.data";
import styles from "./MobileBottomNav.module.scss"; import styles from "./MobileBottomNav.module.scss";
type MobileBottomNavProps = { type MobileBottomNavProps = {
@@ -39,12 +40,14 @@ const MobileNavEntry = (props: {
}; };
export const MobileBottomNav = (props: MobileBottomNavProps): JSX.Element => { export const MobileBottomNav = (props: MobileBottomNavProps): JSX.Element => {
const appShellData = useAppShellData();
return ( return (
<nav class={styles.mobileNav} aria-label="Mobile workspace navigation"> <nav class={styles.mobileNav} aria-label="Mobile workspace navigation">
<div class={styles.contextBar}> <div class={styles.contextBar}>
<span class={styles.contextServer}>{activeServer.name}</span> <span class={styles.contextServer}>{appShellData.activeServer().name}</span>
<span class={styles.contextDivider}>/</span> <span class={styles.contextDivider}>/</span>
<span class={styles.contextProject}>{activeProject.name}</span> <span class={styles.contextProject}>{appShellData.activeProject().name}</span>
</div> </div>
<div class={styles.navGrid}> <div class={styles.navGrid}>
@@ -0,0 +1,49 @@
// Path: Frontend/src/components/app-shell/ServerDock/ServerDock.tsx
import { For, Show, type JSX } from "solid-js";
import { useAppShellData } from "../data/app-shell.context";
import styles from "./ServerDock.module.scss";
export const ServerDock = (): JSX.Element => {
const appShellData = useAppShellData();
const activeServer = () => appShellData.activeServer();
return (
<section class={styles.panel} aria-label="Server dock" data-ui="server-dock" data-server-kind={activeServer().kind}>
<div class={styles.identity} data-slot="server-dock-identity">
<div class={styles.glyph} aria-hidden="true">
{activeServer().abbreviation}
</div>
<div class={styles.copy} data-slot="server-dock-copy">
<span class={styles.name}>{activeServer().name}</span>
<Show
when={activeServer().kind === "organization"}
fallback={<span class={styles.subtitle}>{activeServer().subtitle}</span>}
>
<span class={styles.status}>
<span class={styles.statusDot} aria-hidden="true" />
<span>{activeServer().connectedLabel}</span>
</span>
</Show>
</div>
</div>
<Show when={activeServer().dockActions.length > 0}>
<div class={styles.actions} data-slot="server-dock-actions">
<For each={activeServer().dockActions}>
{(item): JSX.Element => {
const Icon = item.icon;
return (
<button type="button" class={styles.action} aria-label={item.label} title={item.label} data-slot="server-dock-action" data-action-id={item.id}>
<Icon size={16} strokeWidth={2} />
<span class={styles.actionLabel}>{item.label}</span>
</button>
);
}}
</For>
</div>
</Show>
</section>
);
};
@@ -0,0 +1,169 @@
// Path: Frontend/src/components/app-shell/data/app-shell.builders.ts
import {
activeDepartment as fallbackActiveDepartment,
activeProject as fallbackActiveProject,
activeServer as fallbackActiveServer,
activeUserProfile as fallbackActiveUserProfile,
departmentItems as fallbackDepartmentItems,
organizationAdminDockActions,
personalDockActions,
projectItems as fallbackProjectItems,
railItems as fallbackRailItems,
workspaceTree as fallbackWorkspaceTree,
type ActiveDepartment,
type ActiveProject,
type ActiveServer,
type ActiveUserProfile,
type DepartmentItem,
type ProjectItem,
type RailItem,
type WorkspaceTreeNode,
} from "./shell.data";
import type { AppShellPayload } from "./app-shell.types";
const buildAbbreviation = (name: string, fallback: string): string => {
const parts = name
.trim()
.split(/\s+/)
.filter(Boolean);
if (parts.length === 0) {
return fallback;
}
const abbreviation = parts
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
return abbreviation || fallback;
};
export const buildRailItems = (payload: AppShellPayload | null): readonly RailItem[] => {
if (!payload?.installation || payload.organizations.length === 0) {
return fallbackRailItems;
}
const kind = payload.installation.mode === "personal" ? "personal" : "organization";
const serverName = payload.installation.name || payload.organizations[0]?.name || payload.installation.host;
return payload.organizations.map((organization, index) => ({
id: organization.id,
label: serverName || organization.name,
abbreviation: buildAbbreviation(serverName || organization.name, kind === "personal" ? "P" : "O"),
kind,
active: index === 0,
}));
};
export const buildActiveServer = (payload: AppShellPayload | null): ActiveServer => {
const installation = payload?.installation;
const organization = payload?.organizations[0];
if (!installation || !organization) {
return fallbackActiveServer;
}
const kind = installation.mode === "personal" ? "personal" : "organization";
const serverName = installation.name || organization.name || installation.host;
return {
id: installation.id,
name: serverName || fallbackActiveServer.name,
abbreviation: buildAbbreviation(serverName, kind === "personal" ? "P" : "O"),
kind,
connectedLabel: kind === "organization" ? `${payload?.teams.length ?? 0} connected` : undefined,
subtitle: kind === "personal" ? installation.host || payload?.admin?.homeTitle || "Personal home" : undefined,
dockActions: kind === "personal" ? personalDockActions : organizationAdminDockActions,
};
};
export const buildProjectItems = (payload: AppShellPayload | null): readonly ProjectItem[] => {
if (!payload?.projects.length) {
return fallbackProjectItems;
}
return payload.projects.map((project, index) => ({
id: project.id,
name: project.name,
description: project.slug || "Persisted project workspace",
groupLabel: payload.departments.find((department) => department.id === project.departmentId)?.name || "Projects",
parentLabel:
payload.teams.find((team) => team.id === project.teamId)?.name ||
payload.departments.find((department) => department.id === project.departmentId)?.name ||
"Shared project",
meta: (() => {
const workspaceCount = payload.workspaces.filter((workspace) => workspace.projectId === project.id).length;
return workspaceCount > 0 ? `${workspaceCount} workspace${workspaceCount === 1 ? "" : "s"}` : undefined;
})(),
active: index === 0,
}));
};
export const buildActiveProject = (payload: AppShellPayload | null): ActiveProject => {
const firstProject = payload?.projects[0];
if (!firstProject) {
return fallbackActiveProject;
}
return {
id: firstProject.id,
name: firstProject.name,
};
};
export const buildDepartmentItems = (payload: AppShellPayload | null): readonly DepartmentItem[] => {
if (!payload?.departments.length) {
return fallbackDepartmentItems;
}
return payload.departments.map((department, index) => ({
id: department.id,
name: department.name,
teams: payload.teams.filter((team) => team.departmentId === department.id).map((team) => team.name),
active: index === 0,
}));
};
export const buildActiveDepartment = (payload: AppShellPayload | null): ActiveDepartment => {
const firstDepartment = payload?.departments[0];
if (!firstDepartment) {
return fallbackActiveDepartment;
}
const firstTeamName = payload?.teams.find((team) => team.departmentId === firstDepartment.id)?.name ?? "";
return {
id: firstDepartment.id,
name: firstDepartment.name,
teamName: firstTeamName,
};
};
export const buildWorkspaceTree = (payload: AppShellPayload | null): readonly WorkspaceTreeNode[] => {
if (!payload?.projects.length) {
return fallbackWorkspaceTree;
}
return [];
};
export const buildActiveUserProfile = (payload: AppShellPayload | null): ActiveUserProfile => {
if (!payload?.admin) {
return fallbackActiveUserProfile;
}
const organizationName = payload.installation?.name || payload.organizations[0]?.name || fallbackActiveServer.name;
const departmentName = payload.departments[0]?.name;
return {
name: payload.admin.displayName,
email: payload.admin.email,
roleLabel: payload.admin.isInstanceAdmin ? "Instance admin" : "Member",
contextLabel: departmentName ? `${organizationName}${departmentName}` : organizationName,
};
};
@@ -0,0 +1,97 @@
// Path: Frontend/src/components/app-shell/data/app-shell.context.tsx
import {
createContext,
createMemo,
createSignal,
onMount,
useContext,
type JSX,
} from "solid-js";
import { resolveAPIBase } from "../../../lib/api";
import {
buildActiveDepartment,
buildActiveProject,
buildActiveServer,
buildActiveUserProfile,
buildDepartmentItems,
buildProjectItems,
buildRailItems,
buildWorkspaceTree,
} from "./app-shell.builders";
import type { AppShellContextValue, AppShellPayload } from "./app-shell.types";
import { normalizeAppShellPayload } from "./app-shell.types";
const AppShellContext = createContext<AppShellContextValue>();
export const AppShellDataProvider = (props: { children: JSX.Element }): JSX.Element => {
const [status, setStatus] = createSignal<"idle" | "loading" | "success" | "error">("idle");
const [error, setError] = createSignal("");
const [payload, setPayload] = createSignal<AppShellPayload | null>(null);
const load = async (): Promise<void> => {
setStatus("loading");
setError("");
try {
const response = await fetch(`${resolveAPIBase()}/app-shell`, {
headers: {
Accept: "application/json",
},
});
const body = (await response.json()) as {
data?: AppShellPayload;
error?: { message?: string } | string;
message?: string;
};
const errorMessage =
typeof body.message === "string"
? body.message
: typeof body.error === "string"
? body.error
: body.error?.message;
if (!response.ok || !body.data) {
throw new Error(errorMessage || "Failed to load app shell state.");
}
setPayload(normalizeAppShellPayload(body.data));
setStatus("success");
} catch (loadError) {
setStatus("error");
setError(loadError instanceof Error ? loadError.message : "Failed to load app shell state.");
}
};
onMount(() => {
void load();
});
const value: AppShellContextValue = {
status,
error,
installation: createMemo(() => payload()?.installation),
railItems: createMemo(() => buildRailItems(payload())),
activeServer: createMemo(() => buildActiveServer(payload())),
activeProject: createMemo(() => buildActiveProject(payload())),
activeDepartment: createMemo(() => buildActiveDepartment(payload())),
projectItems: createMemo(() => buildProjectItems(payload())),
departmentItems: createMemo(() => buildDepartmentItems(payload())),
workspaceTree: createMemo(() => buildWorkspaceTree(payload())),
activeUserProfile: createMemo(() => buildActiveUserProfile(payload())),
reload: load,
};
return <AppShellContext.Provider value={value}>{props.children}</AppShellContext.Provider>;
};
export const useAppShellData = (): AppShellContextValue => {
const context = useContext(AppShellContext);
if (!context) {
throw new Error("useAppShellData must be used within AppShellDataProvider");
}
return context;
};
@@ -0,0 +1,130 @@
// Path: Frontend/src/components/app-shell/data/app-shell.types.ts
import type { Accessor } from "solid-js";
import type {
ActiveDepartment,
ActiveProject,
ActiveServer,
ActiveUserProfile,
DepartmentItem,
ProjectItem,
RailItem,
WorkspaceTreeNode,
} from "./shell.data";
export type AppShellInstallation = {
id: string;
name: string;
mode: "personal" | "organizational" | string;
access: string;
protocol: string;
host: string;
isBootstrapped: boolean;
materializationStatus: "not_started" | "pending" | "running" | "succeeded" | "failed" | string;
materializationError?: string;
};
export type AppShellAdmin = {
id: string;
email: string;
displayName: string;
isInstanceAdmin: boolean;
homeTitle: string;
};
export type AppShellOrganization = {
id: string;
name: string;
slug: string;
};
export type AppShellDepartment = {
id: string;
organizationId: string;
name: string;
slug: string;
};
export type AppShellTeam = {
id: string;
organizationId: string;
departmentId?: string;
name: string;
slug: string;
};
export type AppShellProject = {
id: string;
organizationId: string;
departmentId?: string;
teamId?: string;
name: string;
slug: string;
};
export type AppShellWorkspace = {
id: string;
organizationId: string;
name: string;
slug: string;
kind: "organization" | "department" | "team" | "project" | string;
departmentId?: string;
teamId?: string;
projectId?: string;
};
export type AppShellPayload = {
installation?: AppShellInstallation;
admin?: AppShellAdmin;
organizations: AppShellOrganization[];
departments: AppShellDepartment[];
teams: AppShellTeam[];
projects: AppShellProject[];
workspaces: AppShellWorkspace[];
};
export type AppShellContextValue = {
status: Accessor<"idle" | "loading" | "success" | "error">;
error: Accessor<string>;
installation: Accessor<AppShellInstallation | undefined>;
railItems: Accessor<readonly RailItem[]>;
activeServer: Accessor<ActiveServer>;
activeProject: Accessor<ActiveProject>;
activeDepartment: Accessor<ActiveDepartment>;
projectItems: Accessor<readonly ProjectItem[]>;
departmentItems: Accessor<readonly DepartmentItem[]>;
workspaceTree: Accessor<readonly WorkspaceTreeNode[]>;
activeUserProfile: Accessor<ActiveUserProfile>;
reload: () => Promise<void>;
};
export const normalizeInstallation = (
installation: AppShellInstallation | null | undefined,
): AppShellInstallation | undefined => {
if (!installation) {
return undefined;
}
const materializationStatus = installation.materializationStatus?.trim()
? installation.materializationStatus
: installation.isBootstrapped
? "succeeded"
: "not_started";
const materializationError = installation.materializationError?.trim() || undefined;
return {
...installation,
materializationStatus,
materializationError,
};
};
export const normalizeAppShellPayload = (payload: AppShellPayload | null | undefined): AppShellPayload => ({
installation: normalizeInstallation(payload?.installation),
admin: payload?.admin,
organizations: Array.isArray(payload?.organizations) ? payload.organizations : [],
departments: Array.isArray(payload?.departments) ? payload.departments : [],
teams: Array.isArray(payload?.teams) ? payload.teams : [],
projects: Array.isArray(payload?.projects) ? payload.projects : [],
workspaces: Array.isArray(payload?.workspaces) ? payload.workspaces : [],
});
@@ -0,0 +1,174 @@
// Path: Frontend/src/components/app-shell/data/shell.context-menu.ts
import { getWorkspaceCreateActions, getWorkspaceItemTypeDefinition } from "./shell.registry";
import type {
ActiveProject,
ProjectContextMenuAction,
ProjectContextMenuSection,
ProjectItem,
ProjectMenuTarget,
WorkspaceContextMenuSection,
WorkspaceContextMenuTarget,
WorkspaceStaticItem,
WorkspaceTreeNode,
} from "./shell.types";
export { getWorkspaceContextMenuEyebrow } from "./shell.registry";
export const createWorkspaceSurfaceTarget = (workspace: ActiveProject): WorkspaceContextMenuTarget => ({
id: `workspace-${workspace.id}`,
label: workspace.name,
kind: "workspace",
});
export const createWorkspaceStaticTarget = (item: WorkspaceStaticItem): WorkspaceContextMenuTarget => ({
id: item.id,
label: item.label,
kind: item.contextKind,
});
export const createWorkspaceTreeTarget = (node: WorkspaceTreeNode): WorkspaceContextMenuTarget => ({
id: node.id,
label: node.label,
...(node.kind === "folder"
? { kind: "folder" as const }
: {
kind: "item" as const,
itemType: node.itemType,
}),
});
export const getWorkspaceContextMenuSections = (
target: WorkspaceContextMenuTarget,
): readonly WorkspaceContextMenuSection[] => {
const createActions = getWorkspaceCreateActions();
const createSubmenuAction = {
id: "create",
label: "Create",
children: createActions,
} as const;
switch (target.kind) {
case "workspace":
return [
{ id: "create", label: undefined, items: [createSubmenuAction] },
{
id: "workspace",
label: undefined,
items: [
{ id: "rename-workspace", label: "Rename workspace", shortcut: { key: "enter" } },
{ id: "copy-workspace-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } },
],
},
] as const;
case "home":
return [
{ id: "create", label: undefined, items: [createSubmenuAction] },
{ id: "workspace", label: undefined, items: [{ id: "open-home", label: "Open home", shortcut: { key: "enter" } }] },
] as const;
case "settings":
return [
{
id: "settings",
label: undefined,
items: [
{ id: "open-settings", label: "Open settings", shortcut: { key: "enter" } },
{ id: "copy-settings-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } },
],
},
] as const;
case "folder":
return [
{ id: "open", items: [{ id: "open-folder", label: "Open folder", shortcut: { key: "enter" } }, { id: "rename-folder", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } }] },
{ id: "create", label: undefined, items: [createSubmenuAction] },
{
id: "organize",
label: undefined,
items: [
{ id: "move-folder", label: "Move…", shortcut: { modifiers: ["meta"], key: "m" } },
{ id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
],
},
] as const;
case "item": {
const definition = getWorkspaceItemTypeDefinition(target.itemType);
const actionPrefix = definition.actionPrefix;
const nounLabel = definition.noun;
return [
{
id: `${actionPrefix}-primary`,
items: [
{ id: `open-${actionPrefix}`, label: `Open ${nounLabel}`, shortcut: { key: "enter" } },
{ id: `rename-${actionPrefix}`, label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
],
},
{
id: "organize",
label: undefined,
items: [
{ id: `move-${actionPrefix}`, label: "Move…", shortcut: { modifiers: ["meta"], key: "m" } },
{ id: `delete-${actionPrefix}`, label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
],
},
] as const;
}
}
};
const getProjectCreateActions = (): readonly ProjectContextMenuAction[] =>
[
{ id: "new-project", label: "New project" },
{ id: "new-folder", label: "New folder" },
] as const;
const getProjectFolderDangerActions = (): readonly ProjectContextMenuAction[] =>
[
{ id: "rename-folder", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
{ id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
] as const;
export const createProjectSurfaceTarget = (label = "Projects"): ProjectMenuTarget => ({
id: "project-surface",
label,
kind: "surface",
});
export const createProjectFolderTarget = (id: string, label: string): ProjectMenuTarget => ({
id,
label,
kind: "folder",
});
export const createProjectTarget = (project: ProjectItem): ProjectMenuTarget => ({
id: project.id,
label: project.name,
kind: "project",
});
export const getProjectContextMenuEyebrow = (target: ProjectMenuTarget): string => {
switch (target.kind) {
case "surface":
return "Projects";
case "folder":
return "Folder";
case "project":
return "Project";
}
};
export const getProjectContextMenuSections = (target: ProjectMenuTarget): readonly ProjectContextMenuSection[] => {
const createActions = getProjectCreateActions();
switch (target.kind) {
case "surface":
return [{ id: "create", items: createActions }] as const;
case "folder":
return [
{ id: "create", items: createActions },
{ id: "organize", items: getProjectFolderDangerActions() },
] as const;
case "project":
return [{ id: "create", items: createActions }] as const;
}
};
@@ -0,0 +1,6 @@
// Path: Frontend/src/components/app-shell/data/shell.data.ts
export * from "./shell.types";
export * from "./shell.registry";
export * from "./shell.scaffold";
export * from "./shell.context-menu";
@@ -0,0 +1,100 @@
// Path: Frontend/src/components/app-shell/data/shell.registry.ts
import { FileText, LayoutGrid } from "../../../lib/icons";
import type {
ShellIcon,
WorkspaceContextMenuAction,
WorkspaceContextMenuTarget,
WorkspaceItemTypeDefinition,
WorkspaceItemTypeId,
WorkspaceTreeNode,
} from "./shell.types";
export const firstPartyWorkspaceItemTypes: readonly WorkspaceItemTypeDefinition[] = [
{
id: "core.doc",
label: "Doc",
shortLabel: "Doc",
icon: FileText,
noun: "doc",
actionPrefix: "doc",
defaultCreateLabel: "New doc",
includeInWorkspaceCreate: true,
description: "Rich text documents and notes.",
},
{
id: "core.board.kanban",
label: "Kanban board",
shortLabel: "Board",
icon: LayoutGrid,
noun: "board",
actionPrefix: "board",
defaultCreateLabel: "New board",
includeInWorkspaceCreate: true,
description: "Default board-style workspace item.",
},
{
id: "core.board.list",
label: "List board",
shortLabel: "Board",
icon: LayoutGrid,
noun: "board",
actionPrefix: "list-board",
defaultCreateLabel: "New list board",
description: "Alternate first-party board view prepared for the future registry.",
},
] as const;
const workspaceItemTypeMap = new Map<WorkspaceItemTypeId, WorkspaceItemTypeDefinition>(
firstPartyWorkspaceItemTypes.map((definition) => [definition.id, definition]),
);
const createUnknownWorkspaceItemTypeDefinition = (
itemType: WorkspaceItemTypeId,
): WorkspaceItemTypeDefinition => ({
id: itemType,
label: "Item",
shortLabel: "Item",
icon: FileText,
noun: "item",
actionPrefix: "item",
defaultCreateLabel: "New item",
description: "Fallback definition for unknown or future workspace item types.",
});
export const getWorkspaceItemTypeDefinition = (itemType: WorkspaceItemTypeId): WorkspaceItemTypeDefinition => {
return workspaceItemTypeMap.get(itemType) ?? createUnknownWorkspaceItemTypeDefinition(itemType);
};
export const getWorkspaceNodeIcon = (node: WorkspaceTreeNode): ShellIcon =>
node.kind === "folder" ? node.icon : getWorkspaceItemTypeDefinition(node.itemType).icon;
export const getWorkspaceCreateActions = (): readonly WorkspaceContextMenuAction[] => [
{ id: "new-folder", label: "New folder", shortcut: { modifiers: ["alt"], key: "f" } },
...firstPartyWorkspaceItemTypes
.filter((definition) => definition.includeInWorkspaceCreate)
.map((definition) => ({
id: `create-${definition.actionPrefix}`,
label: definition.defaultCreateLabel,
shortcut:
definition.id === "core.board.kanban"
? ({ modifiers: ["alt"], key: "b" } as const)
: definition.id === "core.doc"
? ({ modifiers: ["alt"], key: "d" } as const)
: undefined,
})),
];
export const getWorkspaceContextMenuEyebrow = (target: WorkspaceContextMenuTarget): string => {
switch (target.kind) {
case "workspace":
case "home":
return "Workspace";
case "settings":
return "Configuration";
case "folder":
return "Folder";
case "item":
return getWorkspaceItemTypeDefinition(target.itemType).shortLabel;
}
};
@@ -0,0 +1,222 @@
// Path: Frontend/src/components/app-shell/data/shell.scaffold.ts
import {
Bell,
CircleHelp,
FileText,
Folder,
Home,
Keyboard,
LayoutGrid,
ListCollapse,
LogOut,
Repeat,
Search,
Settings,
Shield,
User,
} from "../../../lib/icons";
import type {
ActiveDepartment,
ActiveProject,
ActiveServer,
ActiveUserProfile,
DepartmentItem,
MobileBottomNavItem,
NotificationItem,
ProfileMenuSection,
ProjectItem,
RailItem,
ServerDockAction,
SidebarHeaderAction,
TopBarAction,
WorkspaceStaticItem,
WorkspaceTreeNode,
} from "./shell.types";
export const personalDockActions: readonly ServerDockAction[] = [
{ id: "account", label: "Account", icon: User },
{ id: "settings", label: "Settings", icon: Settings },
] as const;
export const organizationAdminDockActions: readonly ServerDockAction[] = [
{ id: "members", label: "Members", icon: User },
{ id: "server", label: "Server", icon: Settings },
] as const;
export const railItems: readonly RailItem[] = [
{ id: "personal-server", label: "Personal Server Name", abbreviation: "P", kind: "personal" },
{ id: "organization-server", label: "Organization Name", abbreviation: "O", kind: "organization", active: true },
{ id: "design-review", label: "Design Review", abbreviation: "D", kind: "organization" },
] as const;
export const activeServer: ActiveServer = {
id: "organization-server",
name: "Organization Name",
abbreviation: "O",
kind: "organization",
connectedLabel: "12 connected",
dockActions: organizationAdminDockActions,
};
export const activeProject: ActiveProject = {
id: "general",
name: "General",
};
export const activeDepartment: ActiveDepartment = {
id: "product",
name: "Product",
teamName: "Design Systems",
};
export const projectItems: readonly ProjectItem[] = [
{
id: "general",
name: "General",
description: "Default shared project",
groupLabel: "Shared space",
parentLabel: "Workspace home",
meta: "1 workspace",
active: true,
},
{
id: "operations",
name: "Operations",
description: "Cross-team planning and delivery",
groupLabel: "Team folders",
parentLabel: "Shared Services",
meta: "2 workspaces",
},
{
id: "hiring",
name: "Hiring",
description: "Candidate pipeline and interview loops",
groupLabel: "Team folders",
parentLabel: "People Ops",
meta: "1 workspace",
},
] as const;
export const departmentItems: readonly DepartmentItem[] = [
{ id: "product", name: "Product", teams: ["Design Systems", "Research Ops"], active: true },
{ id: "engineering", name: "Engineering", teams: ["Platform", "Realtime Collaboration"] },
{ id: "operations", name: "Operations", teams: ["Shared Services", "People Ops"] },
] as const;
export const workspaceStaticItems: readonly WorkspaceStaticItem[] = [
{ id: "home", label: "Home", icon: Home, active: true, contextKind: "home" },
{ id: "workspace-settings", label: "Settings", icon: Settings, contextKind: "settings" },
] as const;
export const workspaceTree: readonly WorkspaceTreeNode[] = [
{
id: "product-workspace",
label: "Product",
kind: "folder",
icon: Folder,
children: [
{ id: "roadmap-board", label: "Roadmap", kind: "item", itemType: "core.board.kanban", active: true },
{ id: "launch-brief", label: "Launch Brief", kind: "item", itemType: "core.doc" },
{
id: "research-folder",
label: "Research",
kind: "folder",
icon: Folder,
children: [
{ id: "interviews-doc", label: "Interviews", kind: "item", itemType: "core.doc" },
{ id: "signals-board", label: "Signals", kind: "item", itemType: "core.board.kanban", meta: "2" },
],
},
],
},
{
id: "design-folder",
label: "Design",
kind: "folder",
icon: Folder,
children: [
{ id: "system-doc", label: "Design System", kind: "item", itemType: "core.doc" },
{ id: "review-board", label: "Review Queue", kind: "item", itemType: "core.board.kanban" },
],
},
{ id: "general-notes", label: "General Notes", kind: "item", itemType: "core.doc" },
] as const;
export const workspaceSidebarHeaderActions: readonly SidebarHeaderAction[] = [
{ id: "search-workspace", label: "Search workspace", icon: Search },
{ id: "toggle-workspace-folders", label: "Collapse all folders", icon: ListCollapse },
] as const;
export const mobileBottomNavItems: readonly MobileBottomNavItem[] = [
{ id: "home", label: "Home", icon: Home, active: true },
{ id: "search", label: "Search", icon: Search },
{ id: "browse", label: "Browse", icon: Folder },
] as const;
export const topBarActions: readonly TopBarAction[] = [{ id: "search", label: "Search", icon: Search }] as const;
export const notificationItems: readonly NotificationItem[] = [
{
id: "comment-design-systems",
title: "New comment on Design Systems",
contextLabel: "Product • Review thread updated",
timeLabel: "2m ago",
unread: true,
},
{
id: "sprint-platform",
title: "Sprint updated in Platform",
contextLabel: "Engineering • Scope changed",
timeLabel: "15m ago",
unread: true,
},
{
id: "member-joined",
title: "New member joined Operations",
contextLabel: "Organization Name • Access granted",
timeLabel: "1h ago",
},
{
id: "daily-summary",
title: "Daily summary is ready",
contextLabel: "General • 8 updates across boards",
timeLabel: "Today, 8:00 AM",
},
] as const;
export const unreadNotificationCount = notificationItems.filter((item) => item.unread).length;
export const activeUserProfile: ActiveUserProfile = {
name: "Demo Account",
email: "demo@moku.work",
roleLabel: "Founder · Product",
contextLabel: "Organization Name • Design Systems",
};
export const profileMenuSections: readonly ProfileMenuSection[] = [
{
id: "account",
items: [
{ id: "profile", label: "Profile", icon: User },
{ id: "account-settings", label: "Account Settings", icon: Settings },
{ id: "notifications", label: "Notifications", icon: Bell },
{ id: "security", label: "Security", icon: Shield },
],
},
{
id: "preferences",
items: [
{ id: "keyboard-shortcuts", label: "Keyboard Shortcuts", icon: Keyboard },
{ id: "theme-preferences", label: "Theme Preferences", icon: Settings },
{ id: "help-support", label: "Help & Support", icon: CircleHelp },
],
},
{
id: "session",
items: [
{ id: "switch-account", label: "Switch Account", icon: Repeat },
{ id: "sign-out", label: "Sign Out", icon: LogOut, tone: "danger" },
],
},
] as const;
@@ -0,0 +1,233 @@
// Path: Frontend/src/components/app-shell/data/shell.types.ts
import type { Component } from "solid-js";
export type ShellIconProps = {
class?: string;
size?: number;
strokeWidth?: number;
};
export type ShellIcon = Component<ShellIconProps>;
export type RailItem = {
id: string;
label: string;
abbreviation: string;
kind: "personal" | "organization";
active?: boolean;
};
export type ServerDockAction = {
id: string;
label: string;
icon: ShellIcon;
};
export type ActiveServer = {
id: string;
name: string;
abbreviation: string;
kind: "personal" | "organization";
connectedLabel?: string;
subtitle?: string;
dockActions: readonly ServerDockAction[];
};
export type ActiveProject = {
id: string;
name: string;
};
export type ActiveDepartment = {
id: string;
name: string;
teamName: string;
};
export type DepartmentItem = {
id: string;
name: string;
teams: readonly string[];
active?: boolean;
};
export type ProjectItem = {
id: string;
name: string;
description: string;
groupLabel?: string;
parentLabel?: string;
meta?: string;
active?: boolean;
};
export type ProjectMenuTarget =
| {
id: string;
label: string;
kind: "surface";
}
| {
id: string;
label: string;
kind: "folder";
}
| {
id: string;
label: string;
kind: "project";
};
export type ProjectContextMenuAction = {
id: string;
label: string;
tone?: "default" | "danger";
shortcut?: WorkspaceContextMenuShortcut;
children?: readonly ProjectContextMenuAction[];
};
export type ProjectContextMenuSection = {
id: string;
label?: string;
items: readonly ProjectContextMenuAction[];
};
export type SidebarItem = {
id: string;
label: string;
icon: ShellIcon;
active?: boolean;
meta?: string;
};
export type WorkspaceStaticKind = "workspace" | "home" | "settings";
export type WorkspaceItemTypeId = string;
export type WorkspaceStaticItem = SidebarItem & {
contextKind: WorkspaceStaticKind;
};
export type WorkspaceFolderNode = {
id: string;
path?: string;
label: string;
kind: "folder";
icon: ShellIcon;
active?: boolean;
meta?: string;
children?: readonly WorkspaceTreeNode[];
};
export type WorkspaceItemNode = {
id: string;
path?: string;
label: string;
kind: "item";
itemType: WorkspaceItemTypeId;
active?: boolean;
meta?: string;
children?: undefined;
};
export type WorkspaceTreeNode = WorkspaceFolderNode | WorkspaceItemNode;
export type WorkspaceItemTypeDefinition = {
id: WorkspaceItemTypeId;
label: string;
shortLabel: string;
icon: ShellIcon;
noun: string;
actionPrefix: string;
defaultCreateLabel: string;
includeInWorkspaceCreate?: boolean;
description?: string;
};
export type SidebarHeaderAction = {
id: string;
label: string;
icon: ShellIcon;
};
export type TopBarAction = {
id: string;
label: string;
icon: ShellIcon;
};
export type MobileBottomNavItem = {
id: string;
label: string;
icon: ShellIcon;
active?: boolean;
};
export type WorkspaceContextMenuTarget =
| {
id: string;
label: string;
kind: WorkspaceStaticKind;
}
| {
id: string;
label: string;
kind: "folder";
}
| {
id: string;
label: string;
kind: "item";
itemType: WorkspaceItemTypeId;
};
export type WorkspaceContextMenuAction = {
id: string;
label: string;
tone?: "default" | "danger";
shortcut?: WorkspaceContextMenuShortcut;
children?: readonly WorkspaceContextMenuAction[];
};
export type WorkspaceContextMenuShortcutModifier = "meta" | "alt" | "shift";
export type WorkspaceContextMenuShortcutKey = "b" | "c" | "d" | "delete" | "enter" | "f" | "m" | "r";
export type WorkspaceContextMenuShortcut = {
modifiers?: readonly WorkspaceContextMenuShortcutModifier[];
key: WorkspaceContextMenuShortcutKey;
};
export type WorkspaceContextMenuSection = {
id: string;
label?: string;
items: readonly WorkspaceContextMenuAction[];
};
export type NotificationItem = {
id: string;
title: string;
contextLabel: string;
timeLabel: string;
unread?: boolean;
};
export type ProfileMenuAction = {
id: string;
label: string;
icon: ShellIcon;
tone?: "default" | "danger";
};
export type ProfileMenuSection = {
id: string;
items: readonly ProfileMenuAction[];
};
export type ActiveUserProfile = {
name: string;
email: string;
roleLabel: string;
contextLabel: string;
};
@@ -0,0 +1,173 @@
// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.data.ts
export type BootstrapStepKey = "persona" | "instance" | "mode" | "admin" | "structure";
export type BootstrapStepDefinition = {
id: BootstrapStepKey;
title: string;
buttonLabel: string;
};
export type InstanceForm = {
protocol: "http" | "https";
access: "local" | "remote";
host: string;
};
export type ModeForm = {
mode: "personal" | "organizational";
name: string;
};
export type AdminForm = {
displayName: string;
email: string;
password: string;
};
export type StructureForm = {
departmentName: string;
teamName: string;
projectName: string;
};
export type BootstrapPersona = "personal" | "enthusiast" | "team" | "organization";
export type BootstrapPersonaDefinition = {
id: BootstrapPersona;
title: string;
isAvailable: boolean;
bestFor: string;
bullets: readonly string[];
defaults: {
protocol: InstanceForm["protocol"];
access: InstanceForm["access"];
host: string;
mode: ModeForm["mode"];
namePlaceholder: string;
departmentName: string;
teamName: string;
projectName: string;
};
};
export const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [
{ id: "persona", title: "What are you setting up your server for?", buttonLabel: "Continue" },
{ id: "instance", title: "Connection details", buttonLabel: "Save and continue" },
{ id: "mode", title: "Server identity", buttonLabel: "Save and continue" },
{ id: "admin", title: "Admin account", buttonLabel: "Save and continue" },
{ id: "structure", title: "Initial structure", buttonLabel: "Submit" },
];
export const defaultInstanceForm: InstanceForm = {
protocol: "http",
access: "local",
host: "localhost",
};
export const defaultModeForm: ModeForm = {
mode: "personal",
name: "",
};
export const defaultAdminForm: AdminForm = {
displayName: "Admin",
email: "admin@example.com",
password: "",
};
export const personalStructureDefaults = {
departmentName: "Default",
teamName: "Personal",
};
export const organizationalStructureDefaults = {
departmentName: "Department",
teamName: "Team",
};
export const defaultStructureForm: StructureForm = {
...personalStructureDefaults,
projectName: "Project",
};
export const bootstrapPersonaDefinitions: readonly BootstrapPersonaDefinition[] = [
{
id: "personal",
title: "Personal",
isAvailable: true,
bestFor: "Best for low maintenance, personal use",
bullets: ["Preconfigured for personal use", "Low setup time", "Easy to manage"],
defaults: {
protocol: "http",
access: "local",
host: "localhost",
mode: "personal",
namePlaceholder: "Personal Server",
departmentName: "Default",
teamName: "Personal",
projectName: "Project",
},
},
{
id: "enthusiast",
title: "Self Hosted Enthusiast",
isAvailable: true,
bestFor: "Best for people who want to customize their server",
bullets: ["Networking knowledge", "Comfortable with tinkering", "Willing to troubleshoot issues"],
defaults: {
protocol: "https",
access: "remote",
host: "moku.local",
mode: "personal",
namePlaceholder: "Personal Server",
departmentName: "Default",
teamName: "Personal",
projectName: "Project",
},
},
{
id: "team",
title: "Team",
isAvailable: true,
bestFor: "Best for low maintenance but for small team",
bullets: ["Built-in collaboration with low setup time", "Keeps the shared structure simple", "Good for a small product, design, or delivery team"],
defaults: {
protocol: "http",
access: "local",
host: "localhost",
mode: "organizational",
namePlaceholder: "Team Server",
departmentName: "Default",
teamName: "Core Team",
projectName: "Project",
},
},
{
id: "organization",
title: "Organization",
isAvailable: true,
bestFor: "Best for multiple teams and shared ownership",
bullets: ["SME to Organization", "Fine grained access control", "Better fit for teams with multiple departments"],
defaults: {
protocol: "https",
access: "remote",
host: "workspace.example.com",
mode: "organizational",
namePlaceholder: "Organization server name",
departmentName: "Operations",
teamName: "Platform Team",
projectName: "Moku",
},
},
];
export const workspaceHomeFieldTooltips = {
protocol: "Usually people use http for a local-only setup and https when the server will be reached over a domain or reverse proxy.",
access: "Usually people use local when Moku is only reached on the same machine or LAN, and remote when they plan to reach it from another network or public domain.",
host: "Examples people usually set here are localhost, moku.local, or a real domain like workspace.example.com depending on how they plan to reach the server.",
serverName: "This is the friendly name people usually give the server itself, for example Personal Server, Ronald's Server, Homelab, Studio, or Workspace.",
department: "Departments are the highest-level grouping for work. People usually use names like Default, Operations, Product, Design, or Engineering.",
team: "Teams sit inside a department. Common examples are Personal, Platform Team, Core Team, Delivery, or Design Systems.",
project: "Projects are the workspace or initiative people work inside. Common examples are Project, Shared Workspace, Moku, Client Portal, or Website Redesign.",
} as const;
@@ -0,0 +1,469 @@
// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.hook.ts
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import { createStore } from "solid-js/store";
import {
bootstrapPersonaDefinitions,
bootstrapStepDefinitions,
defaultAdminForm,
defaultInstanceForm,
defaultModeForm,
defaultStructureForm,
organizationalStructureDefaults,
personalStructureDefaults,
type AdminForm,
type BootstrapPersona,
type BootstrapPersonaDefinition,
type BootstrapStepDefinition,
type BootstrapStepKey,
type InstanceForm,
type ModeForm,
type StructureForm,
} from "./BootstrapWizard.data";
import { submitBootstrapStepRequest } from "./bootstrapWizard.api";
type AppShellBootstrapAdapter = {
installation: () => { isBootstrapped?: boolean; materializationStatus?: string; materializationError?: string } | undefined;
status: () => string;
reload: () => Promise<void>;
};
export type BootstrapSubmissionState = {
status: "idle" | "submitting" | "success" | "error";
error: string;
};
export type MaterializationState = "not_started" | "pending" | "running" | "succeeded" | "failed";
export type FieldTooltipState = {
text: string;
left: number;
top: number;
placement: "top" | "bottom";
};
const initialSubmissionState = (): BootstrapSubmissionState => ({
status: "idle",
error: "",
});
const materializationPollIntervalMs = 2000;
export const useBootstrapWizard = (appShellData: AppShellBootstrapAdapter) => {
const [instanceForm, setInstanceForm] = createStore<InstanceForm>({ ...defaultInstanceForm });
const [modeForm, setModeForm] = createStore<ModeForm>({ ...defaultModeForm });
const [adminForm, setAdminForm] = createStore<AdminForm>({ ...defaultAdminForm });
const [structureForm, setStructureForm] = createStore<StructureForm>({ ...defaultStructureForm });
const [selectedPersona, setSelectedPersona] = createSignal<BootstrapPersona>("enthusiast");
const [hasChosenPersona, setHasChosenPersona] = createSignal(false);
const [stepState, setStepState] = createStore<Record<BootstrapStepKey, BootstrapSubmissionState>>({
persona: initialSubmissionState(),
instance: initialSubmissionState(),
mode: initialSubmissionState(),
admin: initialSubmissionState(),
structure: initialSubmissionState(),
});
const [isBootstrapStateResolved, setIsBootstrapStateResolved] = createSignal(false);
const [isBootstrapComplete, setIsBootstrapComplete] = createSignal(false);
const [isWizardOpen, setIsWizardOpen] = createSignal(false);
const [isFinishingBootstrapFlow, setIsFinishingBootstrapFlow] = createSignal(false);
const [currentStepIndex, setCurrentStepIndex] = createSignal(0);
const [fieldTooltip, setFieldTooltip] = createSignal<FieldTooltipState | null>(null);
const installation = createMemo(() => appShellData.installation());
const materializationState = createMemo<MaterializationState>(() => {
const status = installation()?.materializationStatus;
switch (status) {
case "pending":
case "running":
case "failed":
case "succeeded":
case "not_started":
return status;
default:
return installation()?.isBootstrapped ? "succeeded" : "not_started";
}
});
const isBootstrapPersisted = createMemo(() => installation()?.isBootstrapped ?? false);
const isMaterializationInFlight = createMemo(() => materializationState() === "pending" || materializationState() === "running");
const hasMaterializationFailed = createMemo(() => materializationState() === "failed");
const showBootstrapFinishingState = createMemo(() => isFinishingBootstrapFlow() && (isMaterializationInFlight() || hasMaterializationFailed()));
const materializationStatusLabel = createMemo(() => {
switch (materializationState()) {
case "pending":
return "Materialization queued";
case "running":
return "Materialization running";
case "failed":
return "Materialization failed";
case "succeeded":
return "Ready";
default:
return "Not started";
}
});
const materializationMessage = createMemo(() => {
if (isMaterializationInFlight()) {
return "Your bootstrap is saved. The worker is still creating the POSIX skeleton and rebuilding the app shell index.";
}
if (hasMaterializationFailed()) {
return installation()?.materializationError || "Bootstrap saved, but background materialization did not finish cleanly.";
}
return "";
});
const personaDefinition = createMemo<BootstrapPersonaDefinition>(() => bootstrapPersonaDefinitions.find((persona) => persona.id === selectedPersona()) ?? bootstrapPersonaDefinitions[0]!);
const selectedPersonaIsAvailable = createMemo(() => personaDefinition().isAvailable);
const usesCondensedBootstrapFlow = createMemo(() => selectedPersona() === "personal" || selectedPersona() === "team");
const activeBootstrapSteps = createMemo<readonly BootstrapStepDefinition[]>(() => {
if (usesCondensedBootstrapFlow()) {
return [bootstrapStepDefinitions[0]!, bootstrapStepDefinitions[2]!, bootstrapStepDefinitions[3]!];
}
return bootstrapStepDefinitions;
});
const activeWizardSteps = createMemo(() => activeBootstrapSteps().filter((step) => step.id !== "persona"));
createEffect(() => {
const defaults = personaDefinition().defaults;
setInstanceForm({
protocol: defaults.protocol,
access: defaults.access,
host: defaults.host,
});
setModeForm("mode", defaults.mode);
setStructureForm({
departmentName: defaults.departmentName,
teamName: defaults.teamName,
projectName: defaults.projectName,
});
});
createEffect(() => {
if (modeForm.mode === "personal") {
setStructureForm("departmentName", personalStructureDefaults.departmentName);
setStructureForm("teamName", personalStructureDefaults.teamName);
return;
}
if (structureForm.departmentName === personalStructureDefaults.departmentName) {
setStructureForm("departmentName", organizationalStructureDefaults.departmentName);
}
if (structureForm.teamName === personalStructureDefaults.teamName) {
setStructureForm("teamName", organizationalStructureDefaults.teamName);
}
});
const resetWizardState = (): void => {
setSelectedPersona("enthusiast");
setHasChosenPersona(false);
setInstanceForm({ ...defaultInstanceForm });
setModeForm({ ...defaultModeForm });
setAdminForm({ ...defaultAdminForm });
setStructureForm({ ...defaultStructureForm });
setStepState({
persona: initialSubmissionState(),
instance: initialSubmissionState(),
mode: initialSubmissionState(),
admin: initialSubmissionState(),
structure: initialSubmissionState(),
});
setCurrentStepIndex(0);
setIsFinishingBootstrapFlow(false);
};
createEffect(() => {
const shellStatus = appShellData.status();
if (shellStatus === "idle" || shellStatus === "loading") {
return;
}
if (shellStatus !== "success") {
return;
}
if (!isBootstrapPersisted()) {
setIsFinishingBootstrapFlow(false);
resetWizardState();
}
setIsBootstrapComplete(isBootstrapPersisted() && !isMaterializationInFlight());
setIsWizardOpen(!isBootstrapPersisted() || showBootstrapFinishingState());
setIsBootstrapStateResolved(true);
});
createEffect(() => {
if (!isFinishingBootstrapFlow()) {
return;
}
if (isMaterializationInFlight() || hasMaterializationFailed()) {
return;
}
setIsFinishingBootstrapFlow(false);
setIsWizardOpen(false);
});
createEffect(() => {
if (!isBootstrapPersisted() || !isMaterializationInFlight()) {
return;
}
let cancelled = false;
let timeoutId: number | undefined;
const scheduleReload = (): void => {
timeoutId = window.setTimeout(async () => {
if (cancelled) {
return;
}
await appShellData.reload();
if (!cancelled && isBootstrapPersisted() && isMaterializationInFlight()) {
scheduleReload();
}
}, materializationPollIntervalMs);
};
scheduleReload();
onCleanup(() => {
cancelled = true;
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
});
});
const bootstrapNamePlaceholder = (): string => personaDefinition().defaults.namePlaceholder;
const bootstrapStepCount = createMemo(() => activeWizardSteps().length);
const currentStep = createMemo<BootstrapStepDefinition>(() => activeBootstrapSteps()[currentStepIndex()] ?? activeBootstrapSteps()[0] ?? bootstrapStepDefinitions[0]!);
const currentWizardStepIndex = createMemo(() => {
const visibleIndex = activeWizardSteps().findIndex((step) => step.id === currentStep().id);
return visibleIndex >= 0 ? visibleIndex : 0;
});
const wizardProgressPercent = createMemo(() => {
const totalSteps = bootstrapStepCount();
const activeIndex = Math.max(currentWizardStepIndex(), 0);
if (totalSteps <= 1) {
return 100;
}
return (activeIndex / (totalSteps - 1)) * 100;
});
const wizardProgressFillWidth = createMemo(() => {
if (currentStepIndex() <= 0) {
return `${wizardProgressPercent()}%`;
}
return `calc(${wizardProgressPercent()}% + ((var(--control-size-md) - var(--space-2)) / 2))`;
});
const currentStepState = createMemo<BootstrapSubmissionState>(() => stepState[currentStep().id]);
const isFirstStep = (): boolean => currentStepIndex() === 0;
const isLastStep = (): boolean => currentStepIndex() === activeBootstrapSteps().length - 1;
const canDismissWizard = (): boolean => isBootstrapPersisted() && !isMaterializationInFlight();
createEffect(() => {
setCurrentStepIndex((index) => Math.min(index, activeBootstrapSteps().length - 1));
});
const submitStep = async (step: BootstrapStepKey, payload: unknown): Promise<boolean> => {
setStepState(step, { status: "submitting", error: "" });
try {
await submitBootstrapStepRequest(step, payload);
setStepState(step, {
status: "success",
error: "",
});
return true;
} catch (error) {
setStepState(step, {
status: "error",
error: error instanceof Error ? error.message : `Bootstrap ${step} request failed.`,
});
return false;
}
};
const payloadForStep = (step: BootstrapStepKey): unknown => {
switch (step) {
case "instance":
return instanceForm;
case "mode":
return modeForm;
case "admin":
return adminForm;
case "structure":
return structureForm;
}
};
const applyPersonaSelection = (persona: BootstrapPersona): void => {
const definition = bootstrapPersonaDefinitions.find((candidate) => candidate.id === persona);
if (!definition?.isAvailable) {
return;
}
setSelectedPersona(persona);
setHasChosenPersona(true);
setStepState("persona", {
status: "success",
error: "",
});
setCurrentStepIndex((index) => Math.min(index + 1, activeBootstrapSteps().length - 1));
};
const statusLabel = (state: BootstrapSubmissionState): string => {
switch (state.status) {
case "submitting":
return "Sending";
case "error":
return "Request failed";
default:
return "";
}
};
const submitCurrentStep = async (): Promise<void> => {
const step = currentStep().id;
if (step === "persona") {
applyPersonaSelection(selectedPersona());
return;
}
if (step === "mode" && usesCondensedBootstrapFlow() && stepState.instance.status !== "success") {
const didPersistInstanceDefaults = await submitStep("instance", instanceForm);
if (!didPersistInstanceDefaults) {
return;
}
}
const didSucceed = await submitStep(step, payloadForStep(step));
if (!didSucceed) {
return;
}
if (step === "admin" && usesCondensedBootstrapFlow()) {
const didPersistStructureDefaults = await submitStep("structure", structureForm);
if (!didPersistStructureDefaults) {
return;
}
}
if (isLastStep()) {
await appShellData.reload();
const shouldShowFinishingState = isBootstrapPersisted() && (isMaterializationInFlight() || hasMaterializationFailed());
setIsFinishingBootstrapFlow(shouldShowFinishingState);
setIsBootstrapComplete(isBootstrapPersisted() && !isMaterializationInFlight());
setIsWizardOpen(!isBootstrapPersisted() || shouldShowFinishingState);
setIsBootstrapStateResolved(true);
return;
}
setCurrentStepIndex((index) => Math.min(index + 1, activeBootstrapSteps().length - 1));
};
const showFieldTooltip = (target: HTMLElement, text: string): void => {
const rect = target.getBoundingClientRect();
const placement = rect.top > 96 ? "top" : "bottom";
const viewportPadding = 20;
const left = Math.min(Math.max(rect.left + rect.width / 2, viewportPadding), window.innerWidth - viewportPadding);
const top = placement === "top" ? rect.top - 10 : rect.bottom + 10;
setFieldTooltip({ text, left, top, placement });
};
const hideFieldTooltip = (): void => {
setFieldTooltip(null);
};
const stepStatusLabel = (step: BootstrapStepDefinition): string => {
const state = stepState[step.id];
if (state.status === "success") {
return "Done";
}
if (state.status === "error") {
return "Needs retry";
}
return "";
};
return {
instanceForm,
setInstanceForm,
modeForm,
setModeForm,
adminForm,
setAdminForm,
structureForm,
setStructureForm,
selectedPersona,
setSelectedPersona,
hasChosenPersona,
stepState,
isBootstrapStateResolved,
isBootstrapComplete,
isWizardOpen,
setIsWizardOpen,
isFinishingBootstrapFlow,
setIsFinishingBootstrapFlow,
fieldTooltip,
materializationState,
isMaterializationInFlight,
hasMaterializationFailed,
showBootstrapFinishingState,
materializationStatusLabel,
materializationMessage,
personaDefinition,
selectedPersonaIsAvailable,
usesCondensedBootstrapFlow,
activeWizardSteps,
bootstrapNamePlaceholder,
bootstrapStepCount,
currentStep,
currentWizardStepIndex,
wizardProgressFillWidth,
currentStepState,
isFirstStep,
canDismissWizard,
resetWizardState,
handleCurrentStepSubmit: (event: SubmitEvent & { currentTarget: HTMLFormElement; target: Element }): void => {
event.preventDefault();
void submitCurrentStep();
},
applyPersonaSelection,
statusLabel,
showFieldTooltip,
hideFieldTooltip,
stepStatusLabel,
navigateBack: (): void => {
setCurrentStepIndex((index) => Math.max(index - 1, 0));
},
navigateToVisibleStep: (index: number): void => {
setCurrentStepIndex(index + 1);
},
};
};
@@ -0,0 +1,968 @@
/* Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.module.scss */
.viewport,
.wizardLayer {
--workspace-content-max-width: var(--content-width-wide);
--bootstrap-accent: var(--color-accent-primary, var(--color-primary-2, hsl(272 80% 70%)));
--bootstrap-accent-contrast: var(--color-accent-primary-contrast, white);
}
.viewport {
min-width: 0;
min-height: 0;
display: grid;
align-content: start;
gap: var(--space-5);
padding: var(--space-5) var(--space-6);
}
.workspaceTopBar {
width: 100%;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-2);
min-height: calc(var(--control-size-md) - var(--space-3));
padding: 0;
}
.workspaceTopBarStart,
.workspaceTopBarEnd {
min-width: calc(var(--control-size-md) - 0.5rem);
display: inline-flex;
align-items: center;
}
.workspaceTopBarEnd {
justify-content: flex-end;
}
.workspaceTopBarCenter {
min-width: 0;
display: flex;
justify-content: center;
}
.workspaceBreadcrumb {
@include text-caption;
min-width: 0;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.workspaceCollapseButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: calc(var(--control-size-md) - 0.5rem);
height: calc(var(--control-size-md) - 0.5rem);
border: 1px solid color-mix(in srgb, var(--color-border-strong) 44%, transparent);
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-surface) 94%, transparent);
color: var(--color-text-muted);
box-shadow: var(--shadow-soft);
transition:
background 160ms var(--easing-standard),
color 160ms var(--easing-standard),
border-color 160ms var(--easing-standard),
transform 180ms var(--easing-standard);
}
.workspaceCollapseButton:hover,
.workspaceCollapseButton:focus-visible {
background: var(--color-surface-hover);
border-color: var(--color-border);
color: var(--color-text);
}
.workspaceCollapseButton:hover {
transform: translateY(-1px);
}
.hero {
display: grid;
gap: var(--space-3);
width: 100%;
max-width: var(--workspace-content-max-width);
}
.heroActions {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
}
.heroStatus {
display: grid;
gap: var(--space-2);
justify-items: start;
}
.heroStatusMessage {
max-width: 64ch;
color: var(--color-text-muted);
}
.heroStatusMessage[data-status="failed"] {
color: var(--color-danger-text, var(--color-text));
}
.title {
@include text-display;
font-family: var(--font-family-display);
max-width: 12ch;
}
.description {
max-width: 64ch;
color: var(--color-text-muted);
}
.overviewCard,
.summaryCard,
.wizardSidebarSection,
.wizardStepPanel {
display: grid;
gap: var(--space-2);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
background: var(--color-surface);
box-shadow: var(--shadow-soft);
}
.overviewCard {
width: 100%;
max-width: var(--workspace-content-max-width);
gap: var(--space-3);
}
.summaryGrid {
width: 100%;
max-width: var(--workspace-content-max-width);
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.summaryCard {
min-height: 0;
}
.summaryCardHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.summaryStepNumber,
.wizardStepEyebrow {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.sectionHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
}
.sectionTitle,
.cardTitle,
.wizardTitle,
.sidebarTitle {
@include text-title;
}
.sectionCopy,
.cardCopy,
.cardMeta {
color: var(--color-text-muted);
}
.statusBadge {
@include text-caption;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: calc(var(--control-size-sm) - 0.25rem);
padding: 0 var(--space-2);
border-radius: var(--radius-pill);
border: 1px solid color-mix(in srgb, var(--color-border-strong) 44%, transparent);
background: color-mix(in srgb, var(--color-surface-secondary) 92%, transparent);
color: var(--color-text-muted);
white-space: nowrap;
}
.statusBadge[data-status="submitting"] {
color: var(--bootstrap-accent);
border-color: color-mix(in srgb, var(--bootstrap-accent) 38%, transparent);
}
.statusBadge[data-status="success"] {
color: var(--color-success-text, var(--color-text));
border-color: color-mix(in srgb, var(--color-success-border, var(--color-border)) 64%, transparent);
background: color-mix(in srgb, var(--color-success-surface, var(--color-surface-secondary)) 80%, transparent);
}
.statusBadge[data-status="pending"],
.statusBadge[data-status="running"] {
color: var(--bootstrap-accent);
border-color: color-mix(in srgb, var(--bootstrap-accent) 38%, transparent);
background: color-mix(in srgb, var(--bootstrap-accent) 10%, var(--color-surface-secondary));
}
.statusBadge[data-status="failed"] {
color: var(--color-danger-text, var(--color-text));
border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 68%, transparent);
background: color-mix(in srgb, var(--color-danger-surface, var(--color-surface-secondary)) 80%, transparent);
}
.statusBadge[data-status="error"] {
color: var(--color-danger-text, var(--color-text));
border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 68%, transparent);
background: color-mix(in srgb, var(--color-danger-surface, var(--color-surface-secondary)) 80%, transparent);
}
.endpointMeta {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.endpointMeta span {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
}
.endpointMeta code,
.cardMeta {
@include text-caption;
}
.endpointMeta code {
padding: 0.125rem 0.5rem;
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-surface-secondary) 90%, transparent);
color: var(--color-text);
}
.form {
display: grid;
gap: var(--space-3);
}
.field {
display: grid;
gap: var(--space-1);
}
.fieldLabel {
@include text-caption;
color: var(--color-text-muted);
}
.fieldLabelRow {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.fieldInfoButton {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
background: transparent;
color: var(--color-text-muted);
cursor: help;
outline: none;
}
.fieldInfoButton:hover,
.fieldInfoButton:focus-visible {
color: var(--color-text);
}
.fieldTooltip {
position: fixed;
z-index: calc(var(--z-modal, 1000) + 4);
pointer-events: none;
transform: translateX(-50%);
max-width: min(18rem, calc(100vw - 2rem));
}
.fieldTooltip[data-placement="top"] {
transform: translate(-50%, -100%);
}
.fieldTooltip[data-placement="bottom"] {
transform: translate(-50%, 0);
}
.fieldTooltipBubble {
position: relative;
padding: 0.45rem 0.6rem;
border: 1px solid color-mix(in srgb, var(--color-border-strong) 48%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-surface-elevated, var(--color-surface)) 96%, black 4%);
box-shadow: var(--shadow-soft);
color: var(--color-text);
white-space: normal;
text-align: left;
line-height: 1.35;
}
.fieldTooltipBubble::after {
content: "";
position: absolute;
left: 50%;
width: 0.55rem;
height: 0.55rem;
background: color-mix(in srgb, var(--color-surface-elevated, var(--color-surface)) 96%, black 4%);
transform: translateX(-50%) rotate(45deg);
}
.fieldTooltip[data-placement="top"] .fieldTooltipBubble::after {
top: calc(100% - 0.3rem);
border-right: 1px solid color-mix(in srgb, var(--color-border-strong) 48%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--color-border-strong) 48%, transparent);
}
.fieldTooltip[data-placement="bottom"] .fieldTooltipBubble::after {
bottom: calc(100% - 0.3rem);
border-top: 1px solid color-mix(in srgb, var(--color-border-strong) 48%, transparent);
border-left: 1px solid color-mix(in srgb, var(--color-border-strong) 48%, transparent);
}
.fieldHelp {
@include text-caption;
color: var(--color-text-muted);
}
.personaIntro {
display: grid;
gap: var(--space-2);
}
.personaGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.personaCard {
appearance: none;
position: relative;
display: grid;
padding: var(--space-3);
border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
border-radius: var(--radius-xl);
background: color-mix(in srgb, var(--color-surface-secondary) 84%, transparent);
text-align: left;
overflow: hidden;
transition:
transform 180ms var(--easing-standard),
border-color 160ms var(--easing-standard),
background 160ms var(--easing-standard),
box-shadow 160ms var(--easing-standard);
}
.personaCard:hover,
.personaCard:focus-visible,
.personaCard[data-selected="true"] {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--bootstrap-accent) 32%, var(--color-border));
background: color-mix(in srgb, var(--bootstrap-accent) 7%, var(--color-surface));
box-shadow: var(--shadow-soft);
}
.personaCard:focus-visible {
outline: none;
box-shadow:
var(--shadow-soft),
0 0 0 3px color-mix(in srgb, var(--bootstrap-accent) 16%, transparent);
}
.personaCard[data-available="false"] {
opacity: 0.9;
}
.personaCard[data-available="false"]:hover,
.personaCard[data-available="false"]:focus-visible,
.personaCard[data-available="false"][data-selected="true"] {
transform: none;
border-color: color-mix(in srgb, var(--color-border) 88%, transparent);
background: color-mix(in srgb, var(--color-surface-secondary) 84%, transparent);
box-shadow: none;
}
.personaCardMedia {
position: relative;
display: flex;
align-items: center;
justify-content: center;
justify-self: center;
align-self: center;
width: min(100%, 16rem);
aspect-ratio: 1 / 1;
border: 1px dashed color-mix(in srgb, var(--color-border-strong) 40%, transparent);
border-radius: calc(var(--radius-xl) - var(--space-1));
background: linear-gradient(
135deg,
color-mix(in srgb, var(--color-surface-elevated) 92%, transparent),
color-mix(in srgb, var(--color-surface-secondary) 88%, transparent)
);
transition:
filter 180ms var(--easing-standard),
opacity 180ms var(--easing-standard),
transform 180ms var(--easing-standard);
}
.personaCardBody {
position: absolute;
inset: 0;
display: grid;
align-content: space-between;
gap: var(--space-3);
padding: var(--space-3);
pointer-events: none;
z-index: 1;
}
.personaCardTitle {
@include text-title;
margin: 0;
max-width: min(100%, 14rem);
padding: 0.35rem 0.65rem;
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-surface) 84%, transparent);
backdrop-filter: blur(10px);
color: var(--color-text);
}
.personaCardDetails {
display: grid;
gap: var(--space-2);
max-height: 0;
opacity: 0;
overflow: hidden;
align-self: end;
padding: var(--space-3);
border-radius: var(--radius-lg);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--color-surface) 18%, transparent),
color-mix(in srgb, var(--color-surface) 92%, transparent)
);
backdrop-filter: blur(12px);
transition:
max-height 180ms var(--easing-standard),
opacity 160ms var(--easing-standard);
}
.personaCard:hover .personaCardMedia,
.personaCard:focus-visible .personaCardMedia,
.personaCard[data-selected="true"] .personaCardMedia {
filter: brightness(0.72);
opacity: 0.92;
transform: scale(0.985);
}
.personaCard:hover .personaCardDetails,
.personaCard:focus-visible .personaCardDetails,
.personaCard[data-selected="true"] .personaCardDetails {
max-height: 12rem;
opacity: 1;
}
.personaCard[data-available="false"]:hover .personaCardMedia,
.personaCard[data-available="false"]:focus-visible .personaCardMedia,
.personaCard[data-available="false"][data-selected="true"] .personaCardMedia {
filter: brightness(0.82);
opacity: 0.96;
transform: none;
}
.personaBestFor,
.personaBulletList {
margin: 0;
color: var(--color-text);
}
.personaBulletList {
padding-left: 1rem;
display: grid;
gap: 0.25rem;
}
.personaAvailability {
@include text-caption;
margin: 0;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.field input,
.field select {
min-height: var(--control-size-md);
width: 100%;
font: inherit;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface-elevated);
color: var(--color-text);
padding: 0 var(--space-3);
transition:
border-color 160ms var(--easing-standard),
box-shadow 160ms var(--easing-standard),
background 160ms var(--easing-standard);
}
.field input:disabled,
.field select:disabled {
cursor: not-allowed;
color: var(--color-text-muted);
background: color-mix(in srgb, var(--color-surface-secondary) 92%, transparent);
border-color: color-mix(in srgb, var(--color-border) 72%, transparent);
}
.field input:focus-visible,
.field select:focus-visible {
outline: none;
border-color: color-mix(in srgb, var(--bootstrap-accent) 60%, var(--color-border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--bootstrap-accent) 16%, transparent);
}
.wizardFormActions,
.heroActions,
.primaryButton,
.secondaryButton {
display: flex;
align-items: center;
}
.wizardFormActions {
justify-content: space-between;
gap: var(--space-3);
flex-wrap: nowrap;
}
.wizardFormActions .primaryButton {
margin-left: auto;
}
.primaryButton,
.secondaryButton,
.wizardStepButton,
.wizardCloseButton {
appearance: none;
justify-content: center;
gap: var(--space-2);
min-height: var(--control-size-md);
border-radius: var(--radius-pill);
font-weight: 600;
cursor: pointer;
transition:
transform 180ms var(--easing-standard),
background 160ms var(--easing-standard),
border-color 160ms var(--easing-standard),
color 160ms var(--easing-standard),
box-shadow 160ms var(--easing-standard);
}
.primaryButton,
.secondaryButton,
.wizardCloseButton {
padding: 0 var(--space-4);
}
.primaryButton {
border: 1px solid color-mix(in srgb, var(--bootstrap-accent) 72%, black 8%);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bootstrap-accent) 88%, white 12%),
color-mix(in srgb, var(--bootstrap-accent) 92%, black 8%)
);
color: var(--bootstrap-accent-contrast);
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 28%, transparent),
0 10px 24px color-mix(in srgb, var(--bootstrap-accent) 26%, transparent);
}
.secondaryButton,
.wizardCloseButton {
border: 1px solid var(--color-border);
background: var(--color-surface-secondary);
color: var(--color-text);
}
.primaryButton:hover,
.secondaryButton:hover,
.wizardCloseButton:hover,
.wizardProgressStep:hover {
transform: translateY(-1px);
}
.primaryButton:hover,
.primaryButton:focus-visible {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bootstrap-accent) 84%, white 16%),
color-mix(in srgb, var(--bootstrap-accent) 90%, black 10%)
);
border-color: color-mix(in srgb, var(--bootstrap-accent) 78%, black 10%);
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 32%, transparent),
0 14px 32px color-mix(in srgb, var(--bootstrap-accent) 30%, transparent);
}
.primaryButton:disabled,
.secondaryButton:disabled {
opacity: 0.72;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.errorText {
@include text-caption;
color: var(--color-danger-text, var(--color-text));
}
.wizardLayer {
position: fixed;
inset: 0;
z-index: var(--z-modal);
}
.wizardBackdrop {
position: absolute;
inset: 0;
border: 0;
background: color-mix(in srgb, black 56%, transparent);
backdrop-filter: blur(var(--blur-overlay));
}
.wizardPanel {
position: relative;
z-index: 1;
width: min(calc(100vw - (var(--space-6) * 2)), 72rem);
max-height: calc(100dvh - (var(--space-6) * 2));
margin: var(--space-6) auto;
display: grid;
gap: var(--space-4);
padding: var(--space-4);
border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
border-radius: var(--radius-xl);
background: color-mix(in srgb, var(--color-surface) 94%, var(--color-surface-elevated, var(--color-surface)) 6%);
box-shadow: var(--shadow-strong);
overflow: auto;
}
.wizardHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
}
.wizardHeaderCopy {
min-width: 0;
display: grid;
gap: var(--space-1);
}
.wizardBody {
display: grid;
gap: var(--space-4);
min-height: 0;
}
.wizardProgress {
position: relative;
display: grid;
gap: var(--space-3);
}
.wizardProgressTrack {
position: absolute;
left: calc((var(--control-size-md) - var(--space-2)) / 2);
right: calc((var(--control-size-md) - var(--space-2)) / 2);
top: calc((var(--control-size-md) - var(--space-2)) / 2);
height: 2px;
background: color-mix(in srgb, var(--color-border) 72%, transparent);
transform: translateY(-50%);
pointer-events: none;
}
.wizardProgressFill {
height: 100%;
border-radius: 999px;
background: color-mix(in srgb, var(--bootstrap-accent) 72%, white 8%);
transition: width 220ms var(--easing-standard);
}
.wizardProgressSteps {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0;
}
.wizardProgressStep {
position: relative;
z-index: 1;
flex: 0 0 auto;
display: inline-flex;
justify-content: center;
justify-items: center;
text-align: center;
padding: 0;
border: 0;
background: transparent;
}
.wizardProgressStep:disabled {
cursor: not-allowed;
transform: none;
}
.wizardProgressIndex {
width: calc(var(--control-size-md) - var(--space-2));
height: calc(var(--control-size-md) - var(--space-2));
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
color: var(--color-text-muted);
transition:
border-color 160ms var(--easing-standard),
background 160ms var(--easing-standard),
color 160ms var(--easing-standard),
box-shadow 160ms var(--easing-standard),
transform 180ms var(--easing-standard);
}
.wizardProgressStep[data-active="true"] .wizardProgressIndex,
.wizardProgressStep[data-complete="true"] .wizardProgressIndex {
border-color: color-mix(in srgb, var(--bootstrap-accent) 42%, transparent);
background: color-mix(in srgb, var(--bootstrap-accent) 12%, var(--color-surface));
color: var(--color-text);
}
.wizardProgressStep[data-active="true"] .wizardProgressIndex {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--bootstrap-accent) 14%, transparent);
}
.wizardProgressStep:not(:disabled):hover .wizardProgressIndex,
.wizardProgressStep:not(:disabled):focus-visible .wizardProgressIndex {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--bootstrap-accent) 36%, var(--color-border));
color: var(--color-text);
}
.wizardStepPanel {
align-content: start;
gap: var(--space-3);
}
.wizardFinishPanel {
display: grid;
gap: var(--space-4);
justify-items: stretch;
padding: var(--space-2) 0 0;
min-height: min(14rem, 32dvh);
align-content: center;
}
.wizardFinishShell {
display: grid;
gap: var(--space-4);
width: min(100%, 40rem);
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.wizardFinishStatusRow {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--space-3);
align-items: start;
}
.wizardFinishIndicator {
width: 2.5rem;
height: 2.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--bootstrap-accent) 18%, transparent);
background: color-mix(in srgb, var(--bootstrap-accent) 8%, transparent);
}
.wizardFinishIndicator[data-status="failed"] {
border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 44%, transparent);
background: color-mix(in srgb, var(--color-danger-surface, var(--color-surface-secondary)) 36%, transparent);
}
.wizardFinishSpinner {
width: 1.25rem;
height: 1.25rem;
border-radius: 999px;
border: 2px solid color-mix(in srgb, var(--bootstrap-accent) 18%, transparent);
border-top-color: var(--bootstrap-accent);
animation: wizardFinishSpin 900ms linear infinite;
}
.wizardFinishIndicator[data-status="failed"] .wizardFinishSpinner {
border: 2px solid color-mix(in srgb, var(--color-danger-border, var(--color-border)) 22%, transparent);
border-top-color: var(--color-danger-text, var(--color-text));
animation: none;
transform: rotate(45deg);
border-radius: var(--radius-sm);
width: 1rem;
height: 1rem;
}
.wizardFinishCopy {
display: grid;
gap: var(--space-1);
min-width: 0;
}
.wizardFinishTitle {
@include text-title;
margin: 0;
}
.wizardFinishDescription,
.wizardFinishMessage,
.wizardFinishHint {
margin: 0;
color: var(--color-text-muted);
}
.wizardFinishMessage[data-status="failed"] {
color: var(--color-danger-text, var(--color-text));
}
.wizardFinishHint {
@include text-caption;
}
.wizardFinishActions {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
padding-top: var(--space-1);
}
@keyframes wizardFinishSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@include respond-down(tablet) {
.personaGrid {
grid-template-columns: 1fr;
}
.summaryGrid,
.wizardBody {
grid-template-columns: 1fr;
}
.sectionHeader,
.wizardHeader {
display: grid;
}
}
@include respond-down(mobile) {
.viewport {
gap: var(--space-4);
padding: var(--space-4) var(--space-4) calc(var(--space-8) + env(safe-area-inset-bottom, 0px));
}
.workspaceTopBar {
grid-template-columns: minmax(0, 1fr);
}
.workspaceTopBarStart,
.workspaceTopBarEnd,
.workspaceCollapseButton {
display: none;
}
.workspaceTopBarCenter {
justify-content: flex-start;
}
.summaryGrid {
grid-template-columns: 1fr;
}
.wizardPanel {
width: 100vw;
max-height: 100dvh;
margin: 0;
padding: var(--space-3);
padding-bottom: calc(var(--space-3) + env(safe-area-inset-bottom, 0px));
border-radius: 0;
}
.wizardHeader,
.wizardBody {
gap: var(--space-3);
}
.wizardProgressSteps {
justify-content: flex-start;
gap: var(--space-8);
overflow-x: auto;
padding-bottom: var(--space-1);
scrollbar-width: thin;
}
.wizardProgressStep {
min-width: calc(var(--control-size-md) - var(--space-2));
}
.wizardFormActions {
gap: var(--space-2);
flex-direction: column-reverse;
align-items: stretch;
}
.wizardFormActions .primaryButton {
margin-left: 0;
}
.wizardFormActions .primaryButton,
.wizardFormActions .secondaryButton,
.wizardCloseButton {
width: 100%;
}
}
@@ -0,0 +1,287 @@
// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.parts.tsx
import { For, Show, type JSX } from "solid-js";
import { CircleHelp } from "../../../lib/icons";
import {
organizationalStructureDefaults,
workspaceHomeFieldTooltips,
type AdminForm,
type BootstrapPersona,
type BootstrapPersonaDefinition,
type BootstrapStepDefinition,
type BootstrapStepKey,
type InstanceForm,
type ModeForm,
type StructureForm,
} from "./BootstrapWizard.data";
import styles from "./BootstrapWizard.module.scss";
type BootstrapSubmissionState = {
status: "idle" | "submitting" | "success" | "error";
error: string;
};
type MaterializationState = "not_started" | "pending" | "running" | "succeeded" | "failed";
type TooltipHandlers = {
onShowTooltip: (target: HTMLElement, text: string) => void;
onHideTooltip: () => void;
};
type FieldLabelWithTooltipProps = TooltipHandlers & {
label: string;
tooltip?: string;
};
export const FieldLabelWithTooltip = (props: FieldLabelWithTooltipProps): JSX.Element => (
<span class={styles.fieldLabelRow}>
<span class={styles.fieldLabel}>{props.label}</span>
<Show when={props.tooltip}>
<button
type="button"
class={styles.fieldInfoButton}
aria-label={`${props.label} help: ${props.tooltip}`}
onMouseEnter={(event): void => props.onShowTooltip(event.currentTarget, props.tooltip!)}
onMouseLeave={props.onHideTooltip}
onFocus={(event): void => props.onShowTooltip(event.currentTarget, props.tooltip!)}
onBlur={props.onHideTooltip}
>
<CircleHelp size={14} strokeWidth={2} />
</button>
</Show>
</span>
);
type BootstrapFinishingStateProps = {
materializationState: MaterializationState;
statusLabel: string;
message: string;
isInFlight: boolean;
hasFailed: boolean;
onClose: () => void;
};
export const BootstrapFinishingState = (props: BootstrapFinishingStateProps): JSX.Element => (
<div class={styles.wizardFinishPanel} data-slot="bootstrap-wizard-finishing-state">
<div class={styles.wizardFinishShell}>
<div class={styles.wizardFinishStatusRow}>
<div class={styles.wizardFinishIndicator} data-status={props.materializationState} aria-hidden="true">
<div class={styles.wizardFinishSpinner} />
</div>
<div class={styles.wizardFinishCopy}>
<span class={styles.wizardStepEyebrow}>Bootstrap status</span>
<h3 class={styles.wizardFinishTitle}>Finishing setup</h3>
<p class={styles.wizardFinishDescription}>
We saved your initial bootstrap. The server is finishing the last background setup steps now.
</p>
</div>
</div>
<div class={styles.statusBadge} data-status={props.materializationState}>{props.statusLabel}</div>
<Show when={props.message}>
<p class={styles.wizardFinishMessage} data-status={props.materializationState}>{props.message}</p>
</Show>
<Show when={props.isInFlight}>
<p class={styles.wizardFinishHint}>This window will close automatically when setup is complete.</p>
</Show>
</div>
<Show when={props.hasFailed}>
<div class={styles.wizardFinishActions}>
<button type="button" class={styles.secondaryButton} onClick={props.onClose}>Close</button>
</div>
</Show>
</div>
);
type BootstrapWizardProgressProps = {
steps: readonly BootstrapStepDefinition[];
currentStepId: BootstrapStepKey;
currentWizardStepIndex: number;
stepState: Record<BootstrapStepKey, BootstrapSubmissionState>;
bootstrapStepCount: number;
wizardProgressFillWidth: string;
stepStatusLabel: (step: BootstrapStepDefinition) => string;
onSelectStep: (index: number) => void;
};
export const BootstrapWizardProgress = (props: BootstrapWizardProgressProps): JSX.Element => (
<div class={styles.wizardProgress} data-slot="bootstrap-wizard-progress">
<div class={styles.wizardProgressTrack} aria-hidden="true">
<div class={styles.wizardProgressFill} style={{ width: props.wizardProgressFillWidth }} />
</div>
<nav class={styles.wizardProgressSteps} aria-label="Bootstrap steps" style={{ "--wizard-progress-step-count": props.bootstrapStepCount }}>
<For each={props.steps}>
{(step, index): JSX.Element => (
<button
type="button"
class={styles.wizardProgressStep}
data-active={step.id === props.currentStepId ? "true" : "false"}
data-complete={props.stepState[step.id].status === "success" ? "true" : "false"}
disabled={index() > props.currentWizardStepIndex}
onClick={(): void => {
if (index() <= props.currentWizardStepIndex) {
props.onSelectStep(index());
}
}}
aria-label={`Step ${index() + 1}${props.stepStatusLabel(step) ? `, ${props.stepStatusLabel(step)}` : ""}`}
>
<span class={styles.wizardProgressIndex}>{index() + 1}</span>
</button>
)}
</For>
</nav>
</div>
);
type BootstrapPersonaStepProps = {
personas: readonly BootstrapPersonaDefinition[];
hasChosenPersona: boolean;
selectedPersona: BootstrapPersona;
selectedPersonaIsAvailable: boolean;
onSelectPersona: (persona: BootstrapPersona) => void;
};
export const BootstrapPersonaStep = (props: BootstrapPersonaStepProps): JSX.Element => (
<>
<div class={styles.personaGrid}>
<For each={props.personas}>
{(persona): JSX.Element => (
<button
type="button"
class={styles.personaCard}
data-selected={props.hasChosenPersona && persona.id === props.selectedPersona ? "true" : "false"}
data-available={persona.isAvailable ? "true" : "false"}
aria-pressed={props.hasChosenPersona && persona.id === props.selectedPersona}
onClick={(): void => props.onSelectPersona(persona.id)}
>
<div class={styles.personaCardMedia} aria-hidden="true" />
<div class={styles.personaCardBody}>
<h4 class={styles.personaCardTitle}>{persona.title}</h4>
<div class={styles.personaCardDetails}>
<p class={styles.personaBestFor}>{persona.bestFor}</p>
<ul class={styles.personaBulletList}>
<For each={persona.bullets}>{(bullet): JSX.Element => <li>{bullet}</li>}</For>
</ul>
<Show when={!persona.isAvailable}><p class={styles.personaAvailability}>Coming later</p></Show>
</div>
</div>
</button>
)}
</For>
</div>
<Show when={!props.selectedPersonaIsAvailable}>
<p class={styles.fieldHelp}>Only <strong>Self Hosted Enthusiast</strong> is wired up right now. The other setup paths will come next.</p>
</Show>
</>
);
type BootstrapInstanceStepProps = TooltipHandlers & {
instanceForm: InstanceForm;
onProtocolChange: (value: InstanceForm["protocol"]) => void;
onAccessChange: (value: InstanceForm["access"]) => void;
onHostChange: (value: string) => void;
};
export const BootstrapInstanceStep = (props: BootstrapInstanceStepProps): JSX.Element => (
<>
<label class={styles.field}>
<FieldLabelWithTooltip label="Protocol" tooltip={workspaceHomeFieldTooltips.protocol} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<select value={props.instanceForm.protocol} onInput={(event): void => props.onProtocolChange(event.currentTarget.value as InstanceForm["protocol"])}>
<option value="http">http</option>
<option value="https">https</option>
</select>
</label>
<label class={styles.field}>
<FieldLabelWithTooltip label="Access" tooltip={workspaceHomeFieldTooltips.access} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<select value={props.instanceForm.access} onInput={(event): void => props.onAccessChange(event.currentTarget.value as InstanceForm["access"])}>
<option value="local">local</option>
<option value="remote">remote</option>
</select>
</label>
<label class={styles.field}>
<FieldLabelWithTooltip label="Host" tooltip={workspaceHomeFieldTooltips.host} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.instanceForm.host} onInput={(event): void => props.onHostChange(event.currentTarget.value)} placeholder="localhost or app.example.com" />
</label>
</>
);
type BootstrapModeStepProps = TooltipHandlers & {
modeForm: ModeForm;
structureForm: StructureForm;
usesCondensedBootstrapFlow: boolean;
selectedPersona: BootstrapPersona;
namePlaceholder: string;
onNameChange: (value: string) => void;
onProjectNameChange: (value: string) => void;
onTeamNameChange: (value: string) => void;
};
export const BootstrapModeStep = (props: BootstrapModeStepProps): JSX.Element => (
<>
<label class={styles.field}>
<FieldLabelWithTooltip label="Server name" tooltip={workspaceHomeFieldTooltips.serverName} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.modeForm.name} required onInput={(event): void => props.onNameChange(event.currentTarget.value)} placeholder={props.namePlaceholder} />
</label>
<Show when={props.usesCondensedBootstrapFlow}>
<label class={styles.field}>
<FieldLabelWithTooltip label="Default Project" tooltip={workspaceHomeFieldTooltips.project} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.structureForm.projectName} onInput={(event): void => props.onProjectNameChange(event.currentTarget.value)} placeholder="Project" />
</label>
</Show>
<Show when={props.selectedPersona === "team"}>
<label class={styles.field}>
<FieldLabelWithTooltip label="Team name" tooltip={workspaceHomeFieldTooltips.team} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.structureForm.teamName} onInput={(event): void => props.onTeamNameChange(event.currentTarget.value)} placeholder="Core Team" />
</label>
</Show>
</>
);
type BootstrapAdminStepProps = {
adminForm: AdminForm;
onDisplayNameChange: (value: string) => void;
onEmailChange: (value: string) => void;
onPasswordChange: (value: string) => void;
};
export const BootstrapAdminStep = (props: BootstrapAdminStepProps): JSX.Element => (
<>
<label class={styles.field}>
<span class={styles.fieldLabel}>Display name</span>
<input type="text" value={props.adminForm.displayName} onInput={(event): void => props.onDisplayNameChange(event.currentTarget.value)} placeholder="Admin" />
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Email</span>
<input type="email" value={props.adminForm.email} onInput={(event): void => props.onEmailChange(event.currentTarget.value)} placeholder="admin@example.com" />
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Password</span>
<input type="password" value={props.adminForm.password} onInput={(event): void => props.onPasswordChange(event.currentTarget.value)} placeholder="Create a strong password" />
<small class={styles.fieldHelp}>Use at least 12 characters with uppercase, lowercase, numbers, and symbols.</small>
</label>
</>
);
type BootstrapStructureStepProps = TooltipHandlers & {
mode: ModeForm["mode"];
structureForm: StructureForm;
onDepartmentNameChange: (value: string) => void;
onTeamNameChange: (value: string) => void;
onProjectNameChange: (value: string) => void;
};
export const BootstrapStructureStep = (props: BootstrapStructureStepProps): JSX.Element => (
<>
<label class={styles.field}>
<FieldLabelWithTooltip label="Department" tooltip={workspaceHomeFieldTooltips.department} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.structureForm.departmentName} disabled={props.mode === "personal"} onInput={(event): void => props.onDepartmentNameChange(event.currentTarget.value)} placeholder={organizationalStructureDefaults.departmentName} />
</label>
<label class={styles.field}>
<FieldLabelWithTooltip label="Team" tooltip={workspaceHomeFieldTooltips.team} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.structureForm.teamName} disabled={props.mode === "personal"} onInput={(event): void => props.onTeamNameChange(event.currentTarget.value)} placeholder={organizationalStructureDefaults.teamName} />
</label>
<label class={styles.field}>
<FieldLabelWithTooltip label="Project" tooltip={workspaceHomeFieldTooltips.project} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.structureForm.projectName} onInput={(event): void => props.onProjectNameChange(event.currentTarget.value)} placeholder="Moku" />
</label>
</>
);
@@ -0,0 +1,107 @@
import { type JSX } from "solid-js";
import { useAppShellData } from "../../app-shell/data/app-shell.context";
import { useBootstrapWizard } from "./BootstrapWizard.hook";
import { BootstrapWizardDialog } from "./BootstrapWizardDialog";
export const BootstrapWizard = (): JSX.Element => {
const appShellData = useAppShellData();
const {
instanceForm,
setInstanceForm,
modeForm,
setModeForm,
adminForm,
setAdminForm,
structureForm,
setStructureForm,
selectedPersona,
hasChosenPersona,
stepState,
isBootstrapStateResolved,
isWizardOpen,
setIsWizardOpen,
setIsFinishingBootstrapFlow,
fieldTooltip,
materializationState,
isMaterializationInFlight,
hasMaterializationFailed,
showBootstrapFinishingState,
materializationStatusLabel,
materializationMessage,
selectedPersonaIsAvailable,
usesCondensedBootstrapFlow,
activeWizardSteps,
bootstrapNamePlaceholder,
bootstrapStepCount,
currentStep,
currentWizardStepIndex,
wizardProgressFillWidth,
currentStepState,
isFirstStep,
canDismissWizard,
handleCurrentStepSubmit,
applyPersonaSelection,
statusLabel,
showFieldTooltip,
hideFieldTooltip,
stepStatusLabel,
navigateBack,
navigateToVisibleStep,
} = useBootstrapWizard(appShellData);
return (
<BootstrapWizardDialog
isOpen={isBootstrapStateResolved() && isWizardOpen()}
currentStep={currentStep()}
currentStepState={currentStepState()}
activeWizardSteps={activeWizardSteps()}
currentWizardStepIndex={currentWizardStepIndex()}
stepState={stepState}
bootstrapStepCount={bootstrapStepCount()}
wizardProgressFillWidth={wizardProgressFillWidth()}
showBootstrapFinishingState={showBootstrapFinishingState()}
materializationState={materializationState()}
materializationStatusLabel={materializationStatusLabel()}
materializationMessage={materializationMessage()}
isMaterializationInFlight={isMaterializationInFlight()}
hasMaterializationFailed={hasMaterializationFailed()}
instanceForm={instanceForm}
modeForm={modeForm}
adminForm={adminForm}
structureForm={structureForm}
selectedPersona={selectedPersona()}
hasChosenPersona={hasChosenPersona()}
selectedPersonaIsAvailable={selectedPersonaIsAvailable()}
usesCondensedBootstrapFlow={usesCondensedBootstrapFlow()}
bootstrapNamePlaceholder={bootstrapNamePlaceholder()}
isFirstStep={isFirstStep()}
canDismissWizard={canDismissWizard()}
fieldTooltip={fieldTooltip()}
onClose={(): void => {
setIsWizardOpen(false);
}}
onCloseFinishingState={(): void => {
setIsFinishingBootstrapFlow(false);
setIsWizardOpen(false);
}}
onSubmit={handleCurrentStepSubmit}
onSelectPersona={applyPersonaSelection}
onProtocolChange={(value): void => setInstanceForm("protocol", value)}
onAccessChange={(value): void => setInstanceForm("access", value)}
onHostChange={(value): void => setInstanceForm("host", value)}
onModeNameChange={(value): void => setModeForm("name", value)}
onProjectNameChange={(value): void => setStructureForm("projectName", value)}
onTeamNameChange={(value): void => setStructureForm("teamName", value)}
onAdminDisplayNameChange={(value): void => setAdminForm("displayName", value)}
onAdminEmailChange={(value): void => setAdminForm("email", value)}
onAdminPasswordChange={(value): void => setAdminForm("password", value)}
onDepartmentNameChange={(value): void => setStructureForm("departmentName", value)}
onShowTooltip={showFieldTooltip}
onHideTooltip={hideFieldTooltip}
statusLabel={statusLabel}
stepStatusLabel={stepStatusLabel}
onNavigateBack={navigateBack}
onSelectStep={navigateToVisibleStep}
/>
);
};
@@ -0,0 +1,234 @@
// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizardDialog.tsx
import { Show, type JSX } from "solid-js";
import { Portal } from "solid-js/web";
import {
type AdminForm,
type BootstrapPersona,
bootstrapPersonaDefinitions,
type BootstrapStepDefinition,
type BootstrapStepKey,
type InstanceForm,
type ModeForm,
type StructureForm,
} from "./BootstrapWizard.data";
import { type BootstrapSubmissionState, type FieldTooltipState, type MaterializationState } from "./BootstrapWizard.hook";
import {
BootstrapAdminStep,
BootstrapFinishingState,
BootstrapInstanceStep,
BootstrapModeStep,
BootstrapPersonaStep,
BootstrapStructureStep,
BootstrapWizardProgress,
} from "./BootstrapWizard.parts";
import styles from "./BootstrapWizard.module.scss";
export type BootstrapWizardDialogProps = {
isOpen: boolean;
currentStep: BootstrapStepDefinition;
currentStepState: BootstrapSubmissionState;
activeWizardSteps: readonly BootstrapStepDefinition[];
currentWizardStepIndex: number;
stepState: Record<BootstrapStepKey, BootstrapSubmissionState>;
bootstrapStepCount: number;
wizardProgressFillWidth: string;
showBootstrapFinishingState: boolean;
materializationState: MaterializationState;
materializationStatusLabel: string;
materializationMessage: string;
isMaterializationInFlight: boolean;
hasMaterializationFailed: boolean;
instanceForm: InstanceForm;
modeForm: ModeForm;
adminForm: AdminForm;
structureForm: StructureForm;
selectedPersona: BootstrapPersona;
hasChosenPersona: boolean;
selectedPersonaIsAvailable: boolean;
usesCondensedBootstrapFlow: boolean;
bootstrapNamePlaceholder: string;
isFirstStep: boolean;
canDismissWizard: boolean;
fieldTooltip: FieldTooltipState | null;
onClose: () => void;
onCloseFinishingState: () => void;
onSubmit: JSX.EventHandlerUnion<HTMLFormElement, SubmitEvent>;
onSelectPersona: (persona: BootstrapPersona) => void;
onProtocolChange: (value: InstanceForm["protocol"]) => void;
onAccessChange: (value: InstanceForm["access"]) => void;
onHostChange: (value: string) => void;
onModeNameChange: (value: string) => void;
onProjectNameChange: (value: string) => void;
onTeamNameChange: (value: string) => void;
onAdminDisplayNameChange: (value: string) => void;
onAdminEmailChange: (value: string) => void;
onAdminPasswordChange: (value: string) => void;
onDepartmentNameChange: (value: string) => void;
onShowTooltip: (target: HTMLElement, text: string) => void;
onHideTooltip: () => void;
statusLabel: (state: BootstrapSubmissionState) => string;
stepStatusLabel: (step: BootstrapStepDefinition) => string;
onNavigateBack: () => void;
onSelectStep: (index: number) => void;
};
export const BootstrapWizardDialog = (props: BootstrapWizardDialogProps): JSX.Element => (
<Show when={props.isOpen}>
<Portal>
<div class={styles.wizardLayer} data-ui="bootstrap-wizard" data-step={props.currentStep.id}>
<div class={styles.wizardBackdrop} aria-hidden="true" />
<section class={styles.wizardPanel} role="dialog" aria-modal="true" aria-labelledby="bootstrap-wizard-title" data-slot="bootstrap-wizard-panel">
<header class={styles.wizardHeader} data-slot="bootstrap-wizard-header">
<div class={styles.wizardHeaderCopy}>
<h2 id="bootstrap-wizard-title" class={styles.wizardTitle}>
Bootstrap Server
</h2>
</div>
<Show when={props.canDismissWizard}>
<button type="button" class={styles.wizardCloseButton} onClick={props.onClose}>
Close
</button>
</Show>
</header>
<Show
when={!props.showBootstrapFinishingState}
fallback={
<BootstrapFinishingState
materializationState={props.materializationState}
statusLabel={props.materializationStatusLabel}
message={props.materializationMessage}
isInFlight={props.isMaterializationInFlight}
hasFailed={props.hasMaterializationFailed}
onClose={props.onCloseFinishingState}
/>
}
>
<div class={styles.wizardBody}>
<Show when={props.currentStep.id !== "persona"}>
<BootstrapWizardProgress
steps={props.activeWizardSteps}
currentStepId={props.currentStep.id}
currentWizardStepIndex={props.currentWizardStepIndex}
stepState={props.stepState}
bootstrapStepCount={props.bootstrapStepCount}
wizardProgressFillWidth={props.wizardProgressFillWidth}
stepStatusLabel={props.stepStatusLabel}
onSelectStep={props.onSelectStep}
/>
</Show>
<div class={styles.wizardStepPanel} data-slot="bootstrap-wizard-step-panel">
<Show when={props.currentStep.id !== "persona" || props.statusLabel(props.currentStepState)}>
<div class={styles.sectionHeader}>
<Show when={props.currentStep.id !== "persona"}>
<div>
<span class={styles.wizardStepEyebrow}>{`Step ${props.currentWizardStepIndex + 1} of ${props.bootstrapStepCount}`}</span>
</div>
</Show>
<Show when={props.statusLabel(props.currentStepState)}>
<div class={styles.statusBadge} data-status={props.currentStepState.status}>
{props.statusLabel(props.currentStepState)}
</div>
</Show>
</div>
</Show>
<form class={styles.form} onSubmit={props.onSubmit}>
<Show when={props.currentStep.id === "persona"}>
<BootstrapPersonaStep
personas={bootstrapPersonaDefinitions}
hasChosenPersona={props.hasChosenPersona}
selectedPersona={props.selectedPersona}
selectedPersonaIsAvailable={props.selectedPersonaIsAvailable}
onSelectPersona={props.onSelectPersona}
/>
</Show>
<Show when={props.currentStep.id === "instance"}>
<BootstrapInstanceStep
instanceForm={props.instanceForm}
onProtocolChange={props.onProtocolChange}
onAccessChange={props.onAccessChange}
onHostChange={props.onHostChange}
onShowTooltip={props.onShowTooltip}
onHideTooltip={props.onHideTooltip}
/>
</Show>
<Show when={props.currentStep.id === "mode"}>
<BootstrapModeStep
modeForm={props.modeForm}
structureForm={props.structureForm}
usesCondensedBootstrapFlow={props.usesCondensedBootstrapFlow}
selectedPersona={props.selectedPersona}
namePlaceholder={props.bootstrapNamePlaceholder}
onNameChange={props.onModeNameChange}
onProjectNameChange={props.onProjectNameChange}
onTeamNameChange={props.onTeamNameChange}
onShowTooltip={props.onShowTooltip}
onHideTooltip={props.onHideTooltip}
/>
</Show>
<Show when={props.currentStep.id === "admin"}>
<BootstrapAdminStep
adminForm={props.adminForm}
onDisplayNameChange={props.onAdminDisplayNameChange}
onEmailChange={props.onAdminEmailChange}
onPasswordChange={props.onAdminPasswordChange}
/>
</Show>
<Show when={props.currentStep.id === "structure"}>
<BootstrapStructureStep
mode={props.modeForm.mode}
structureForm={props.structureForm}
onDepartmentNameChange={props.onDepartmentNameChange}
onTeamNameChange={props.onTeamNameChange}
onProjectNameChange={props.onProjectNameChange}
onShowTooltip={props.onShowTooltip}
onHideTooltip={props.onHideTooltip}
/>
</Show>
<Show when={props.currentStep.id !== "persona"}>
<div class={styles.wizardFormActions}>
<button type="button" class={styles.secondaryButton} disabled={props.isFirstStep} onClick={props.onNavigateBack}>
Back
</button>
<button type="submit" class={styles.primaryButton} disabled={props.currentStepState.status === "submitting"}>
{props.currentStep.buttonLabel}
</button>
</div>
</Show>
</form>
<Show when={props.currentStepState.error}>
<p class={styles.errorText}>{props.currentStepState.error}</p>
</Show>
</div>
</div>
</Show>
</section>
<Show when={props.fieldTooltip}>
{(tooltip): JSX.Element => (
<div
class={styles.fieldTooltip}
data-placement={tooltip().placement}
style={{
left: `${tooltip().left}px`,
top: `${tooltip().top}px`,
}}
>
<div class={styles.fieldTooltipBubble}>{tooltip().text}</div>
</div>
)}
</Show>
</div>
</Portal>
</Show>
);
@@ -0,0 +1,81 @@
import { resolveAPIBase } from "../../../lib/api";
import type { BootstrapStepKey } from "./BootstrapWizard.data";
const readBootstrapResponseBody = async (response: Response): Promise<unknown> => {
const raw = await response.text();
if (!raw.trim()) {
return null;
}
try {
return JSON.parse(raw);
} catch {
return raw;
}
};
const readBootstrapResponseError = (step: BootstrapStepKey, data: unknown): string => {
const fallback = `Bootstrap ${step} request failed.`;
if (typeof data === "string") {
const message = data.trim();
return message || fallback;
}
if (!data || typeof data !== "object") {
return fallback;
}
const record = data as {
error?: string;
message?: string;
requestId?: string;
};
const message = typeof record.message === "string" ? record.message.trim() : "";
const errorCode = typeof record.error === "string" ? record.error.trim() : "";
const requestId = typeof record.requestId === "string" ? record.requestId.trim() : "";
if (!message && !errorCode && !requestId) {
return fallback;
}
const details: string[] = [];
if (errorCode) {
details.push(`code: ${errorCode}`);
}
if (requestId) {
details.push(`request: ${requestId}`);
}
if (message && details.length > 0) {
return `${message} (${details.join(", ")})`;
}
if (message) {
return message;
}
return `${fallback} (${details.join(", ")})`;
};
export const submitBootstrapStepRequest = async (
step: BootstrapStepKey,
payload: unknown,
): Promise<void> => {
const response = await fetch(`${resolveAPIBase()}/bootstrap/steps/${step}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(payload),
});
const data = await readBootstrapResponseBody(response);
if (!response.ok) {
throw new Error(readBootstrapResponseError(step, data));
}
};
@@ -1,190 +0,0 @@
.browserLayer {
display: none;
}
@include respond-down(mobile) {
.browserLayer {
position: fixed;
inset: 0;
display: grid;
z-index: calc(var(--z-popover, 20) + 2);
background: color-mix(in srgb, var(--color-canvas) 96%, black 4%);
}
.sheet {
min-height: 100dvh;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
background:
linear-gradient(180deg, color-mix(in srgb, var(--color-surface) 84%, transparent) 0%, transparent 8rem),
color-mix(in srgb, var(--color-canvas) 97%, black 3%);
overflow: clip;
}
.sheetHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: calc(var(--space-5) + env(safe-area-inset-top, 0px)) var(--space-4) var(--space-4);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 84%, transparent);
}
.headerActions {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.brandBlock {
min-width: 0;
display: grid;
gap: var(--space-1);
}
.sectionLabel {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.brandEyebrow {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.brandTitle {
@include text-title;
color: var(--color-text);
font-weight: var(--font-weight-semibold);
}
.brandContext {
@include text-caption;
color: var(--color-text-subtle);
}
.closeButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--control-size-md);
height: var(--control-size-md);
padding: 0;
border: 1px solid color-mix(in srgb, var(--color-border) 92%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
color: var(--color-text-subtle);
}
.createButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-height: var(--control-size-md);
padding: 0 var(--space-3);
border: 1px solid color-mix(in srgb, var(--color-border-strong, var(--color-border)) 82%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--color-surface) 94%, var(--color-canvas) 6%);
color: var(--color-text);
box-shadow: var(--shadow-soft);
font: inherit;
font-weight: var(--font-weight-semibold);
}
.sheetBody {
min-height: 0;
display: grid;
gap: var(--space-5);
padding: var(--space-5) var(--space-5) calc(var(--space-12) + var(--space-10) + env(safe-area-inset-bottom, 0px));
overflow: auto;
}
.sectionBlock {
display: grid;
gap: var(--space-2);
}
.treeList,
.treeListItem {
list-style: none;
margin: 0;
padding: 0;
}
.treeList {
display: grid;
gap: 0;
border-top: 1px solid color-mix(in srgb, var(--color-border) 92%, transparent);
}
.treeListNested {
display: grid;
gap: 0;
}
.treeRow {
min-width: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
min-height: calc(var(--control-size-lg) + var(--space-3));
padding: var(--space-4) var(--space-2) var(--space-4) calc(var(--space-2) + (var(--tree-depth, 0) * var(--space-4)));
border: 0;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 92%, transparent);
border-radius: 0;
background: transparent;
color: var(--color-text);
text-align: left;
}
.treeRowActive {
color: var(--color-text);
background: color-mix(in srgb, var(--color-surface) 72%, transparent);
}
.treeRowBranch {
font-weight: var(--font-weight-semibold);
}
.treeRowLead,
.treeRowTrail {
min-width: 0;
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.treeRowTrail {
flex: 0 0 auto;
color: var(--color-text-muted);
}
.treeLabel {
@include text-label;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.treeMeta {
@include text-caption;
min-width: 1rem;
text-align: right;
}
.treeChevron {
color: var(--color-text-subtle);
}
.treeListNested > .treeListItem > .treeRow {
min-height: calc(var(--control-size-lg) + var(--space-1));
}
}
@@ -1,236 +0,0 @@
import { For, Show, createSignal, type JSX } from "solid-js";
import { ChevronRight, Plus, X } from "../../../lib/icons";
import { createLongPressGesture } from "../createLongPressGesture";
import {
activeProject,
activeServer,
createWorkspaceStaticTarget,
createWorkspaceSurfaceTarget,
createWorkspaceTreeTarget,
workspaceStaticItems,
workspaceTree,
type SidebarItem,
type WorkspaceContextMenuAction,
type WorkspaceContextMenuTarget,
type WorkspaceStaticItem,
type WorkspaceTreeNode,
} from "../data/shell.data";
import { WorkspaceMobileActionSheet } from "../WorkspaceMobileActionSheet/WorkspaceMobileActionSheet";
import styles from "./MobileWorkspaceBrowser.module.scss";
type MobileWorkspaceBrowserProps = {
open: boolean;
onClose: VoidFunction;
};
const TreeRow = (props: { node: WorkspaceTreeNode; depth?: number }): JSX.Element => {
const depth = props.depth ?? 0;
const Icon = props.node.icon;
const hasChildren = (props.node.children?.length ?? 0) > 0;
return (
<button
classList={{
[styles.treeRow]: true,
[styles.treeRowActive]: props.node.active ?? false,
[styles.treeRowBranch]: hasChildren,
}}
type="button"
style={{ "--tree-depth": `${depth}` }}
>
<span class={styles.treeRowLead}>
<Icon size={16} strokeWidth={2} />
<span class={styles.treeLabel}>{props.node.label}</span>
</span>
<span class={styles.treeRowTrail}>
<Show when={props.node.meta}>
<span class={styles.treeMeta}>{props.node.meta}</span>
</Show>
<Show when={hasChildren}>
<ChevronRight size={14} strokeWidth={2} class={styles.treeChevron} />
</Show>
</span>
</button>
);
};
const StaticRow = (props: { item: SidebarItem }): JSX.Element => {
const Icon = props.item.icon;
return (
<button classList={{ [styles.treeRow]: true, [styles.treeRowActive]: props.item.active ?? false }} type="button" style={{ "--tree-depth": "0" }}>
<span class={styles.treeRowLead}>
<Icon size={16} strokeWidth={2} />
<span class={styles.treeLabel}>{props.item.label}</span>
</span>
<span class={styles.treeRowTrail}>
<Show when={props.item.meta}>
<span class={styles.treeMeta}>{props.item.meta}</span>
</Show>
<ChevronRight size={14} strokeWidth={2} class={styles.treeChevron} />
</span>
</button>
);
};
const WorkspaceStaticRow = (props: {
item: WorkspaceStaticItem;
onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void;
}): JSX.Element => {
const target = createWorkspaceStaticTarget(props.item);
const longPress = createLongPressGesture({
onLongPress: () => {
props.onOpenActionSheet(target);
},
});
return (
<li
class={styles.treeListItem}
onContextMenu={(event): void => {
event.preventDefault();
props.onOpenActionSheet(target);
}}
{...longPress}
>
<StaticRow item={props.item} />
</li>
);
};
const WorkspaceTreeBranch = (props: {
nodes: readonly WorkspaceTreeNode[];
depth?: number;
onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void;
}): JSX.Element => {
const depth = props.depth ?? 0;
return (
<For each={props.nodes}>
{(node): JSX.Element => {
const target = createWorkspaceTreeTarget(node);
const longPress = createLongPressGesture({
onLongPress: () => {
props.onOpenActionSheet(target);
},
});
return (
<li
class={styles.treeListItem}
onContextMenu={(event): void => {
event.preventDefault();
props.onOpenActionSheet(target);
}}
{...longPress}
>
<TreeRow node={node} depth={depth} />
<Show when={node.children?.length}>
<ul class={styles.treeListNested}>
<WorkspaceTreeBranch nodes={node.children ?? []} depth={depth + 1} onOpenActionSheet={props.onOpenActionSheet} />
</ul>
</Show>
</li>
);
}}
</For>
);
};
export const MobileWorkspaceBrowser = (props: MobileWorkspaceBrowserProps): JSX.Element => {
const [actionSheetTarget, setActionSheetTarget] = createSignal<WorkspaceContextMenuTarget | null>(null);
const sectionNodes = workspaceTree.filter((node) => (node.children?.length ?? 0) > 0);
const looseNodes = workspaceTree.filter((node) => (node.children?.length ?? 0) === 0);
const workspaceTarget = createWorkspaceSurfaceTarget(activeProject);
const openActionSheet = (target: WorkspaceContextMenuTarget): void => {
setActionSheetTarget(target);
};
const closeActionSheet = (): void => {
setActionSheetTarget(null);
};
const openWorkspaceActionSheet = (): void => {
openActionSheet(workspaceTarget);
};
const handleActionSelect = (_action: WorkspaceContextMenuAction, _target: WorkspaceContextMenuTarget): void => {
// Mobile first pass only establishes the action-sheet IA and long-press behavior.
};
const workspaceLongPress = createLongPressGesture({
onLongPress: openWorkspaceActionSheet,
});
return (
<Show when={props.open}>
<div class={styles.browserLayer}>
<section class={styles.sheet} aria-label="Mobile workspace browser">
<header class={styles.sheetHeader}>
<div
class={styles.brandBlock}
onContextMenu={(event): void => {
event.preventDefault();
openWorkspaceActionSheet();
}}
{...workspaceLongPress}
>
{/* Long-pressing the browser header exposes workspace-level actions on mobile. */}
<span class={styles.brandEyebrow}>Moku Work</span>
<strong class={styles.brandTitle}>{activeProject.name}</strong>
<span class={styles.brandContext}>{activeServer.name}</span>
</div>
<div class={styles.headerActions}>
<button
class={styles.createButton}
type="button"
aria-label="Create"
onClick={openWorkspaceActionSheet}
>
<Plus size={16} strokeWidth={2.25} />
<span>Create</span>
</button>
<button class={styles.closeButton} type="button" aria-label="Close workspace browser" onClick={props.onClose}>
<X size={18} strokeWidth={2} />
</button>
</div>
</header>
<div class={styles.sheetBody}>
<section class={styles.sectionBlock}>
<span class={styles.sectionLabel}>Workspace</span>
<ul class={styles.treeList}>
<For each={workspaceStaticItems}>
{(item): JSX.Element => <WorkspaceStaticRow item={item} onOpenActionSheet={openActionSheet} />}
</For>
</ul>
</section>
<section class={styles.sectionBlock}>
<span class={styles.sectionLabel}>Items</span>
<ul class={styles.treeList}>
<WorkspaceTreeBranch nodes={sectionNodes} onOpenActionSheet={openActionSheet} />
</ul>
</section>
<Show when={looseNodes.length > 0}>
<section class={styles.sectionBlock}>
<span class={styles.sectionLabel}>More</span>
<ul class={styles.treeList}>
<WorkspaceTreeBranch nodes={looseNodes} onOpenActionSheet={openActionSheet} />
</ul>
</section>
</Show>
</div>
</section>
<WorkspaceMobileActionSheet
target={actionSheetTarget()}
onClose={closeActionSheet}
onSelect={handleActionSelect}
/>
</div>
</Show>
);
};
@@ -1,163 +0,0 @@
// Path: Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx
import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js";
import { ChevronDown, Folder } from "../../../lib/icons";
import { activeProject, projectItems } from "../data/shell.data";
import styles from "./ProjectSelector.module.scss";
type ProjectSelectorProps = {
compact?: boolean;
isOpen: boolean;
onToggle: () => void;
onClose: () => void;
};
const defaultProject = projectItems.find((item) => item.id === activeProject.id) ?? projectItems[0];
export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
const [selectedProject, setSelectedProject] = createSignal({ id: defaultProject.id, name: defaultProject.name });
const [drawerTop, setDrawerTop] = createSignal<number>(0);
let triggerRef: HTMLButtonElement | undefined;
onMount(() => {
if (!triggerRef) {
return;
}
const updateDrawerTop = (): void => {
if (!triggerRef) {
return;
}
setDrawerTop(triggerRef.offsetTop + triggerRef.offsetHeight);
};
updateDrawerTop();
const observer = new ResizeObserver(() => {
updateDrawerTop();
});
observer.observe(triggerRef);
window.addEventListener("resize", updateDrawerTop);
onCleanup(() => {
observer.disconnect();
window.removeEventListener("resize", updateDrawerTop);
});
});
const toggleOpen = (): void => {
if (!props.isOpen) {
props.onToggle();
return;
}
props.onClose();
};
const selectProject = (projectId: string): void => {
const nextProject = projectItems.find((item): boolean => item.id === projectId);
if (!nextProject) {
return;
}
setSelectedProject({ id: nextProject.id, name: nextProject.name });
props.onClose();
};
return (
<div
classList={{
[styles.root]: true,
[styles.rootCompact]: !!props.compact,
}}
style={{
"--project-drawer-top": `${drawerTop()}px`,
}}
>
{/* Project trigger */}
<button
type="button"
ref={triggerRef}
classList={{
[styles.trigger]: true,
[styles.triggerCompact]: !!props.compact,
[styles.triggerOpen]: props.isOpen,
}}
aria-label={`Open left workspace sidebar menu for ${selectedProject().name}`}
aria-expanded={props.isOpen}
title={selectedProject().name}
onClick={toggleOpen}
>
<span class={styles.triggerLead} aria-hidden="true">
<Folder size={18} strokeWidth={2} />
</span>
{!props.compact ? (
<span class={styles.triggerCopy}>
<span class={styles.eyebrow}>Projects</span>
<span class={styles.value}>{selectedProject().name}</span>
</span>
) : null}
<ChevronDown
classList={{
[styles.triggerIcon]: true,
[styles.triggerIconOpen]: props.isOpen,
}}
size={16}
strokeWidth={2}
/>
</button>
{/* Outside-click scrim */}
<button
type="button"
classList={{
[styles.scrim]: true,
[styles.scrimOpen]: props.isOpen,
}}
aria-hidden={!props.isOpen}
tabIndex={props.isOpen ? 0 : -1}
onClick={props.onClose}
/>
{/* Slide-out project list */}
<div
classList={{
[styles.drawer]: true,
[styles.drawerOpen]: props.isOpen,
}}
aria-hidden={!props.isOpen}
>
<div class={styles.drawerBody}>
<ul class={styles.projectList} role="list">
<For each={projectItems}>
{(item): JSX.Element => {
const isSelected = (): boolean => selectedProject().id === item.id;
return (
<li>
<button
type="button"
classList={{
[styles.projectItem]: true,
[styles.projectItemActive]: isSelected(),
}}
onClick={(): void => selectProject(item.id)}
>
<span class={styles.projectItemCopy}>
<span class={styles.projectItemName}>{item.name}</span>
<span class={styles.projectItemDescription}>{item.description}</span>
</span>
</button>
</li>
);
}}
</For>
</ul>
</div>
</div>
</div>
);
};
@@ -1,46 +0,0 @@
// Path: Frontend/src/components/shell/ServerDock/ServerDock.tsx
import { For, Show, type JSX } from "solid-js";
import { activeServer } from "../data/shell.data";
import styles from "./ServerDock.module.scss";
export const ServerDock = (): JSX.Element => {
return (
<section class={styles.panel} aria-label="Server dock">
<div class={styles.identity}>
<div class={styles.glyph} aria-hidden="true">
{activeServer.abbreviation}
</div>
<div class={styles.copy}>
<span class={styles.name}>{activeServer.name}</span>
<Show
when={activeServer.kind === "organization"}
fallback={<span class={styles.subtitle}>{activeServer.subtitle}</span>}
>
<span class={styles.status}>
<span class={styles.statusDot} aria-hidden="true" />
<span>{activeServer.connectedLabel}</span>
</span>
</Show>
</div>
</div>
<Show when={activeServer.dockActions.length > 0}>
<div class={styles.actions}>
<For each={activeServer.dockActions}>
{(item): JSX.Element => {
const Icon = item.icon;
return (
<button type="button" class={styles.action} aria-label={item.label} title={item.label}>
<Icon size={16} strokeWidth={2} />
<span class={styles.actionLabel}>{item.label}</span>
</button>
);
}}
</For>
</div>
</Show>
</section>
);
};
@@ -1,261 +0,0 @@
// Path: Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx
import { For, Show, createMemo, createSignal, type JSX } from "solid-js";
import { ChevronLeft, ChevronRight } from "../../../lib/icons";
import { ProjectSelector } from "../ProjectSelector/ProjectSelector";
import {
activeProject,
createWorkspaceStaticTarget,
createWorkspaceSurfaceTarget,
createWorkspaceTreeTarget,
workspaceSidebarHeaderActions,
workspaceStaticItems,
workspaceTree,
type WorkspaceContextMenuAction,
type WorkspaceContextMenuTarget,
type WorkspaceStaticItem,
type WorkspaceTreeNode,
} from "../data/shell.data";
import { WorkspaceContextMenu } from "../WorkspaceContextMenu/WorkspaceContextMenu";
import { createWorkspaceContextMenuController } from "../WorkspaceContextMenu/createWorkspaceContextMenuController";
import styles from "./WorkspaceSidebar.module.scss";
type WorkspaceSidebarProps = {
collapsed: boolean;
railCollapsed: boolean;
onToggleRailCollapse: () => void;
};
const isContextMenuKeyboardTrigger = (event: KeyboardEvent): boolean => event.key === "ContextMenu" || (event.shiftKey && event.key === "F10");
const WorkspaceHomeEntry = (props: {
item: WorkspaceStaticItem;
onOpenContextMenu: (event: MouseEvent, target: WorkspaceContextMenuTarget) => void;
onOpenContextMenuFromKeyboard: (element: HTMLElement, target: WorkspaceContextMenuTarget) => void;
}): JSX.Element => {
const Icon = props.item.icon;
const target = createWorkspaceStaticTarget(props.item);
return (
<li>
<button
type="button"
classList={{
[styles.navItem]: true,
[styles.navItemActive]: !!props.item.active,
}}
aria-current={props.item.active ? "page" : undefined}
aria-label={props.item.label}
title={props.item.label}
onContextMenu={(event): void => {
event.stopPropagation();
props.onOpenContextMenu(event, target);
}}
onKeyDown={(event): void => {
if (!isContextMenuKeyboardTrigger(event)) {
return;
}
event.preventDefault();
props.onOpenContextMenuFromKeyboard(event.currentTarget, target);
}}
>
<Icon class={styles.icon} size={18} strokeWidth={2} />
<span class={styles.label}>{props.item.label}</span>
<Show when={props.item.meta}>
<span class={styles.itemMeta}>{props.item.meta}</span>
</Show>
</button>
</li>
);
};
const WorkspaceTreeBranch = (props: {
nodes: readonly WorkspaceTreeNode[];
depth?: number;
onOpenContextMenu: (event: MouseEvent, target: WorkspaceContextMenuTarget) => void;
onOpenContextMenuFromKeyboard: (element: HTMLElement, target: WorkspaceContextMenuTarget) => void;
}): JSX.Element => {
const depth = () => props.depth ?? 0;
return (
<ul class={styles.treeList} role="list">
<For each={props.nodes}>
{(node): JSX.Element => {
const Icon = node.icon;
const target = createWorkspaceTreeTarget(node);
return (
<li>
<button
type="button"
classList={{
[styles.treeItem]: true,
[styles.treeItemActive]: !!node.active,
[styles.treeItemFolder]: node.kind === "folder",
}}
style={{ "--tree-depth": String(depth()) }}
aria-current={node.active ? "page" : undefined}
aria-label={node.label}
title={node.label}
onContextMenu={(event): void => {
event.stopPropagation();
props.onOpenContextMenu(event, target);
}}
onKeyDown={(event): void => {
if (!isContextMenuKeyboardTrigger(event)) {
return;
}
event.preventDefault();
props.onOpenContextMenuFromKeyboard(event.currentTarget, target);
}}
>
<Icon class={styles.icon} size={18} strokeWidth={2} />
<span class={styles.label}>{node.label}</span>
<Show when={node.meta}>
<span class={styles.itemMeta}>{node.meta}</span>
</Show>
</button>
<Show when={node.children?.length}>
<WorkspaceTreeBranch
nodes={node.children ?? []}
depth={depth() + 1}
onOpenContextMenu={props.onOpenContextMenu}
onOpenContextMenuFromKeyboard={props.onOpenContextMenuFromKeyboard}
/>
</Show>
</li>
);
}}
</For>
</ul>
);
};
export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
const [isProjectDrawerOpen, setIsProjectDrawerOpen] = createSignal(false);
const contextMenu = createWorkspaceContextMenuController();
const railToggleLabel = (): string => (props.railCollapsed ? "Expand server rail" : "Collapse server rail");
const sidebarContextMenuTarget = createWorkspaceSurfaceTarget(activeProject);
const contextMenuTarget = createMemo(() => contextMenu.menuState()?.target ?? null);
const contextMenuPosition = createMemo(() => {
const state = contextMenu.menuState();
return state
? {
x: state.x,
y: state.y,
}
: null;
});
const handleContextActionSelect = (_action: WorkspaceContextMenuAction, _target: WorkspaceContextMenuTarget): void => {
// Initial implementation only establishes the menu IA and placement.
};
return (
<>
<aside
classList={{
[styles.sidebar]: true,
[styles.sidebarCollapsed]: props.collapsed,
}}
aria-label="Left workspace sidebar"
onContextMenu={(event): void => {
contextMenu.openMenu(event, sidebarContextMenuTarget);
}}
>
<div
classList={{
[styles.header]: true,
[styles.headerDrawerOpen]: isProjectDrawerOpen(),
}}
>
<div class={styles.headerActions}>
<button
type="button"
classList={{
[styles.headerActionButton]: true,
[styles.headerCollapseButton]: true,
}}
aria-label={railToggleLabel()}
title={railToggleLabel()}
onClick={props.onToggleRailCollapse}
>
{props.railCollapsed ? <ChevronRight size={16} strokeWidth={2} /> : <ChevronLeft size={16} strokeWidth={2} />}
</button>
<For each={workspaceSidebarHeaderActions}>
{(action): JSX.Element => {
const Icon = action.icon;
return (
<button type="button" class={styles.headerActionButton} aria-label={action.label} title={action.label}>
<Icon size={16} strokeWidth={2} />
</button>
);
}}
</For>
</div>
<div class={styles.headerControls}>
<ProjectSelector
compact={props.collapsed}
isOpen={isProjectDrawerOpen()}
onToggle={(): void => {
setIsProjectDrawerOpen(true);
}}
onClose={(): void => {
setIsProjectDrawerOpen(false);
}}
/>
</div>
</div>
<div
classList={{
[styles.section]: true,
[styles.sectionHidden]: isProjectDrawerOpen(),
}}
>
<Show when={!props.collapsed}>
<span class={styles.sectionLabel}>Workspace</span>
</Show>
<div class={styles.navScroller}>
<ul class={styles.navList} role="list">
<For each={workspaceStaticItems}>
{(item): JSX.Element => (
<WorkspaceHomeEntry
item={item}
onOpenContextMenu={contextMenu.openMenu}
onOpenContextMenuFromKeyboard={contextMenu.openMenuFromElement}
/>
)}
</For>
</ul>
<Show when={!props.collapsed}>
<div class={styles.treeSectionLabel}>Items</div>
</Show>
<WorkspaceTreeBranch
nodes={workspaceTree}
onOpenContextMenu={contextMenu.openMenu}
onOpenContextMenuFromKeyboard={contextMenu.openMenuFromElement}
/>
</div>
</div>
</aside>
<WorkspaceContextMenu
target={contextMenuTarget()}
position={contextMenuPosition()}
menuRef={contextMenu.setMenuRef}
onClose={contextMenu.closeMenu}
onSelect={handleContextActionSelect}
/>
</>
);
};
@@ -1,502 +0,0 @@
// Path: Frontend/src/components/shell/data/shell.data.ts
import type { Component } from "solid-js";
import {
Bell,
CircleHelp,
FileText,
Folder,
Home,
Keyboard,
LayoutGrid,
LogOut,
Repeat,
Search,
Settings,
Shield,
User,
} from "../../../lib/icons";
type ShellIconProps = {
class?: string;
size?: number;
strokeWidth?: number;
};
export type ShellIcon = Component<ShellIconProps>;
export type RailItem = {
id: string;
label: string;
abbreviation: string;
kind: "personal" | "organization";
active?: boolean;
};
export type ServerDockAction = {
id: string;
label: string;
icon: ShellIcon;
};
export type ActiveServer = {
id: string;
name: string;
abbreviation: string;
kind: "personal" | "organization";
connectedLabel?: string;
subtitle?: string;
dockActions: readonly ServerDockAction[];
};
export type ActiveProject = {
id: string;
name: string;
};
export type ActiveDepartment = {
id: string;
name: string;
teamName: string;
};
export type DepartmentItem = {
id: string;
name: string;
teams: readonly string[];
active?: boolean;
};
export type ProjectItem = {
id: string;
name: string;
description: string;
active?: boolean;
};
export type SidebarItem = {
id: string;
label: string;
icon: ShellIcon;
active?: boolean;
meta?: string;
};
export type WorkspaceStaticKind = "workspace" | "home" | "settings";
export type WorkspaceStaticItem = SidebarItem & {
contextKind: WorkspaceStaticKind;
};
export type WorkspaceTreeNode = {
id: string;
label: string;
kind: "folder" | "board" | "doc";
icon: ShellIcon;
active?: boolean;
meta?: string;
children?: readonly WorkspaceTreeNode[];
};
export type SidebarHeaderAction = {
id: string;
label: string;
icon: ShellIcon;
};
export type TopBarAction = {
id: string;
label: string;
icon: ShellIcon;
};
export type MobileBottomNavItem = {
id: string;
label: string;
icon: ShellIcon;
active?: boolean;
};
export type WorkspaceContextMenuTarget = {
id: string;
label: string;
kind: WorkspaceStaticKind | WorkspaceTreeNode["kind"];
};
export type WorkspaceContextMenuAction = {
id: string;
label: string;
tone?: "default" | "danger";
shortcut?: WorkspaceContextMenuShortcut;
children?: readonly WorkspaceContextMenuAction[];
};
export type WorkspaceContextMenuShortcutModifier = "meta" | "alt" | "shift";
export type WorkspaceContextMenuShortcutKey = "b" | "c" | "d" | "delete" | "enter" | "f" | "m" | "r";
export type WorkspaceContextMenuShortcut = {
modifiers?: readonly WorkspaceContextMenuShortcutModifier[];
key: WorkspaceContextMenuShortcutKey;
};
export type WorkspaceContextMenuSection = {
id: string;
label?: string;
items: readonly WorkspaceContextMenuAction[];
};
export const getWorkspaceContextMenuEyebrow = (target: WorkspaceContextMenuTarget): string => {
switch (target.kind) {
case "workspace":
case "home":
return "Workspace";
case "settings":
return "Configuration";
case "folder":
return "Folder";
case "board":
return "Board";
case "doc":
return "Doc";
}
};
export const createWorkspaceSurfaceTarget = (workspace: ActiveProject): WorkspaceContextMenuTarget => ({
id: `workspace-${workspace.id}`,
label: workspace.name,
kind: "workspace",
});
export const createWorkspaceStaticTarget = (item: WorkspaceStaticItem): WorkspaceContextMenuTarget => ({
id: item.id,
label: item.label,
kind: item.contextKind,
});
export const createWorkspaceTreeTarget = (node: WorkspaceTreeNode): WorkspaceContextMenuTarget => ({
id: node.id,
label: node.label,
kind: node.kind,
});
export type NotificationItem = {
id: string;
title: string;
contextLabel: string;
timeLabel: string;
unread?: boolean;
};
export type ProfileMenuAction = {
id: string;
label: string;
icon: ShellIcon;
tone?: "default" | "danger";
};
export type ProfileMenuSection = {
id: string;
items: readonly ProfileMenuAction[];
};
export type ActiveUserProfile = {
name: string;
email: string;
roleLabel: string;
contextLabel: string;
};
const personalDockActions: readonly ServerDockAction[] = [
{ id: "account", label: "Account", icon: User },
{ id: "settings", label: "Settings", icon: Settings },
] as const;
const organizationAdminDockActions: readonly ServerDockAction[] = [
{ id: "members", label: "Members", icon: User },
{ id: "server", label: "Server", icon: Settings },
] as const;
// Server shell scaffold data
export const railItems: readonly RailItem[] = [
{ id: "personal-server", label: "Personal Server Name", abbreviation: "P", kind: "personal" },
{ id: "organization-server", label: "Organization Name", abbreviation: "O", kind: "organization", active: true },
{ id: "design-review", label: "Design Review", abbreviation: "D", kind: "organization" },
] as const;
export const activeServer: ActiveServer = {
id: "organization-server",
name: "Organization Name",
abbreviation: "O",
kind: "organization",
connectedLabel: "12 connected",
dockActions: organizationAdminDockActions,
};
// Workspace framing scaffold data
export const activeProject: ActiveProject = {
id: "general",
name: "General",
};
export const activeDepartment: ActiveDepartment = {
id: "product",
name: "Product",
teamName: "Design Systems",
};
export const projectItems: readonly ProjectItem[] = [
{ id: "general", name: "General", description: "Default shared project", active: true },
{ id: "operations", name: "Operations", description: "Cross-team planning and delivery" },
{ id: "hiring", name: "Hiring", description: "Candidate pipeline and interview loops" },
] as const;
export const departmentItems: readonly DepartmentItem[] = [
{ id: "product", name: "Product", teams: ["Design Systems", "Research Ops"], active: true },
{ id: "engineering", name: "Engineering", teams: ["Platform", "Realtime Collaboration"] },
{ id: "operations", name: "Operations", teams: ["Shared Services", "People Ops"] },
] as const;
// Sidebar and topbar scaffold data
// These static entries stay pinned in both desktop and mobile workspace navigation.
export const workspaceStaticItems: readonly WorkspaceStaticItem[] = [
{ id: "home", label: "Home", icon: Home, active: true, contextKind: "home" },
{ id: "workspace-settings", label: "Settings", icon: Settings, contextKind: "settings" },
] as const;
// Freeform workspace tree scaffold: folders, boards, and docs are first-class siblings.
export const workspaceTree: readonly WorkspaceTreeNode[] = [
{
id: "product-workspace",
label: "Product",
kind: "folder",
icon: Folder,
children: [
{ id: "roadmap-board", label: "Roadmap", kind: "board", icon: LayoutGrid, active: true },
{ id: "launch-brief", label: "Launch Brief", kind: "doc", icon: FileText },
{
id: "research-folder",
label: "Research",
kind: "folder",
icon: Folder,
children: [
{ id: "interviews-doc", label: "Interviews", kind: "doc", icon: FileText },
{ id: "signals-board", label: "Signals", kind: "board", icon: LayoutGrid, meta: "2" },
],
},
],
},
{
id: "design-folder",
label: "Design",
kind: "folder",
icon: Folder,
children: [
{ id: "system-doc", label: "Design System", kind: "doc", icon: FileText },
{ id: "review-board", label: "Review Queue", kind: "board", icon: LayoutGrid },
],
},
{ id: "general-notes", label: "General Notes", kind: "doc", icon: FileText },
] as const;
export const workspaceSidebarHeaderActions: readonly SidebarHeaderAction[] = [
{ id: "search-workspace", label: "Search workspace", icon: Search },
] as const;
export const mobileBottomNavItems: readonly MobileBottomNavItem[] = [
{ id: "home", label: "Home", icon: Home, active: true },
{ id: "search", label: "Search", icon: Search },
{ id: "browse", label: "Browse", icon: Folder },
] as const;
// Initial context-menu IA scaffold. Behavior wiring can evolve later, but the
// target kinds and action grouping should stay shared across workspace surfaces.
export const getWorkspaceContextMenuSections = (
target: WorkspaceContextMenuTarget,
): readonly WorkspaceContextMenuSection[] => {
const createActions = [
{ id: "new-folder", label: "New folder", shortcut: { modifiers: ["alt"], key: "f" } },
{ id: "new-board", label: "New board", shortcut: { modifiers: ["alt"], key: "b" } },
{ id: "new-doc", label: "New doc", shortcut: { modifiers: ["alt"], key: "d" } },
] as const;
const createSubmenuAction = {
id: "create",
label: "Create",
children: createActions,
} as const;
switch (target.kind) {
case "workspace":
return [
{
id: "create",
label: undefined,
items: [createSubmenuAction],
},
{
id: "workspace",
label: undefined,
items: [
{ id: "rename-workspace", label: "Rename workspace", shortcut: { key: "enter" } },
{ id: "copy-workspace-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } },
],
},
] as const;
case "home":
return [
{
id: "create",
label: undefined,
items: [createSubmenuAction],
},
{
id: "workspace",
label: undefined,
items: [{ id: "open-home", label: "Open home", shortcut: { key: "enter" } }],
},
] as const;
case "settings":
return [
{
id: "settings",
label: undefined,
items: [
{ id: "open-settings", label: "Open settings", shortcut: { key: "enter" } },
{ id: "copy-settings-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } },
],
},
] as const;
case "folder":
return [
{
id: "open",
items: [
{ id: "open-folder", label: "Open folder", shortcut: { key: "enter" } },
{ id: "rename-folder", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
],
},
{
id: "create",
label: undefined,
items: [createSubmenuAction],
},
{
id: "organize",
label: undefined,
items: [
{ id: "duplicate-folder", label: "Duplicate", shortcut: { modifiers: ["meta"], key: "d" } },
{ id: "move-folder", label: "Move", shortcut: { modifiers: ["meta"], key: "m" } },
{ id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
],
},
] as const;
case "board":
return [
{
id: "board",
items: [
{ id: "open-board", label: "Open board", shortcut: { key: "enter" } },
{ id: "rename-board", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
],
},
{
id: "organize",
label: undefined,
items: [
{ id: "duplicate-board", label: "Duplicate", shortcut: { modifiers: ["meta"], key: "d" } },
{ id: "move-board", label: "Move", shortcut: { modifiers: ["meta"], key: "m" } },
{ id: "delete-board", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
],
},
] as const;
case "doc":
return [
{
id: "doc",
items: [
{ id: "open-doc", label: "Open doc", shortcut: { key: "enter" } },
{ id: "rename-doc", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
],
},
{
id: "organize",
label: undefined,
items: [
{ id: "duplicate-doc", label: "Duplicate", shortcut: { modifiers: ["meta"], key: "d" } },
{ id: "move-doc", label: "Move", shortcut: { modifiers: ["meta"], key: "m" } },
{ id: "delete-doc", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
],
},
] as const;
}
};
export const topBarActions: readonly TopBarAction[] = [
{ id: "search", label: "Search", icon: Search },
] as const;
export const notificationItems: readonly NotificationItem[] = [
{
id: "comment-design-systems",
title: "New comment on Design Systems",
contextLabel: "Product • Review thread updated",
timeLabel: "2m ago",
unread: true,
},
{
id: "sprint-platform",
title: "Sprint updated in Platform",
contextLabel: "Engineering • Scope changed",
timeLabel: "15m ago",
unread: true,
},
{
id: "member-joined",
title: "New member joined Operations",
contextLabel: "Organization Name • Access granted",
timeLabel: "1h ago",
},
{
id: "daily-summary",
title: "Daily summary is ready",
contextLabel: "General • 8 updates across boards",
timeLabel: "Today, 8:00 AM",
},
] as const;
export const unreadNotificationCount = notificationItems.filter((item) => item.unread).length;
export const activeUserProfile: ActiveUserProfile = {
name: "Demo Account",
email: "demo@moku.work",
roleLabel: "Founder · Product",
contextLabel: "Organization Name • Design Systems",
};
export const profileMenuSections: readonly ProfileMenuSection[] = [
{
id: "account",
items: [
{ id: "profile", label: "Profile", icon: User },
{ id: "account-settings", label: "Account Settings", icon: Settings },
{ id: "notifications", label: "Notifications", icon: Bell },
{ id: "security", label: "Security", icon: Shield },
],
},
{
id: "preferences",
items: [
{ id: "keyboard-shortcuts", label: "Keyboard Shortcuts", icon: Keyboard },
{ id: "theme-preferences", label: "Theme Preferences", icon: Settings },
{ id: "help-support", label: "Help & Support", icon: CircleHelp },
],
},
{
id: "session",
items: [
{ id: "switch-account", label: "Switch Account", icon: Repeat },
{ id: "sign-out", label: "Sign Out", icon: LogOut, tone: "danger" },
],
},
] as const;
@@ -1,22 +1,29 @@
import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js"; import { For, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js";
import { ChevronDown } from "../../../lib/icons"; import { ChevronDown } from "../../../lib/icons";
import { activeDepartment, departmentItems, type DepartmentItem } from "../data/shell.data"; import { useAppShellData } from "../../app-shell/data/app-shell.context";
import { type DepartmentItem } from "../../app-shell/data/shell.data";
import styles from "./DepartmentSelector.module.scss"; import styles from "./DepartmentSelector.module.scss";
const defaultDepartment = departmentItems.find((item) => item.id === activeDepartment.id) ?? departmentItems[0];
const defaultTeamName = departmentItems
.find((item) => item.id === activeDepartment.id)
?.teams.find((teamName) => teamName === activeDepartment.teamName)
?? defaultDepartment?.teams[0]
?? "";
export const DepartmentSelector = (): JSX.Element => { export const DepartmentSelector = (): JSX.Element => {
const appShellData = useAppShellData();
const [isOpen, setIsOpen] = createSignal(false); const [isOpen, setIsOpen] = createSignal(false);
const [selectedDepartment, setSelectedDepartment] = createSignal<DepartmentItem>(defaultDepartment); const [selectedDepartment, setSelectedDepartment] = createSignal<DepartmentItem>(appShellData.departmentItems()[0] ?? appShellData.activeDepartment());
const [selectedTeamName, setSelectedTeamName] = createSignal(defaultTeamName); const [selectedTeamName, setSelectedTeamName] = createSignal(appShellData.activeDepartment().teamName);
let rootRef: HTMLDivElement | undefined; let rootRef: HTMLDivElement | undefined;
createEffect(() => {
const activeDepartment = appShellData.activeDepartment();
const matchingDepartment = appShellData.departmentItems().find((item) => item.id === activeDepartment.id) ?? appShellData.departmentItems()[0];
if (!matchingDepartment) {
return;
}
setSelectedDepartment(matchingDepartment);
setSelectedTeamName(activeDepartment.teamName || matchingDepartment.teams[0] || "");
});
onMount(() => { onMount(() => {
const handlePointerDown = (event: PointerEvent): void => { const handlePointerDown = (event: PointerEvent): void => {
if (!isOpen()) return; if (!isOpen()) return;
@@ -66,7 +73,7 @@ export const DepartmentSelector = (): JSX.Element => {
<div class={styles.menuSection}> <div class={styles.menuSection}>
<span class={styles.menuSectionLabel}>Departments</span> <span class={styles.menuSectionLabel}>Departments</span>
<For each={departmentItems}> <For each={appShellData.departmentItems()}>
{(item): JSX.Element => ( {(item): JSX.Element => (
<button <button
classList={{ [styles.menuItem]: true, [styles.menuItemActive]: item.id === selectedDepartment().id }} classList={{ [styles.menuItem]: true, [styles.menuItemActive]: item.id === selectedDepartment().id }}
@@ -1,6 +1,6 @@
import type { JSX } from "solid-js"; import type { JSX } from "solid-js";
import { Bell } from "../../../lib/icons"; import { Bell } from "../../../lib/icons";
import { unreadNotificationCount } from "../data/shell.data"; import { unreadNotificationCount } from "../../app-shell/data/shell.data";
import styles from "./NotificationsButton.module.scss"; import styles from "./NotificationsButton.module.scss";
type NotificationsButtonProps = { type NotificationsButtonProps = {
@@ -1,6 +1,6 @@
import { For, Show, type JSX } from "solid-js"; import { For, Show, type JSX } from "solid-js";
import { Bell, Settings } from "../../../lib/icons"; import { Bell, Settings } from "../../../lib/icons";
import { notificationItems, unreadNotificationCount } from "../data/shell.data"; import { notificationItems, unreadNotificationCount } from "../../app-shell/data/shell.data";
import styles from "./NotificationsMenu.module.scss"; import styles from "./NotificationsMenu.module.scss";
type NotificationsMenuProps = { type NotificationsMenuProps = {
@@ -27,9 +27,11 @@ export const NotificationsMenu = (props: NotificationsMenuProps): JSX.Element =>
role="menu" role="menu"
aria-label="Notifications" aria-label="Notifications"
ref={props.menuRef} ref={props.menuRef}
data-ui="notifications-menu"
data-variant={variant}
> >
<div class={styles.header}> <div class={styles.header} data-slot="notifications-header">
<div class={styles.headerCopy}> <div class={styles.headerCopy} data-slot="notifications-header-copy">
<strong class={styles.title}>Notifications</strong> <strong class={styles.title}>Notifications</strong>
<span class={styles.subtitle}> <span class={styles.subtitle}>
{unreadNotificationCount > 0 {unreadNotificationCount > 0
@@ -45,7 +47,7 @@ export const NotificationsMenu = (props: NotificationsMenuProps): JSX.Element =>
</Show> </Show>
</div> </div>
<div class={styles.listWrap}> <div class={styles.listWrap} data-slot="notifications-body">
<Show when={!hasNotifications}> <Show when={!hasNotifications}>
<div class={styles.stateCard}> <div class={styles.stateCard}>
<span class={styles.stateIcon} aria-hidden="true"> <span class={styles.stateIcon} aria-hidden="true">
@@ -67,12 +69,12 @@ export const NotificationsMenu = (props: NotificationsMenuProps): JSX.Element =>
</Show> </Show>
<Show when={unreadItems.length > 0}> <Show when={unreadItems.length > 0}>
<section class={styles.section} aria-label="Unread notifications"> <section class={styles.section} aria-label="Unread notifications" data-slot="notifications-section" data-section-id="unread">
<span class={styles.sectionLabel}>Unread</span> <span class={styles.sectionLabel}>Unread</span>
<div class={styles.list}> <div class={styles.list} data-slot="notifications-list" data-section-id="unread">
<For each={unreadItems}> <For each={unreadItems}>
{(item): JSX.Element => ( {(item): JSX.Element => (
<button type="button" role="menuitem" classList={{ [styles.item]: true, [styles.itemUnread]: true }} onClick={props.onSelect}> <button type="button" role="menuitem" classList={{ [styles.item]: true, [styles.itemUnread]: true }} data-slot="notification-item" data-state="unread" onClick={props.onSelect}>
<span class={styles.itemMarker} aria-hidden="true" /> <span class={styles.itemMarker} aria-hidden="true" />
<div class={styles.itemBody}> <div class={styles.itemBody}>
<span class={styles.itemTitle}>{item.title}</span> <span class={styles.itemTitle}>{item.title}</span>
@@ -87,12 +89,12 @@ export const NotificationsMenu = (props: NotificationsMenuProps): JSX.Element =>
</Show> </Show>
<Show when={earlierItems.length > 0}> <Show when={earlierItems.length > 0}>
<section class={styles.section} aria-label="Earlier notifications"> <section class={styles.section} aria-label="Earlier notifications" data-slot="notifications-section" data-section-id="earlier">
<span class={styles.sectionLabel}>Earlier</span> <span class={styles.sectionLabel}>Earlier</span>
<div class={styles.list}> <div class={styles.list} data-slot="notifications-list" data-section-id="earlier">
<For each={earlierItems}> <For each={earlierItems}>
{(item): JSX.Element => ( {(item): JSX.Element => (
<button type="button" role="menuitem" class={styles.item} onClick={props.onSelect}> <button type="button" role="menuitem" class={styles.item} data-slot="notification-item" data-state="read" onClick={props.onSelect}>
<span class={styles.itemMarkerMuted} aria-hidden="true" /> <span class={styles.itemMarkerMuted} aria-hidden="true" />
<div class={styles.itemBody}> <div class={styles.itemBody}>
<span class={styles.itemTitle}>{item.title}</span> <span class={styles.itemTitle}>{item.title}</span>
@@ -107,7 +109,7 @@ export const NotificationsMenu = (props: NotificationsMenuProps): JSX.Element =>
</Show> </Show>
</div> </div>
<div class={styles.footer}> <div class={styles.footer} data-slot="notifications-footer">
<button type="button" role="menuitem" class={styles.footerAction} onClick={props.onSelect}> <button type="button" role="menuitem" class={styles.footerAction} onClick={props.onSelect}>
<Settings size={16} strokeWidth={2} /> <Settings size={16} strokeWidth={2} />
<span>Notification settings</span> <span>Notification settings</span>
@@ -1,6 +1,7 @@
import { For, type JSX } from "solid-js"; import { For, type JSX } from "solid-js";
import { User } from "../../../lib/icons"; import { User } from "../../../lib/icons";
import { activeUserProfile, profileMenuSections } from "../data/shell.data"; import { useAppShellData } from "../../app-shell/data/app-shell.context";
import { profileMenuSections } from "../../app-shell/data/shell.data";
import styles from "./ProfileMenu.module.scss"; import styles from "./ProfileMenu.module.scss";
type ProfileMenuProps = { type ProfileMenuProps = {
@@ -12,6 +13,8 @@ type ProfileMenuProps = {
export const ProfileMenu = (props: ProfileMenuProps): JSX.Element => { export const ProfileMenu = (props: ProfileMenuProps): JSX.Element => {
const variant = props.variant ?? "popover"; const variant = props.variant ?? "popover";
const appShellData = useAppShellData();
const activeUserProfile = () => appShellData.activeUserProfile();
return ( return (
<div <div
@@ -23,8 +26,10 @@ export const ProfileMenu = (props: ProfileMenuProps): JSX.Element => {
role="menu" role="menu"
aria-label="Profile menu" aria-label="Profile menu"
ref={props.menuRef} ref={props.menuRef}
data-ui="profile-menu"
data-variant={variant}
> >
<div class={styles.summary}> <div class={styles.summary} data-slot="profile-summary">
<div class={styles.avatar} aria-hidden="true"> <div class={styles.avatar} aria-hidden="true">
<span class={styles.avatarRing} /> <span class={styles.avatarRing} />
<span class={styles.avatarCore}> <span class={styles.avatarCore}>
@@ -33,17 +38,17 @@ export const ProfileMenu = (props: ProfileMenuProps): JSX.Element => {
</div> </div>
<div class={styles.summaryCopy}> <div class={styles.summaryCopy}>
<strong class={styles.name}>{activeUserProfile.name}</strong> <strong class={styles.name}>{activeUserProfile().name}</strong>
<span class={styles.email}>{activeUserProfile.email}</span> <span class={styles.email}>{activeUserProfile().email}</span>
<span class={styles.role}>{activeUserProfile.roleLabel}</span> <span class={styles.role}>{activeUserProfile().roleLabel}</span>
<span class={styles.context}>{activeUserProfile.contextLabel}</span> <span class={styles.context}>{activeUserProfile().contextLabel}</span>
</div> </div>
</div> </div>
<div class={styles.sections}> <div class={styles.sections} data-slot="profile-sections">
<For each={profileMenuSections}> <For each={profileMenuSections}>
{(section): JSX.Element => ( {(section): JSX.Element => (
<div class={styles.section}> <div class={styles.section} data-slot="profile-section" data-section-id={section.id}>
<For each={section.items}> <For each={section.items}>
{(item): JSX.Element => { {(item): JSX.Element => {
const Icon = item.icon; const Icon = item.icon;
@@ -56,6 +61,9 @@ export const ProfileMenu = (props: ProfileMenuProps): JSX.Element => {
[styles.item]: true, [styles.item]: true,
[styles.itemDanger]: item.tone === "danger", [styles.itemDanger]: item.tone === "danger",
}} }}
data-slot="profile-action"
data-action-id={item.id}
data-tone={item.tone ?? "default"}
onClick={props.onSelect} onClick={props.onSelect}
> >
<span class={styles.itemIcon} aria-hidden="true"> <span class={styles.itemIcon} aria-hidden="true">
@@ -1,7 +1,7 @@
// Path: Frontend/src/components/shell/TopBar/ThemeToggle.tsx // Path: Frontend/src/components/top-bar/TopBar/ThemeToggle.tsx
import type { JSX } from "solid-js"; import type { JSX } from "solid-js";
import type { Theme } from "../../../theme/runtime"; import type { Theme } from "../../../helper/themeRuntime";
import { Moon, Sun } from "../../../lib/icons"; import { Moon, Sun } from "../../../lib/icons";
import styles from "./ThemeToggle.module.scss"; import styles from "./ThemeToggle.module.scss";
@@ -1,8 +1,8 @@
// Path: Frontend/src/components/shell/TopBar/TopBar.tsx // Path: Frontend/src/components/top-bar/TopBar/TopBar.tsx
import { For, type JSX } from "solid-js"; import { For, type JSX } from "solid-js";
import type { Theme } from "../../../theme/runtime"; import type { Theme } from "../../../helper/themeRuntime";
import { topBarActions } from "../data/shell.data"; import { topBarActions } from "../../app-shell/data/shell.data";
import { DepartmentSelector } from "../DepartmentSelector/DepartmentSelector"; import { DepartmentSelector } from "../DepartmentSelector/DepartmentSelector";
import { NotificationsNav } from "./NotificationsNav"; import { NotificationsNav } from "./NotificationsNav";
import { ThemeToggle } from "./ThemeToggle"; import { ThemeToggle } from "./ThemeToggle";
@@ -21,20 +21,20 @@ type TopBarProps = {
export const TopBar = (props: TopBarProps): JSX.Element => { export const TopBar = (props: TopBarProps): JSX.Element => {
return ( return (
<header class={styles.topBar}> <header class={styles.topBar} data-ui="top-bar">
<div class={styles.identity}> <div class={styles.identity} data-slot="top-bar-identity">
<span class={styles.eyebrow}>Moku Work</span> <span class={styles.eyebrow}>Moku Work</span>
<DepartmentSelector /> <DepartmentSelector />
</div> </div>
<div class={styles.controls}> <div class={styles.controls} data-slot="top-bar-controls">
<div class={styles.actions}> <div class={styles.actions} data-slot="top-bar-actions">
<For each={topBarActions}> <For each={topBarActions}>
{(item): JSX.Element => { {(item): JSX.Element => {
const Icon = item.icon; const Icon = item.icon;
return ( return (
<button class={styles.actionButton} type="button" aria-label={item.label} title={item.label}> <button class={styles.actionButton} type="button" aria-label={item.label} title={item.label} data-slot="top-bar-action" data-action-id={item.id}>
<Icon size={18} strokeWidth={2} /> <Icon size={18} strokeWidth={2} />
</button> </button>
); );
@@ -1,4 +1,4 @@
// Path: Frontend/src/components/shell/TopBar/UserNavButton.tsx // Path: Frontend/src/components/top-bar/TopBar/UserNavButton.tsx
import type { JSX } from "solid-js"; import type { JSX } from "solid-js";
import { User } from "../../../lib/icons"; import { User } from "../../../lib/icons";

Some files were not shown because too many files have changed in this diff Show More