Compare commits

..

2 Commits

Author SHA1 Message Date
MangoPig dc5a2495fa Merge branch 'Features/Frontend/CollapsibleShell' 2026-06-17 05:29:14 +01:00
MangoPig 52fc9001c5 Feat: Add collapsible shell 2026-06-17 05:28:48 +01:00
212 changed files with 1232 additions and 19938 deletions
-5
View File
@@ -6,7 +6,6 @@ dist/
# dependencies
node_modules/
Frontend/.pnpm-store/
# logs
npm-debug.log*
@@ -26,7 +25,3 @@ pnpm-debug.log*
# Go build output
tmp/
bin/
.cgcignore
POSIX/
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<square310x310logo src="/mstile-310x310.png"/>
<TileColor>#ffffff</TileColor>
</tile>
</msapplication>
</browserconfig>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 459 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

-19
View File
@@ -1,19 +0,0 @@
{
"name": "Moku Work",
"short_name": "Moku Work",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
-3
View File
@@ -6,9 +6,6 @@ WORKDIR /app
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 ./
RUN go mod download
-52
View File
@@ -1,52 +0,0 @@
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
}
+3 -25
View File
@@ -1,16 +1,10 @@
package main
import (
"context"
"encoding/json"
"log"
"os/signal"
"syscall"
"time"
"moku-backend/internal/bootstrap"
"moku-backend/internal/jobs"
"moku-backend/internal/worker"
"moku-backend/internal/process"
)
func main() {
@@ -24,25 +18,9 @@ func main() {
}
}()
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
}
}
app.Logger.Info("worker ready", "service", app.ServiceName, "environment", app.Config.Environment)
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 {
if err := process.WaitForShutdown(app.ServiceName, app.Logger); err != nil {
app.Logger.Error("worker stopped", "error", err)
log.Fatal(err)
}
@@ -1,180 +0,0 @@
-- +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;
@@ -1,9 +0,0 @@
-- +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;
@@ -1,45 +0,0 @@
-- +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;
@@ -1,64 +0,0 @@
-- +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;
+3 -39
View File
@@ -23,50 +23,14 @@ target "dev-image" {
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" {
targets = ["dev", "prod-api", "prod-worker"]
targets = ["dev"]
}
group "registry" {
targets = ["dev-image", "prod-api-image", "prod-worker-image"]
targets = ["dev-image"]
}
group "default" {
targets = ["dev", "prod-api", "prod-worker"]
targets = ["dev"]
}
-3
View File
@@ -3,13 +3,11 @@ module moku-backend
go 1.25.7
require (
github.com/fxamacker/cbor/v2 v2.9.0
github.com/go-chi/chi/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
github.com/pressly/goose/v3 v3.27.1
github.com/redis/go-redis/v9 v9.20.1
github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a
)
require (
@@ -19,7 +17,6 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/mfridman/interpolate v0.0.2 // 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/multierr v1.11.0 // indirect
golang.org/x/sync v0.20.0 // indirect
-8
View File
@@ -9,12 +9,8 @@ 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/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/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/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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -48,10 +44,6 @@ 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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
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/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
@@ -1,81 +0,0 @@
// 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)
}
@@ -1,159 +0,0 @@
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
}
@@ -1,180 +0,0 @@
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
}
@@ -1,73 +0,0 @@
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
}
@@ -1,314 +0,0 @@
// 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"`
ThemePresetID string `json:"themePresetId,omitempty"`
}
type SaveThemePresetInput struct {
PresetID string
}
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
}
@@ -1,48 +0,0 @@
// 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"))
}
@@ -1,260 +0,0 @@
// 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,
"theme": map[string]any{
"presetId": "moku-midnight",
},
}); 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
}
@@ -1,252 +0,0 @@
// 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
}
@@ -1,233 +0,0 @@
// 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
}
}
@@ -1,189 +0,0 @@
// 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
}
@@ -1,491 +0,0 @@
// 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
@@ -1,471 +0,0 @@
// 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
}
}
@@ -1,346 +0,0 @@
// 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()
}
-361
View File
@@ -1,361 +0,0 @@
// 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
}
themePresetID, err := service.loadAdminThemePresetID(ctx, record.DisplayName)
if err != nil {
return nil, err
}
if themePresetID != "" {
record.ThemePresetID = themePresetID
}
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
}
-735
View File
@@ -1,735 +0,0 @@
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"])
}
personalTheme, ok := personalSettings["theme"].(map[string]any)
if !ok {
t.Fatalf("expected personal settings to contain theme object, got %#v", personalSettings)
}
if personalTheme["presetId"] != defaultThemePresetID {
t.Fatalf("expected personal theme preset %s, got %#v", defaultThemePresetID, personalTheme["presetId"])
}
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 TestPersonalSettingsPathForDisplayName(t *testing.T) {
path := personalSettingsPathForDisplayName(" Ronald ")
if path != "users/personals/personal-ronald/settings.cbor" {
t.Fatalf("unexpected personal settings path: %s", path)
}
}
func TestApplyThemePresetToSettingsPreservesExistingFields(t *testing.T) {
settings := map[string]any{
"type": "personal",
"slug": "ronald",
"theme": map[string]any{
"density": "comfortable",
},
}
updated := applyThemePresetToSettings(settings, "moku-default")
if updated["type"] != "personal" {
t.Fatalf("expected type personal, got %#v", updated["type"])
}
theme, ok := updated["theme"].(map[string]any)
if !ok {
t.Fatalf("expected theme object, got %#v", updated["theme"])
}
if theme["presetId"] != "moku-default" {
t.Fatalf("expected presetId moku-default, got %#v", theme["presetId"])
}
if theme["density"] != "comfortable" {
t.Fatalf("expected density to be preserved, got %#v", theme["density"])
}
}
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
}
@@ -1,111 +0,0 @@
package bootstrap
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"github.com/jackc/pgx/v5"
)
const defaultThemePresetID = "moku-midnight"
func personalSettingsPathForDisplayName(displayName string) string {
personalName := strings.TrimSpace(displayName)
if personalName == "" {
personalName = defaultPersonalDisplayName
}
personalSlug := normalizePOSIXSlug(personalName)
return filepath.ToSlash(filepath.Join("users", "personals", slugDir("personal", personalSlug), posixSettingsFileName))
}
func extractThemePresetID(settings map[string]any) string {
theme, ok := settings["theme"].(map[string]any)
if !ok {
return ""
}
presetID, _ := theme["presetId"].(string)
return strings.TrimSpace(presetID)
}
func applyThemePresetToSettings(settings map[string]any, presetID string) map[string]any {
normalized := settings
if normalized == nil {
normalized = map[string]any{}
}
theme, ok := normalized["theme"].(map[string]any)
if !ok || theme == nil {
theme = map[string]any{}
}
theme["presetId"] = presetID
normalized["theme"] = theme
return normalized
}
func (service *Service) SaveThemePreset(ctx context.Context, input SaveThemePresetInput) (*AdminRecord, error) {
admin, err := service.GetAdmin(ctx)
if err != nil {
return nil, err
}
if admin == nil {
return nil, ErrAdminNotConfigured
}
presetID := strings.TrimSpace(input.PresetID)
if presetID == "" {
return nil, fmt.Errorf("theme preset id is required")
}
rootPath := strings.TrimSpace(service.posixRoot)
if rootPath == "" {
return nil, fmt.Errorf("posix root is not configured")
}
relativeSettingsPath := personalSettingsPathForDisplayName(admin.DisplayName)
absoluteSettingsPath := filepath.Join(rootPath, filepath.FromSlash(relativeSettingsPath))
settings := readStructuredFileMap(absoluteSettingsPath)
if len(settings) == 0 {
return nil, fmt.Errorf("personal settings file is missing")
}
updatedSettings := applyThemePresetToSettings(settings, presetID)
if err := writeCBORFile(absoluteSettingsPath, updatedSettings); err != nil {
return nil, fmt.Errorf("write personal %s: %w", posixSettingsFileName, err)
}
if err := service.rebuildProjection(ctx); err != nil {
return nil, err
}
return service.GetAdmin(ctx)
}
func (service *Service) loadAdminThemePresetID(ctx context.Context, displayName string) (string, error) {
path := personalSettingsPathForDisplayName(displayName)
var presetID *string
err := service.db.Pool.QueryRow(ctx, `
SELECT content_json->'theme'->>'presetId'
FROM posix_nodes
WHERE path = $1
LIMIT 1;
`, path).Scan(&presetID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
return "", err
}
if presetID == nil {
return "", nil
}
return strings.TrimSpace(*presetID), nil
}
-2
View File
@@ -17,7 +17,6 @@ type Config struct {
APIPort string
PostgresURL string
ValkeyURL string
POSIXRoot string
ShutdownTimeout time.Duration
}
@@ -30,7 +29,6 @@ func Load() *Config {
APIPort: getEnv("BACKEND_API_PORT", "8081"),
PostgresURL: getEnv("DATABASE_URL", "postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable"),
ValkeyURL: getEnv("VALKEY_URL", "redis://localhost:6379/0"),
POSIXRoot: getEnv("POSIX_ROOT", "../POSIX"),
ShutdownTimeout: getDurationEnv("BACKEND_SHUTDOWN_TIMEOUT", 10*time.Second),
}
}
@@ -1,396 +0,0 @@
// 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
}
@@ -1,127 +0,0 @@
// 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
}
@@ -1,42 +0,0 @@
// 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)
}
}
@@ -1,510 +0,0 @@
// 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,
},
})
}
-33
View File
@@ -19,41 +19,8 @@ func newAPIRoutes(cfg RouterConfig) routeRegistrar {
func (routes apiRoutes) Register(router chi.Router) {
router.Route("/v1", func(apiRouter chi.Router) {
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.Put("/settings/theme", routes.handleSaveThemePreset)
apiRouter.Get("/organizations", routes.handleOrganizations)
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)
}
})
}
@@ -1,43 +0,0 @@
package httpx
import (
"net/http"
"strings"
bootstrapservice "moku-backend/internal/bootstrap"
)
type saveThemePresetRequest struct {
PresetID string `json:"presetId"`
}
func (routes apiRoutes) handleSaveThemePreset(w http.ResponseWriter, r *http.Request) {
payload, ok := decodeBootstrapRequest[saveThemePresetRequest](w, r)
if !ok {
return
}
payload.PresetID = strings.TrimSpace(payload.PresetID)
if payload.PresetID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Theme preset ID is required.")
return
}
admin, err := routes.bootstrapService().SaveThemePreset(r.Context(), bootstrapservice.SaveThemePresetInput{
PresetID: payload.PresetID,
})
if err != nil {
routes.writeBootstrapPersistenceError(w, r, err)
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"presetId": payload.PresetID,
"admin": admin,
},
"meta": map[string]any{
"resource": "settings-theme",
},
})
}
-198
View File
@@ -1,198 +0,0 @@
// 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
@@ -1,104 +0,0 @@
// 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
}
@@ -1,227 +0,0 @@
// 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 ""
}
@@ -1,277 +0,0 @@
// 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)
}
@@ -1,316 +0,0 @@
// 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)
}
}
@@ -1,58 +0,0 @@
// 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
@@ -1,126 +0,0 @@
// 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
@@ -1,211 +0,0 @@
// 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
}
+6 -19
View File
@@ -1,39 +1,26 @@
project_root := justfile_directory()
backend_dir := project_root + "/Backend"
common_sh := project_root + "/Commands/Local/scripts/common.sh"
posix_root := project_root + "/POSIX"
# Apply embedded database migrations.
migrate-up:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"'
cd '{{backend_dir}}' && go run ./cmd/migrate up
# Roll back the most recent embedded database migration (confirmation required).
# Roll back the most recent embedded database migration.
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
# Reset all embedded database migrations (confirmation required).
# Reset all embedded database migrations and reapply from scratch.
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
# 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.
migrate-status:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"'
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.
fmt:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"'
cd '{{backend_dir}}' && gofmt -w ./cmd ./db ./internal
# Run backend test suite.
test:
cd '{{backend_dir}}' && go test ./...
+1 -5
View File
@@ -2,17 +2,13 @@ project_root := justfile_directory()
local_compose := project_root + "/Docker/docker-compose.local.dev.yaml"
frontend_dir := project_root + "/Frontend"
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 (confirmation required).
# Recreate the frontend node_modules Docker volume.
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 volume rm -f '{{node_modules_volume}}' >/dev/null 2>&1 || true
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate frontend
# Run the frontend TypeScript check.
tsc:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"'
cd '{{frontend_dir}}' && pnpm typecheck
+2 -2
View File
@@ -18,7 +18,7 @@ start:
# Alias for the main full local development flow.
dev: up
# Stop and remove the local development stack (confirmation required).
# Stop and remove the local development stack.
down:
bash '{{stack_runner}}' down
@@ -34,6 +34,6 @@ logs:
restart:
bash '{{stack_runner}}' restart
# Stop the local development stack and remove local images, volumes, backend dev state, and the local POSIX folder (confirmation required).
# Stop the local development stack and remove local images, volumes, and backend dev state.
clean:
bash '{{stack_runner}}' clean
+5 -19
View File
@@ -7,7 +7,6 @@ action=${1:-up}
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
project_root=$(cd -- "$script_dir/../../../.." && pwd)
posix_root="$project_root/POSIX"
backend_dir="$project_root/Backend"
backend_bake="$backend_dir/docker-bake.hcl"
env_dir="$project_root/Env"
@@ -16,14 +15,11 @@ runtime_dir="$backend_dir/tmp/dev"
backend_image="moku/work-backend:dev"
backend_go_pkg_volume="moku_work_backend_go_pkg"
backend_go_build_volume="moku_work_backend_go_build"
local_uid=$(id -u)
local_gid=$(id -g)
services=(web api worker)
source "$script_dir/docker.sh"
source "$script_dir/env.sh"
source "$project_root/Commands/Local/scripts/common.sh"
build_backend() {
cd "$backend_dir"
@@ -31,20 +27,20 @@ build_backend() {
}
up_backend() {
LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" up -d --remove-orphans --force-recreate "${services[@]}"
docker compose -f "$compose_file" up -d --remove-orphans --force-recreate "${services[@]}"
}
down_backend() {
LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" 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" rm -f "${services[@]}" >/dev/null 2>&1 || true
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
}
restart_backend() {
LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" restart "${services[@]}"
docker compose -f "$compose_file" restart "${services[@]}"
}
follow_logs() {
LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" logs -f "${services[@]}"
docker compose -f "$compose_file" logs -f "${services[@]}"
}
clean_runtime() {
@@ -54,44 +50,34 @@ clean_runtime() {
case "$action" in
check)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_posix_root "$project_root" "$posix_root"
;;
build)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_posix_root "$project_root" "$posix_root"
build_backend
;;
up)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_local_env_file "$env_dir"
ensure_posix_root "$project_root" "$posix_root"
up_backend
run_compose_api_migrations "$compose_file" "api"
;;
down)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
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
;;
restart)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_local_env_file "$env_dir"
ensure_posix_root "$project_root" "$posix_root"
restart_backend
;;
logs)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_local_env_file "$env_dir"
ensure_posix_root "$project_root" "$posix_root"
follow_logs
;;
clean)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
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
remove_docker_image_if_present "$backend_image"
remove_docker_volume_if_present "$backend_go_pkg_volume"
+5 -19
View File
@@ -7,15 +7,12 @@ action=${1:-up}
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
project_root=$(cd -- "$script_dir/../../../.." && pwd)
posix_root="$project_root/POSIX"
frontend_dir="$project_root/Frontend"
frontend_bake="$frontend_dir/docker-bake.hcl"
backend_dir="$project_root/Backend"
backend_bake="$backend_dir/docker-bake.hcl"
env_dir="$project_root/Env"
compose_file="$project_root/Docker/docker-compose.local.dev.yaml"
local_uid=$(id -u)
local_gid=$(id -g)
frontend_image="moku/work-frontend:dev"
backend_image="moku/work-backend:dev"
frontend_volume="moku_work_frontend_node_modules"
@@ -25,7 +22,6 @@ backend_runtime_dir="$backend_dir/tmp/dev"
source "$script_dir/docker.sh"
source "$script_dir/env.sh"
source "$project_root/Commands/Local/scripts/common.sh"
build_frontend() {
cd "$frontend_dir"
@@ -43,19 +39,19 @@ build_images() {
}
up_stack() {
LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" up -d --remove-orphans --force-recreate
docker compose -f "$compose_file" up -d --remove-orphans --force-recreate
}
down_stack() {
LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" down --remove-orphans --volumes
docker compose -f "$compose_file" down --remove-orphans --volumes
}
follow_logs() {
LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" logs -f
docker compose -f "$compose_file" logs -f
}
clean_stack() {
LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" down --remove-orphans --volumes >/dev/null 2>&1 || true
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 "$backend_image"
remove_docker_volume_if_present "$frontend_volume"
@@ -67,47 +63,37 @@ clean_stack() {
start_stack() {
build_images
up_stack
run_compose_api_migrations "$compose_file" "api"
}
case "$action" in
build)
ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_posix_root "$project_root" "$posix_root"
build_images
;;
up|start|rebuild)
ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_local_env_file "$env_dir"
ensure_posix_root "$project_root" "$posix_root"
start_stack
;;
down)
ensure_docker 'docker is required for the local development stack. Install Docker first.'
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
;;
restart)
ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_local_env_file "$env_dir"
ensure_posix_root "$project_root" "$posix_root"
LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" restart
docker compose -f "$compose_file" restart
;;
logs)
ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_local_env_file "$env_dir"
ensure_posix_root "$project_root" "$posix_root"
follow_logs
;;
clean)
ensure_docker 'docker is required for the local development stack. Install Docker first.'
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
rm -rf "$posix_root"
;;
*)
printf 'Unsupported dev stack action: %s\n' "$action" >&2
+8 -24
View File
@@ -1,53 +1,37 @@
project_root := justfile_directory()
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"
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:
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 '{{backend_bake}}' prod-api prod-worker
# Start the local production stack in the background using the current image.
up:
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
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
# Build first, then start the local production stack in the background.
start: build up
# Rebuild the local production proxy image locally.
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 '{{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
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
# Stop and remove the local production stack (confirmation required).
# Stop and remove the local production stack.
down:
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
docker compose -f '{{local_compose}}' down --remove-orphans --volumes
# Follow logs for the local production stack.
logs:
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
docker compose -f '{{local_compose}}' logs -f
# Restart the local production stack.
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
docker compose -f '{{local_compose}}' restart
# Stop the local production stack and remove local images (confirmation required).
# Stop the local production stack and remove local images.
clean:
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 compose -f '{{local_compose}}' down --remove-orphans --volumes
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
@@ -1,65 +0,0 @@
#!/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
@@ -1,11 +0,0 @@
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
@@ -1 +0,0 @@
mod backend
+2 -8
View File
@@ -1,16 +1,11 @@
x-backend-service: &backend-service
image: moku/work-backend:dev
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
HOME: /tmp/home
GOMODCACHE: /tmp/go/pkg/mod
GOCACHE: /tmp/go-build
depends_on:
postgres:
condition: service_healthy
@@ -18,9 +13,8 @@ x-backend-service: &backend-service
condition: service_healthy
volumes:
- ../Backend:/app
- ../POSIX:/posix
- moku_work_backend_go_pkg:/tmp/go/pkg/mod
- moku_work_backend_go_build:/tmp/go-build
- moku_work_backend_go_pkg:/go/pkg/mod
- moku_work_backend_go_build:/root/.cache/go-build
services:
postgres:
+6 -70
View File
@@ -1,71 +1,7 @@
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:
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:
image: moku/work-proxy:local-prod
container_name: moku-work-proxy-local
restart: unless-stopped
depends_on:
api:
condition: service_started
ports:
- "8080:80"
volumes:
moku_work_postgres_prod_data:
moku_work_valkey_prod_data:
proxy:
image: moku/work-proxy:local-prod
container_name: moku-work-proxy-local
restart: unless-stopped
ports:
- "8080:80"
-81
View File
@@ -1,81 +0,0 @@
# 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.
+52 -114
View File
@@ -4,149 +4,87 @@
### Version 0.1.0
**Goal:** Finish the base application shell, auth, and platform foundations.
**Goal:** Barebone frontend with a real backend core.
#### Architecture and Delivery
#### Architecture
- [x] Project-Structure
- [x] Stack-Decisions
- [x] Proxy
- [x] Local-Dev-Vite-Proxy
- [x] Local-Prod-NGINX-Proxy
- [x] First-Request-Web-Loader
- [x] Bootstrap-Document
- [x] Route-Intent-Handoff
- [x] Tiny-First-Paint-Budget
- [x] Dev-and-Prod-Builds
- [ ] Project-Structure
- [ ] Stack-Decisions
- [ ] Proxy
- [ ] Local-Prod-NGINX-Proxy
- [ ] Static-Frontend-Serving
- [ ] First-Request-Web-Loader
- [ ] Bootstrap-Document
- [ ] Route-Intent-Handoff
- [ ] Tiny-First-Paint-Budget
- [ ] Dev-and-Prod-Builds
- [x] Local-Dev-Just-Commands
- [x] Local-Dev-Docker-Compose
- [x] Local-Prod-Just-Commands
- [x] Local-Prod-Docker-Compose
- [x] Frontend-Production-Dockerfile
- [x] Frontend-docker-bake
- [ ] Local-Prod-Just-Commands
- [ ] Local-Prod-Docker-Compose
- [ ] Frontend-Production-Dockerfile
- [ ] Frontend-docker-bake
#### 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
#### Backend
- [ ] Auth
- [ ] Session-Flow
- [ ] Login-Logout-Foundation
- [ ] Authentication
- [ ] Current-User-Implementation
- [ ] 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
- [ ] User
- [ ] Base-Model
- [ ] Base-Workspace
- [ ] Boards
- [ ] Dashboard
- [ ] Folders-and-Subfolders
- [ ] Boards
- [ ] Dashboard
- [ ] Organization
- [ ] Base-Model
- [ ] Access-Rules-and-Membership
- [ ] Workspace
- [ ] Folders-and-Subfolders
- [ ] API
- [ ] Real-Organizations-Read-Endpoint
- [ ] Real-Workspaces-Read-Endpoint
- [ ] Tree-Mutation-Endpoints
- [ ] Project-Creation-Endpoint
#### Frontend — Done Foundations
#### Frontend
- [x] Foundation
- [x] Typography
- [x] Icons
- [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
- [ ] App Shell
- [ ] Primitives
- [ ] Button
- [ ] IconButton
- [ ] Input
- [ ] Surface
- [ ] Nav-Bar
- [ ] Workspace-Switching
- [ ] Real-Workspace-Home
- [ ] Real-Workspace-Tree-Hydration
- [ ] Create-Project-Flow
- [ ] Persist-Tree-Mutations
- [ ] Connect-Tree-Interactions-To-Backend-Data
- [ ] Workspace-Home
### Version 0.2.0
**Goal:** Build the plugin app system on top of the base platform. And core app plugins like calendar, board, docs and text channels
**Goal:** First real work surface.
- [ ] 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
**Goal:** Communications and Collaboration (Email System, Reminder System, and Live Collaboration on Documents)
**Goal:** Documents and system hardening.
- [ ] Document
- [ ] Accessibility-Rules
- [ ] Motion-Foundation
### Version 0.4.0
**Goal:** Introduce the POSIX-based file system drive direction with OnlyOffice + S3 blob storage + Per File Versioning
### 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)
- [ ] Gantt-Board
- [ ] Calendar
- [ ] Timeline
-7
View File
@@ -10,10 +10,3 @@ BACKEND_SHUTDOWN_TIMEOUT=10s
DATABASE_URL=postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable
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
-1
View File
@@ -17,7 +17,6 @@
"preview": "vite preview"
},
"dependencies": {
"@solidjs/router": "^0.16.1",
"@solidjs/start": "2.0.0-alpha.2",
"@solidjs/vite-plugin-nitro-2": "^0.1.0",
"lucide-solid": "^0.542.0",
-12
View File
@@ -8,9 +8,6 @@ importers:
.:
dependencies:
'@solidjs/router':
specifier: ^0.16.1
version: 0.16.1(solid-js@1.9.11)
'@solidjs/start':
specifier: 2.0.0-alpha.2
version: 2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
@@ -1213,11 +1210,6 @@ packages:
peerDependencies:
solid-js: '>=1.8.4'
'@solidjs/router@0.16.1':
resolution: {integrity: sha512-IhyjedgC6LRpw/8CPGGI89FrV+r0xTHzOl2c4CRyzYQ1bLepJxbVI1LLKvsavMWY5TRBRacV7hAeOhuTXkjiqg==}
peerDependencies:
solid-js: ^1.8.6
'@solidjs/start@2.0.0-alpha.2':
resolution: {integrity: sha512-z56ATi3P07q8F5Io2I+RQrwjyWZtFZzpXN/J+8scf/gqrAW83LtgRkZFZjJaGH7i9WrHP+ep9F+ZiJ2gDHVBcw==}
engines: {node: '>=22'}
@@ -4493,10 +4485,6 @@ snapshots:
dependencies:
solid-js: 1.9.11
'@solidjs/router@0.16.1(solid-js@1.9.11)':
dependencies:
solid-js: 1.9.11
'@solidjs/start@2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
dependencies:
'@babel/core': 7.29.0
Binary file not shown.

After

Width:  |  Height:  |  Size: 664 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/favicon/mstile-150x150.png"/>
<square310x310logo src="/favicon/mstile-310x310.png"/>
<TileColor>#ffffff</TileColor>
</tile>
</msapplication>
</browserconfig>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 459 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

-19
View File
@@ -1,19 +0,0 @@
{
"name": "Moku Work",
"short_name": "Moku Work",
"icons": [
{
"src": "/favicon/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/favicon/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
+3 -9
View File
@@ -1,17 +1,11 @@
// Path: Frontend/src/app.tsx
import { Suspense, type JSX } from "solid-js";
import { Router } from "@solidjs/router";
import { FileRoutes } from "@solidjs/start/router";
import type { JSX } from "solid-js";
import { AppShell } from "./components/shell/AppShell/AppShell";
import "./styles/main.scss";
import "./styles/user-overrides.scss";
const App = (): JSX.Element => {
return (
<Router root={(props): JSX.Element => <Suspense>{props.children}</Suspense>}>
<FileRoutes />
</Router>
);
return <AppShell />;
};
export default App;
@@ -1,206 +0,0 @@
// Path: Frontend/src/components/app-shell/AppShell/AppShell.tsx
import { useLocation } from "@solidjs/router";
import { createEffect, createSignal, onCleanup, onMount, Show, type JSX } from "solid-js";
import { getDocumentTheme, setTheme, type Theme } from "../../../helper/themeRuntime";
import { BootstrapWizard } from "../../bootstrap/BootstrapWizard/BootstrapWizard";
import { AppShellDataProvider, useAppShellData } from "../data/app-shell.context";
import { getWorkspaceBreadcrumbSegments, getWorkspaceDocumentTitle } from "../data/workspace-routes";
import { LeftRail } from "../../workspace-navigation/LeftRail/LeftRail";
import { MobileBottomNav } from "../MobileBottomNav/MobileBottomNav";
import { MobileWorkspaceBrowser } from "../../workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser";
import { ServerDock } from "../ServerDock/ServerDock";
import { NotificationsMenu } from "../../top-bar/TopBar/NotificationsMenu";
import { ProfileMenu } from "../../top-bar/TopBar/ProfileMenu";
import { TopBar } from "../../top-bar/TopBar/TopBar";
import { WorkspaceTopBar } from "../../workspace-navigation/WorkspaceTopBar/WorkspaceTopBar";
import { WorkspaceSidebar } from "../../workspace-navigation/WorkspaceSidebar/WorkspaceSidebar";
import styles from "./AppShell.module.scss";
type MobileWorkspaceView = "notifications" | "profile" | null;
const MOBILE_VIEWPORT_QUERY = "(max-width: 48rem)";
const AppShellContent = (props: { children: JSX.Element }): JSX.Element => {
const [themeState, setThemeState] = createSignal<Theme>("light");
const [isRailCollapsed, setIsRailCollapsed] = createSignal(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = createSignal(false);
const [isMobileViewport, setIsMobileViewport] = createSignal(false);
const [isMobileWorkspaceBrowserOpen, setIsMobileWorkspaceBrowserOpen] = createSignal(false);
const [activeMobileWorkspaceView, setActiveMobileWorkspaceView] = createSignal<MobileWorkspaceView>(null);
const appShellData = useAppShellData();
const location = useLocation();
onMount((): void => {
setThemeState(getDocumentTheme());
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return;
}
const mediaQuery = window.matchMedia(MOBILE_VIEWPORT_QUERY);
const syncMobileViewport = (): void => {
setIsMobileViewport(mediaQuery.matches);
if (!mediaQuery.matches) {
setIsMobileWorkspaceBrowserOpen(false);
setActiveMobileWorkspaceView(null);
}
};
syncMobileViewport();
mediaQuery.addEventListener("change", syncMobileViewport);
onCleanup(() => {
mediaQuery.removeEventListener("change", syncMobileViewport);
});
});
const toggleTheme = (): void => {
const next: Theme = themeState() === "dark" ? "light" : "dark";
setTheme(next);
setThemeState(next);
};
const openMobileWorkspaceView = (view: Exclude<MobileWorkspaceView, null>): void => {
setIsMobileWorkspaceBrowserOpen(false);
setActiveMobileWorkspaceView((current) => (current === view ? null : view));
};
const toggleMobileWorkspaceBrowser = (): void => {
setActiveMobileWorkspaceView(null);
setIsMobileWorkspaceBrowserOpen((open) => !open);
};
const toggleMobileNotifications = (): void => {
openMobileWorkspaceView("notifications");
};
const toggleMobileProfile = (): void => {
openMobileWorkspaceView("profile");
};
const closeMobileWorkspaceView = (): void => {
setActiveMobileWorkspaceView(null);
};
const workspaceBreadcrumb = (): string =>
[
appShellData.activeServer().name,
...getWorkspaceBreadcrumbSegments(location.pathname, {
activeProjectName: appShellData.activeProject().name,
activeDepartmentName: appShellData.activeDepartment().name,
activeTeamName: appShellData.activeDepartment().teamName,
}),
].join(" / ");
createEffect((): void => {
if (typeof document === "undefined") {
return;
}
document.title = getWorkspaceDocumentTitle(location.pathname, {
activeProjectName: appShellData.activeProject().name,
});
});
return (
<div class={styles.shell} data-ui="app-shell" data-app-shell-status={appShellData.status()}>
<TopBar
theme={themeState()}
onToggleTheme={toggleTheme}
isMobileViewport={isMobileViewport()}
isNotificationsOpen={activeMobileWorkspaceView() === "notifications"}
isProfileOpen={activeMobileWorkspaceView() === "profile"}
onToggleNotifications={toggleMobileNotifications}
onToggleProfile={toggleMobileProfile}
/>
<div
classList={{
[styles.body]: true,
[styles.bodyRailCollapsed]: isRailCollapsed(),
[styles.bodySidebarCollapsed]: isSidebarCollapsed(),
}}
data-slot="shell-body"
data-rail-collapsed={isRailCollapsed() ? "true" : "false"}
data-sidebar-collapsed={isSidebarCollapsed() ? "true" : "false"}
>
{/* Left server rail */}
<div class={styles.railColumn} data-slot="rail-column">
<LeftRail collapsed={isRailCollapsed()} />
</div>
{/* Sidebar + main workspace frame */}
<div class={styles.workspaceRegion} data-slot="workspace-region">
<div class={styles.sidebarColumn} data-slot="sidebar-column">
<WorkspaceSidebar
collapsed={isSidebarCollapsed()}
railCollapsed={isRailCollapsed()}
onToggleRailCollapse={(): void => {
setIsRailCollapsed((collapsed) => !collapsed);
}}
/>
</div>
<div class={styles.workspaceMain} data-slot="workspace-main">
{/* On mobile, top-bar menus become full workspace views instead of popovers. */}
<Show
when={isMobileViewport() && activeMobileWorkspaceView() !== null}
fallback={
<>
<WorkspaceTopBar
sidebarCollapsed={isSidebarCollapsed()}
breadcrumb={workspaceBreadcrumb()}
onToggleSidebarCollapse={(): void => {
setIsSidebarCollapsed((collapsed) => !collapsed);
}}
/>
<div class={styles.workspaceContent} data-slot="workspace-content">
{props.children}
</div>
</>
}
>
<div class={styles.mobileWorkspaceView} data-slot="mobile-workspace-view" data-view={activeMobileWorkspaceView() ?? undefined}>
<Show when={activeMobileWorkspaceView() === "notifications"}>
<NotificationsMenu id="mobile-workspace-notifications" onSelect={closeMobileWorkspaceView} variant="workspace" />
</Show>
<Show when={activeMobileWorkspaceView() === "profile"}>
<ProfileMenu id="mobile-workspace-profile" onSelect={closeMobileWorkspaceView} variant="workspace" />
</Show>
</div>
</Show>
</div>
</div>
{/* Floating server dock overlay */}
<div class={styles.sidebarDock} data-slot="sidebar-dock">
<ServerDock />
</div>
</div>
<MobileBottomNav
isBrowseOpen={isMobileWorkspaceBrowserOpen()}
onBrowseToggle={(): void => {
toggleMobileWorkspaceBrowser();
}}
/>
<MobileWorkspaceBrowser
open={isMobileWorkspaceBrowserOpen()}
onClose={(): void => {
setIsMobileWorkspaceBrowserOpen(false);
}}
/>
<BootstrapWizard />
</div>
);
};
export const AppShell = (props: { children: JSX.Element }): JSX.Element => {
return (
<AppShellDataProvider>
<AppShellContent>{props.children}</AppShellContent>
</AppShellDataProvider>
);
};
@@ -1,108 +0,0 @@
.mobileNav {
display: none;
}
@include respond-down(mobile) {
.mobileNav {
--mobile-nav-button-gap: var(--space-1);
--mobile-nav-button-padding-inline: var(--space-2);
--mobile-nav-button-padding-top: calc(var(--space-2) + (var(--space-1) / 2));
--mobile-nav-button-padding-bottom: var(--space-2);
position: fixed;
right: 0;
bottom: 0;
left: 0;
display: grid;
gap: var(--space-2);
padding: var(--space-2) var(--space-3) calc(var(--space-2) + env(safe-area-inset-bottom, 0px));
background:
linear-gradient(to top, color-mix(in srgb, var(--color-canvas) 98%, transparent), color-mix(in srgb, var(--color-canvas) 92%, transparent));
border-top: 1px solid color-mix(in srgb, var(--color-border-strong) 40%, transparent);
backdrop-filter: blur(var(--blur-overlay));
z-index: var(--z-sticky);
}
.contextBar {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-width: 0;
color: var(--color-text-muted);
}
.contextServer,
.contextProject {
@include text-caption;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contextServer {
max-width: 45vw;
}
.contextProject {
max-width: 30vw;
}
.contextDivider {
@include text-caption;
color: var(--color-text-subtle);
}
.navGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-2);
}
.navButton {
min-width: 0;
display: grid;
justify-items: center;
gap: var(--mobile-nav-button-gap);
padding: var(--mobile-nav-button-padding-top) var(--mobile-nav-button-padding-inline) var(--mobile-nav-button-padding-bottom);
border: 1px solid transparent;
border-radius: var(--radius-xl);
background: transparent;
color: var(--color-text-muted);
transition:
background 160ms var(--easing-standard),
color 160ms var(--easing-standard),
border-color 160ms var(--easing-standard),
transform 180ms var(--easing-standard);
}
.navButton:hover,
.navButton:focus-visible {
color: var(--color-text);
background: color-mix(in srgb, var(--color-surface) 82%, transparent);
border-color: color-mix(in srgb, var(--color-border-strong) 28%, transparent);
}
.navButton:active {
transform: translateY(calc(var(--space-1) / 2));
}
.navButtonActive {
color: var(--color-text);
background: color-mix(in srgb, var(--color-surface) 90%, transparent);
border-color: color-mix(in srgb, var(--color-border-strong) 34%, transparent);
box-shadow: var(--shadow-soft);
}
.iconWrap {
display: inline-flex;
align-items: center;
justify-content: center;
}
.label {
@include text-caption;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@@ -1,66 +0,0 @@
// Path: Frontend/src/components/app-shell/MobileBottomNav/MobileBottomNav.tsx
import { For, type JSX } from "solid-js";
import { useAppShellData } from "../data/app-shell.context";
import { mobileBottomNavItems, type MobileBottomNavItem } from "../data/shell.data";
import styles from "./MobileBottomNav.module.scss";
type MobileBottomNavProps = {
isBrowseOpen: boolean;
onBrowseToggle: VoidFunction;
};
const MobileNavEntry = (props: {
item: MobileBottomNavItem;
isActive: boolean;
onSelect?: VoidFunction;
}): JSX.Element => {
const Icon = props.item.icon;
return (
<button
type="button"
onClick={() => props.onSelect?.()}
classList={{
[styles.navButton]: true,
[styles.navButtonActive]: props.isActive,
}}
aria-current={props.isActive ? "page" : undefined}
aria-expanded={props.item.id === "browse" ? props.isActive : undefined}
aria-label={props.item.label}
title={props.item.label}
>
<span class={styles.iconWrap} aria-hidden="true">
<Icon size={18} strokeWidth={2} />
</span>
<span class={styles.label}>{props.item.label}</span>
</button>
);
};
export const MobileBottomNav = (props: MobileBottomNavProps): JSX.Element => {
const appShellData = useAppShellData();
return (
<nav class={styles.mobileNav} aria-label="Mobile workspace navigation">
<div class={styles.contextBar}>
<span class={styles.contextServer}>{appShellData.activeServer().name}</span>
<span class={styles.contextDivider}>/</span>
<span class={styles.contextProject}>{appShellData.activeProject().name}</span>
</div>
<div class={styles.navGrid}>
<For each={mobileBottomNavItems}>
{(item): JSX.Element => (
<MobileNavEntry
item={item}
isActive={item.id === "browse" ? props.isBrowseOpen : (item.active ?? false) && !props.isBrowseOpen}
onSelect={item.id === "browse" ? props.onBrowseToggle : undefined}
/>
)}
</For>
</div>
</nav>
);
};
@@ -1,66 +0,0 @@
// Path: Frontend/src/components/app-shell/ServerDock/ServerDock.tsx
import { For, Show, type JSX } from "solid-js";
import { useNavigate } from "@solidjs/router";
import { useAppShellData } from "../data/app-shell.context";
import { getWorkspaceSurfaceRoute } from "../data/workspace-routes";
import styles from "./ServerDock.module.scss";
export const ServerDock = (): JSX.Element => {
const appShellData = useAppShellData();
const navigate = useNavigate();
const activeServer = () => appShellData.activeServer();
const handleAction = (actionId: string): void => {
if (actionId === "settings" || actionId === "server") {
navigate(getWorkspaceSurfaceRoute("settings"));
}
};
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}
onClick={() => handleAction(item.id)}
>
<Icon size={16} strokeWidth={2} />
<span class={styles.actionLabel}>{item.label}</span>
</button>
);
}}
</For>
</div>
</Show>
</section>
);
};
@@ -1,169 +0,0 @@
// 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,
};
};
@@ -1,98 +0,0 @@
// 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 { applyThemePresetById, resolvePreferredThemePresetId } from "../../../helper/themeRuntime";
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.");
}
const normalizedPayload = normalizeAppShellPayload(body.data);
const persistedThemePresetId = normalizedPayload.admin?.themePresetId?.trim();
if (persistedThemePresetId && persistedThemePresetId !== resolvePreferredThemePresetId()) {
await applyThemePresetById(persistedThemePresetId);
}
setPayload(normalizedPayload);
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),
admin: createMemo(() => payload()?.admin),
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;
};
@@ -1,132 +0,0 @@
// 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;
themePresetId?: 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>;
admin: Accessor<AppShellAdmin | 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 : [],
});
@@ -1,174 +0,0 @@
// 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;
}
};
@@ -1,6 +0,0 @@
// 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";
@@ -1,100 +0,0 @@
// 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;
}
};
@@ -1,235 +0,0 @@
// 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 WorkspaceStaticSurfaceKind = Exclude<WorkspaceStaticKind, "workspace">;
export type WorkspaceItemTypeId = string;
export type WorkspaceStaticItem = SidebarItem & {
contextKind: WorkspaceStaticSurfaceKind;
};
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;
};
@@ -1,120 +0,0 @@
// Path: Frontend/src/components/app-shell/data/workspace-routes.ts
import type { WorkspaceStaticKind } from "./shell.types";
import { buildPageTitle } from "../../../helper/pageTitle";
export type WorkspaceSurfaceKind = Exclude<WorkspaceStaticKind, "workspace">;
export type SettingsSectionKind = "account" | "workspace" | "server" | "theme" | "security" | "members";
export type WorkspaceBreadcrumbContext = {
activeProjectName: string;
activeDepartmentName: string;
activeTeamName?: string;
};
export type WorkspaceTitleContext = {
activeProjectName: string;
appName?: string;
};
const workspaceSurfaceRoutes: Record<WorkspaceSurfaceKind, string> = {
home: "/workspace/home",
settings: "/settings",
};
export const getWorkspaceSurfaceRoute = (surface: WorkspaceSurfaceKind): string => workspaceSurfaceRoutes[surface];
const settingsSectionRoutes: Record<SettingsSectionKind, string> = {
account: "/settings/account",
workspace: "/settings/workspace",
server: "/settings/server",
theme: "/settings/theme",
security: "/settings/security",
members: "/settings/members",
};
export const getSettingsSectionRoute = (section: SettingsSectionKind): string => settingsSectionRoutes[section];
export const getWorkspaceSurfaceFromPathname = (pathname: string): WorkspaceSurfaceKind => {
if (pathname.startsWith(workspaceSurfaceRoutes.settings) || pathname.startsWith("/workspace/settings")) {
return "settings";
}
return "home";
};
export const getWorkspaceSurfaceBreadcrumbLabel = (pathname: string): string => {
const surface = getWorkspaceSurfaceFromPathname(pathname);
return surface === "settings" ? "Settings" : "Home";
};
const getSettingsSectionFromPathname = (pathname: string): SettingsSectionKind | null => {
const matchedEntry = (Object.entries(settingsSectionRoutes) as Array<[SettingsSectionKind, string]>).find(([, route]) => pathname.startsWith(route));
return matchedEntry?.[0] ?? null;
};
export const getWorkspaceBreadcrumbSegments = (
pathname: string,
context: WorkspaceBreadcrumbContext,
): string[] => {
const settingsSection = getSettingsSectionFromPathname(pathname);
if (pathname === workspaceSurfaceRoutes.settings || pathname === "/workspace/settings") {
return ["Settings"];
}
if (settingsSection) {
switch (settingsSection) {
case "account":
return ["Users", "Settings"];
case "workspace":
return [context.activeProjectName, "Settings"];
case "server":
return ["Settings"];
case "theme":
return ["Personal", "Settings"];
case "security":
return ["Users", "Settings"];
case "members":
return [context.activeTeamName || context.activeDepartmentName || "Members", "Users"];
}
}
return [context.activeProjectName, getWorkspaceSurfaceBreadcrumbLabel(pathname)];
};
export const getWorkspaceDocumentTitle = (
pathname: string,
context: WorkspaceTitleContext,
): string => {
const appName = context.appName?.trim() || undefined;
const settingsSection = getSettingsSectionFromPathname(pathname);
if (pathname === workspaceSurfaceRoutes.settings || pathname === "/workspace/settings") {
return buildPageTitle(appName, "Settings");
}
if (settingsSection) {
switch (settingsSection) {
case "account":
return buildPageTitle(appName, "Account Settings");
case "workspace":
return buildPageTitle(appName, "Workspace Settings");
case "server":
return buildPageTitle(appName, "Server Settings");
case "theme":
return buildPageTitle(appName, "Theme Settings");
case "security":
return buildPageTitle(appName, "Security Settings");
case "members":
return buildPageTitle(appName, "Members & Roles");
}
}
if (pathname.startsWith(workspaceSurfaceRoutes.home)) {
return buildPageTitle(appName, context.activeProjectName);
}
return buildPageTitle(appName);
};

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