diff --git a/.gitignore b/.gitignore index bdb22df..6b8c6cc 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,5 @@ tmp/ bin/ .cgcignore + +POSIX/ \ No newline at end of file diff --git a/Backend/internal/bootstrap/service.go b/Backend/internal/bootstrap/service.go index d3184ad..602c3b8 100644 --- a/Backend/internal/bootstrap/service.go +++ b/Backend/internal/bootstrap/service.go @@ -4,8 +4,11 @@ package bootstrap import ( "context" + "encoding/json" "errors" "fmt" + "os" + "path/filepath" "strings" "github.com/jackc/pgx/v5" @@ -41,7 +44,8 @@ var ( ) type Service struct { - db *database.DB + db *database.DB + posixRoot string } type SaveInstanceInput struct { @@ -172,8 +176,8 @@ type namedRecord struct { Slug string `json:"slug"` } -func NewService(db *database.DB) *Service { - return &Service{db: db} +func NewService(db *database.DB, posixRoot string) *Service { + return &Service{db: db, posixRoot: strings.TrimSpace(posixRoot)} } func (service *Service) SaveInstance(ctx context.Context, input SaveInstanceInput) (InstallationRecord, error) { @@ -405,6 +409,10 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn return StructureRecord{}, err } + if err := service.ensureBootstrapPOSIXSkeleton(installation, admin, organization, department, team, project); err != nil { + return StructureRecord{}, err + } + return StructureRecord{ Installation: installation, Organization: organization, @@ -901,3 +909,173 @@ func personalHomeTitle(displayName string) string { return fmt.Sprintf("%s's Home", trimmedDisplayName) } + +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 := writeJSONFile(filepath.Join(rootPath, "settings.json"), 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 settings.json: %w", err) + } + + if err := writeJSONFile(filepath.Join(rootPath, "layout.json"), map[string]any{ + "version": 1, + "type": "tenant-layout", + "home": map[string]any{ + "defaultProjectSlug": project.Slug, + }, + }); err != nil { + return fmt.Errorf("write tenant layout.json: %w", 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) + } + + 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") + + for _, dirPath := range []string{ + departmentPath, + teamPath, + projectPath, + filepath.Join(projectPath, "tree"), + filepath.Join(usersPath, "personals"), + } { + if err := os.MkdirAll(dirPath, 0o755); err != nil { + return fmt.Errorf("create POSIX directory %s: %w", dirPath, err) + } + } + + if err := writeJSONFile(filepath.Join(departmentPath, "settings.json"), map[string]any{ + "id": department.ID, + "name": department.Name, + "slug": department.Slug, + "type": "department", + }); err != nil { + return fmt.Errorf("write department settings.json: %w", err) + } + + if err := writeJSONFile(filepath.Join(departmentPath, "users.json"), map[string]any{ + "owners": []map[string]string{{ + "id": admin.ID, + "email": admin.Email, + "displayName": admin.DisplayName, + }}, + }); err != nil { + return fmt.Errorf("write department users.json: %w", err) + } + + if err := writeJSONFile(filepath.Join(teamPath, "settings.json"), map[string]any{ + "id": team.ID, + "name": team.Name, + "slug": team.Slug, + "type": "team", + }); err != nil { + return fmt.Errorf("write team settings.json: %w", err) + } + + if err := writeJSONFile(filepath.Join(teamPath, "users.json"), map[string]any{ + "owners": []map[string]string{{ + "id": admin.ID, + "email": admin.Email, + "displayName": admin.DisplayName, + }}, + }); err != nil { + return fmt.Errorf("write team users.json: %w", err) + } + + if err := writeJSONFile(filepath.Join(projectPath, "settings.json"), map[string]any{ + "id": project.ID, + "name": project.Name, + "slug": project.Slug, + "type": "project", + }); err != nil { + return fmt.Errorf("write project settings.json: %w", err) + } + + if err := writeJSONFile(filepath.Join(projectPath, "home.json"), map[string]any{ + "type": "project-home", + "project": project.Slug, + "widgets": []any{}, + }); err != nil { + return fmt.Errorf("write project home.json: %w", err) + } + + if err := writeJSONFile(filepath.Join(usersPath, "settings.json"), map[string]any{ + "primaryAdminId": admin.ID, + }); err != nil { + return fmt.Errorf("write users settings.json: %w", err) + } + + if err := writeJSONFile(filepath.Join(usersPath, "data.json"), map[string]any{ + "users": []map[string]string{{ + "id": admin.ID, + "email": admin.Email, + "displayName": admin.DisplayName, + }}, + }); err != nil { + return fmt.Errorf("write users data.json: %w", err) + } + + return nil +} + +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) +} diff --git a/Backend/internal/bootstrap/service_test.go b/Backend/internal/bootstrap/service_test.go new file mode 100644 index 0000000..1eca971 --- /dev/null +++ b/Backend/internal/bootstrap/service_test.go @@ -0,0 +1,113 @@ +package bootstrap + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +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, "settings.json"), + filepath.Join(rootPath, "layout.json"), + filepath.Join(rootPath, "catalog", "packs"), + filepath.Join(rootPath, "catalog", "standalone"), + filepath.Join(rootPath, "departments", "department-primary-department", "settings.json"), + filepath.Join(rootPath, "departments", "department-primary-department", "users.json"), + filepath.Join(rootPath, "departments", "department-primary-department", "teams", "team-primary-team", "settings.json"), + filepath.Join(rootPath, "departments", "department-primary-department", "teams", "team-primary-team", "users.json"), + filepath.Join(rootPath, "projects", "project-primary-project", "settings.json"), + filepath.Join(rootPath, "projects", "project-primary-project", "home.json"), + filepath.Join(rootPath, "projects", "project-primary-project", "tree"), + filepath.Join(rootPath, "users", "settings.json"), + filepath.Join(rootPath, "users", "data.json"), + filepath.Join(rootPath, "users", "personals"), + } + + for _, path := range requiredPaths { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected path to exist %s: %v", path, err) + } + } + + settingsPayload := readJSONFileForTest[map[string]any](t, filepath.Join(rootPath, "settings.json")) + installationPayload, ok := settingsPayload["installation"].(map[string]any) + if !ok { + t.Fatalf("settings.json missing installation object: %#v", 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 := readJSONFileForTest[map[string]any](t, filepath.Join(rootPath, "layout.json")) + homePayload, ok := layoutPayload["home"].(map[string]any) + if !ok { + t.Fatalf("layout.json missing home object: %#v", layoutPayload) + } + if homePayload["defaultProjectSlug"] != "primary-project" { + t.Fatalf("expected default project slug primary-project, got %#v", homePayload["defaultProjectSlug"]) + } + + projectSettings := readJSONFileForTest[map[string]any](t, filepath.Join(rootPath, "projects", "project-primary-project", "settings.json")) + if projectSettings["type"] != "project" { + t.Fatalf("expected project settings type project, got %#v", projectSettings["type"]) + } + + usersSettings := readJSONFileForTest[map[string]any](t, filepath.Join(rootPath, "users", "settings.json")) + if usersSettings["primaryAdminId"] != "admin-1" { + t.Fatalf("expected primary admin id admin-1, got %#v", usersSettings["primaryAdminId"]) + } +} + +func readJSONFileForTest[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 payload T + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("unmarshal %s: %v", path, err) + } + + return payload +} diff --git a/Backend/internal/config/config.go b/Backend/internal/config/config.go index 63c713f..5123479 100644 --- a/Backend/internal/config/config.go +++ b/Backend/internal/config/config.go @@ -17,6 +17,7 @@ type Config struct { APIPort string PostgresURL string ValkeyURL string + POSIXRoot string ShutdownTimeout time.Duration } @@ -29,6 +30,7 @@ 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), } } diff --git a/Backend/internal/httpx/api_bootstrap_routes.go b/Backend/internal/httpx/api_bootstrap_routes.go index ea01a84..f7a68c4 100644 --- a/Backend/internal/httpx/api_bootstrap_routes.go +++ b/Backend/internal/httpx/api_bootstrap_routes.go @@ -341,7 +341,7 @@ func (routes apiRoutes) handleBootstrapStructureStep(w http.ResponseWriter, r *h } func (routes apiRoutes) bootstrapService() *bootstrapservice.Service { - return bootstrapservice.NewService(routes.cfg.Database) + return bootstrapservice.NewService(routes.cfg.Database, routes.cfg.Config.POSIXRoot) } func (routes apiRoutes) writeBootstrapStepResponse(w http.ResponseWriter, status int, step string, payload any) { diff --git a/Commands/Local/Dev/backend.just b/Commands/Local/Dev/backend.just index fcad40d..7f74c19 100644 --- a/Commands/Local/Dev/backend.just +++ b/Commands/Local/Dev/backend.just @@ -9,10 +9,14 @@ migrate-up: migrate-down: cd '{{backend_dir}}' && go run ./cmd/migrate down -# Reset all embedded database migrations and reapply from scratch. +# Reset all embedded database migrations. migrate-reset: cd '{{backend_dir}}' && go run ./cmd/migrate reset +# Reset embedded database migrations and apply them again from scratch. +migrate-rebuild: + cd '{{backend_dir}}' && go run ./cmd/migrate reset && go run ./cmd/migrate up + # Show the embedded database migration status. migrate-status: cd '{{backend_dir}}' && go run ./cmd/migrate status @@ -20,7 +24,3 @@ migrate-status: # Format backend Go source files. fmt: cd '{{backend_dir}}' && gofmt -w ./cmd ./db ./internal - -# Run backend test suite. -test: - cd '{{backend_dir}}' && go test ./... diff --git a/Commands/Test/backend.just b/Commands/Test/backend.just new file mode 100644 index 0000000..5d52b62 --- /dev/null +++ b/Commands/Test/backend.just @@ -0,0 +1,11 @@ +project_root := justfile_directory() +backend_dir := project_root + "/Backend" + +# Run the full backend test suite. +[default] +all: + cd '{{backend_dir}}' && go test ./... + +# Run the isolated POSIX bootstrap smoke test. +posix-bootstrap: + cd '{{backend_dir}}' && go test ./internal/bootstrap -run TestEnsureBootstrapPOSIXSkeletonInitializesEmptyRoot -count=1 -v diff --git a/Commands/Test/mod.just b/Commands/Test/mod.just new file mode 100644 index 0000000..507f8d7 --- /dev/null +++ b/Commands/Test/mod.just @@ -0,0 +1 @@ +mod backend diff --git a/Docker/docker-compose.local.dev.yaml b/Docker/docker-compose.local.dev.yaml index c504edb..d35ea79 100644 --- a/Docker/docker-compose.local.dev.yaml +++ b/Docker/docker-compose.local.dev.yaml @@ -6,6 +6,7 @@ x-backend-service: &backend-service 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 @@ -13,6 +14,7 @@ x-backend-service: &backend-service condition: service_healthy volumes: - ../Backend:/app + - ../POSIX:/posix - moku_work_backend_go_pkg:/go/pkg/mod - moku_work_backend_go_build:/root/.cache/go-build diff --git a/Docker/docker-compose.local.prod.yaml b/Docker/docker-compose.local.prod.yaml index 574e29e..f18dd3f 100644 --- a/Docker/docker-compose.local.prod.yaml +++ b/Docker/docker-compose.local.prod.yaml @@ -5,11 +5,14 @@ x-backend-service: &backend-service 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: diff --git a/Documentation/POSIX-Structure.md b/Documentation/POSIX-Structure.md new file mode 100644 index 0000000..5c72cba --- /dev/null +++ b/Documentation/POSIX-Structure.md @@ -0,0 +1,61 @@ +# POSIX Structure + +``` markdown +Personal or Organization (server)/ +├── settings.json +├── layout.json +├── catalog/ +│ ├── packs/ +│ │ └── pack-/ +│ │ ├── manifest.json +│ │ └── entries/ +│ │ └── app-/ +│ │ └── manifest.json +│ └── standalone/ +│ └── app-/ +│ └── manifest.json +├── departments/ +│ └── department-/ +│ ├── settings.json +│ ├── users.json +│ └── teams/ +│ └── team-/ +│ ├── settings.json +│ └── users.json +├── projects/ +│ └── project-/ +│ ├── settings.json +│ ├── home.json +│ └── tree/ +│ ├── item-/ +│ │ ├── item.json +│ │ ├── schema.json +│ │ └── data.json +│ └── folder-/ +│ ├── folder.json +│ └── item-/ +│ ├── item.json +│ ├── schema.json +│ └── data.json +└── users/ + ├── settings.json + ├── data.json + └── personals/ + └── personal-/ + ├── layout.json + ├── settings.json + ├── home.json + └── tree/ +``` + +## File Responsibilities + +- `settings.json` — Metadata and presentation config for the thing, such as display name, icon, description, and simple settings. +- `layout.json` — Layout configuration for the current server or personal space. +- `home.json` — Home surface configuration, such as widgets, sections, and how they are arranged. +- `folder.json` — Metadata for a folder node in a tree. +- `item.json` — 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.json` — The actual content or state data for that item. +- `manifest.json` — Catalog definition metadata, including versioning, description, and capabilities for reusable apps or entries. +- `users.json` — User membership or assignment data for departments and teams. diff --git a/Documentation/TODO.md b/Documentation/TODO.md index d548697..d6de8fe 100644 --- a/Documentation/TODO.md +++ b/Documentation/TODO.md @@ -137,7 +137,7 @@ ### Version 0.4.0 -**Goal:** Introduce the POSIX-based file system drive direction with OnlyOffice + S3 blob storage +**Goal:** Introduce the POSIX-based file system drive direction with OnlyOffice + S3 blob storage + Per File Versioning ### Version 0.5.0 diff --git a/Env/.env.example b/Env/.env.example index 52c8dc5..ea91058 100644 --- a/Env/.env.example +++ b/Env/.env.example @@ -10,6 +10,7 @@ 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 diff --git a/Justfile b/Justfile index 1aff8b2..d5ae1c2 100644 --- a/Justfile +++ b/Justfile @@ -1,6 +1,7 @@ set shell := ["bash", "-cu"] mod local "Commands/Local" +mod test "Commands/Test" [default] help: