Refactor: improve backend modularity

This commit is contained in:
MangoPig
2026-06-29 04:29:05 +01:00
parent fc5ef23af8
commit 95fb8629d1
22 changed files with 2989 additions and 3372 deletions
@@ -0,0 +1,81 @@
// Path: Backend/internal/bootstrap/bootstrap_helpers.go
package bootstrap
import (
"context"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"moku-backend/internal/database"
)
func NewService(db *database.DB, posixRoot string) *Service {
return &Service{db: db, posixRoot: strings.TrimSpace(posixRoot)}
}
func upsertNamedRecord(ctx context.Context, tx pgx.Tx, query string, args ...any) (namedRecord, error) {
var record namedRecord
if err := tx.QueryRow(ctx, query, args...).Scan(&record.ID, &record.Name, &record.Slug); err != nil {
return namedRecord{}, err
}
return record, nil
}
func upsertWorkspace(ctx context.Context, tx pgx.Tx, organizationID, name, slug, kind, createdByUserID string, departmentID, teamID, projectID *string) error {
_, err := tx.Exec(ctx, `
INSERT INTO workspaces (organization_id, name, slug, kind, created_by_user_id, department_id, team_id, project_id)
VALUES ($1::uuid, $2, $3, $4::workspace_kind, $5::uuid, $6::uuid, $7::uuid, $8::uuid)
ON CONFLICT (organization_id, slug) DO UPDATE
SET
name = EXCLUDED.name,
kind = EXCLUDED.kind,
created_by_user_id = EXCLUDED.created_by_user_id,
department_id = EXCLUDED.department_id,
team_id = EXCLUDED.team_id,
project_id = EXCLUDED.project_id,
updated_at = NOW();
`, organizationID, name, slug, kind, createdByUserID, departmentID, teamID, projectID)
return err
}
func defaultRootOrganizationName(installationName, mode, host, adminDisplayName string) string {
trimmedInstallationName := strings.TrimSpace(installationName)
trimmedHost := strings.TrimSpace(host)
trimmedAdminDisplayName := strings.TrimSpace(adminDisplayName)
if trimmedInstallationName != "" {
return trimmedInstallationName
}
if strings.EqualFold(mode, defaultInstallationMode) {
if trimmedAdminDisplayName != "" {
return fmt.Sprintf("%s %s", trimmedAdminDisplayName, defaultPersonalServerSuffix)
}
return defaultPersonalDisplayName
}
if trimmedHost != "" {
return trimmedHost
}
return defaultOrganizationName
}
func personalHomeTitle(displayName string) string {
trimmedDisplayName := strings.TrimSpace(displayName)
if trimmedDisplayName == "" {
return "Home"
}
if strings.HasSuffix(strings.ToLower(trimmedDisplayName), "s") {
return fmt.Sprintf("%s' Home", trimmedDisplayName)
}
return fmt.Sprintf("%s's Home", trimmedDisplayName)
}
@@ -0,0 +1,309 @@
// Path: Backend/internal/bootstrap/bootstrap_types.go
package bootstrap
import (
"errors"
"moku-backend/internal/database"
)
const (
primaryOrganizationSlug = "primary-organization"
primaryDepartmentSlug = "primary-department"
primaryTeamSlug = "primary-team"
primaryProjectSlug = "primary-project"
organizationWorkspaceSlug = "organization-home"
departmentWorkspaceSlug = "department-home"
teamWorkspaceSlug = "team-home"
projectWorkspaceSlug = "project-home"
defaultInstallationHost = "localhost"
defaultInstallationMode = "personal"
defaultInstallationAccess = "local"
defaultInstallationProtocol = "http"
defaultOrganizationName = "Moku"
defaultPersonalServerSuffix = "Personal"
defaultPersonalDisplayName = "Personal"
bootstrapWorkspaceKindOrg = "organization"
bootstrapWorkspaceKindDept = "department"
bootstrapWorkspaceKindTeam = "team"
bootstrapWorkspaceKindProject = "project"
projectFolderOrderRootKey = "__root__"
projectFolderOrderHierarchy = "hierarchy"
projectFolderOrderTree = "tree"
)
var (
ErrInstallationNotConfigured = errors.New("bootstrap installation step has not been completed")
ErrAdminNotConfigured = errors.New("bootstrap admin step has not been completed")
ErrBootstrapStructureMissing = errors.New("bootstrap structure is incomplete")
ErrProjectNotFound = errors.New("project not found")
ErrProjectFolderNotFound = errors.New("project folder not found")
ErrProjectItemNotFound = errors.New("project item not found")
ErrInvalidProjectFolderMove = errors.New("invalid project folder move")
ErrInvalidProjectItemMove = errors.New("invalid project item move")
)
type Service struct {
db *database.DB
posixRoot string
}
type SaveInstanceInput struct {
Protocol string
Access string
Host string
}
type SaveModeInput struct {
Mode string
Name string
}
type SaveAdminInput struct {
DisplayName string
Email string
Password string
}
type SaveStructureInput struct {
OrganizationName string
DepartmentName string
TeamName string
ProjectName string
}
type InstallationRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Mode string `json:"mode"`
Access string `json:"access"`
Protocol string `json:"protocol"`
Host string `json:"host"`
IsBootstrapped bool `json:"isBootstrapped"`
MaterializationStatus string `json:"materializationStatus"`
MaterializationError *string `json:"materializationError,omitempty"`
}
type AdminRecord struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
IsInstanceAdmin bool `json:"isInstanceAdmin"`
HomeTitle string `json:"homeTitle"`
}
type OrganizationRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type DepartmentRecord struct {
ID string `json:"id"`
OrganizationID string `json:"organizationId"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type TeamRecord struct {
ID string `json:"id"`
OrganizationID string `json:"organizationId"`
DepartmentID *string `json:"departmentId,omitempty"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type ProjectRecord struct {
ID string `json:"id"`
OrganizationID string `json:"organizationId"`
DepartmentID *string `json:"departmentId,omitempty"`
TeamID *string `json:"teamId,omitempty"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type WorkspaceRecord struct {
ID string `json:"id"`
OrganizationID string `json:"organizationId"`
Name string `json:"name"`
Slug string `json:"slug"`
Kind string `json:"kind"`
DepartmentID *string `json:"departmentId,omitempty"`
TeamID *string `json:"teamId,omitempty"`
ProjectID *string `json:"projectId,omitempty"`
}
type StructureRecord struct {
Installation InstallationRecord `json:"installation"`
Organization namedRecord `json:"organization"`
Department namedRecord `json:"department"`
Team namedRecord `json:"team"`
Project namedRecord `json:"project"`
Admin AdminSummary `json:"admin"`
}
type AdminSummary struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
}
type BootstrapStructureState struct {
Organization *OrganizationRecord `json:"organization,omitempty"`
Department *DepartmentRecord `json:"department,omitempty"`
Team *TeamRecord `json:"team,omitempty"`
Project *ProjectRecord `json:"project,omitempty"`
Workspaces []WorkspaceRecord `json:"workspaces"`
}
type BootstrapState struct {
Installation *InstallationRecord `json:"installation,omitempty"`
Admin *AdminRecord `json:"admin,omitempty"`
Structure BootstrapStructureState `json:"structure"`
}
type AppShellState struct {
Installation *InstallationRecord `json:"installation,omitempty"`
Admin *AdminRecord `json:"admin,omitempty"`
Organizations []OrganizationRecord `json:"organizations"`
Departments []DepartmentRecord `json:"departments"`
Teams []TeamRecord `json:"teams"`
Projects []ProjectRecord `json:"projects"`
Workspaces []WorkspaceRecord `json:"workspaces"`
}
type namedRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type ProjectHierarchyFolderRecord struct {
ID string `json:"id"`
Path string `json:"path"`
Label string `json:"label"`
Children []ProjectHierarchyFolderRecord `json:"children"`
}
type ProjectTreeNodeRecord struct {
ID string `json:"id"`
Path string `json:"path"`
Label string `json:"label"`
Kind string `json:"kind"`
ItemType string `json:"itemType,omitempty"`
Children []ProjectTreeNodeRecord `json:"children,omitempty"`
}
type CreateProjectFolderInput struct {
ProjectID string
ParentFolderPath string
Name string
}
type DeleteProjectFolderInput struct {
ProjectID string
FolderPath string
}
type RenameProjectFolderInput struct {
ProjectID string
FolderPath string
Name string
}
type MoveProjectFolderInput struct {
ProjectID string
FolderPath string
FolderStableID string
ParentFolderPath string
ParentStableID string
TargetIndex int
}
type CreateProjectItemInput struct {
ProjectID string
ParentFolderPath string
Name string
ItemType string
}
type DeleteProjectItemInput struct {
ProjectID string
ItemPath string
}
type MoveProjectItemInput struct {
ProjectID string
ItemPath string
ItemStableID string
ParentFolderPath string
ParentStableID string
TargetIndex int
}
type CreateProjectFolderResult struct {
ProjectID string `json:"projectId"`
CreatedFolder ProjectHierarchyFolderRecord `json:"createdFolder"`
Folders []ProjectHierarchyFolderRecord `json:"folders"`
}
type DeleteProjectFolderResult struct {
ProjectID string `json:"projectId"`
DeletedFolderStableID string `json:"deletedFolderId"`
DeletedFolderPath string `json:"deletedFolderPath"`
Folders []ProjectHierarchyFolderRecord `json:"folders"`
}
type RenameProjectFolderResult struct {
ProjectID string `json:"projectId"`
PreviousFolderStableID string `json:"previousFolderId"`
PreviousFolderPath string `json:"previousFolderPath"`
RenamedFolder ProjectHierarchyFolderRecord `json:"renamedFolder"`
Folders []ProjectHierarchyFolderRecord `json:"folders"`
}
type MoveProjectFolderResult struct {
ProjectID string `json:"projectId"`
PreviousFolderStableID string `json:"previousFolderId"`
PreviousFolderPath string `json:"previousFolderPath"`
MovedFolder ProjectHierarchyFolderRecord `json:"movedFolder"`
Folders []ProjectHierarchyFolderRecord `json:"folders"`
}
type CreateProjectItemResult struct {
ProjectID string `json:"projectId"`
CreatedItem ProjectTreeNodeRecord `json:"createdItem"`
Nodes []ProjectTreeNodeRecord `json:"nodes"`
}
type DeleteProjectItemResult struct {
ProjectID string `json:"projectId"`
DeletedItemStableID string `json:"deletedItemId"`
DeletedItemPath string `json:"deletedItemPath"`
Nodes []ProjectTreeNodeRecord `json:"nodes"`
}
type MoveProjectItemResult struct {
ProjectID string `json:"projectId"`
PreviousItemStableID string `json:"previousItemId"`
PreviousItemPath string `json:"previousItemPath"`
MovedItem ProjectTreeNodeRecord `json:"movedItem"`
Nodes []ProjectTreeNodeRecord `json:"nodes"`
}
type projectHierarchyFolderRow struct {
ID string
Path string
ParentPath string
Label string
}
type projectTreeNodeRow struct {
ID string
Path string
ParentPath string
Label string
Kind string
ItemType string
}
@@ -0,0 +1,48 @@
// Path: Backend/internal/bootstrap/project_disk.go
package bootstrap
import (
"path/filepath"
)
func (service *Service) createProjectHierarchyFolderOnDisk(projectSlug, parentFolderID, name string) (string, string, error) {
return service.createProjectFolderOnDisk(projectSlug, parentFolderID, name, projectHierarchyRootPath)
}
func (service *Service) createProjectTreeFolderOnDisk(projectSlug, parentFolderID, name string) (string, string, error) {
return service.createProjectFolderOnDisk(projectSlug, parentFolderID, name, projectTreeRootPath)
}
func (service *Service) deleteProjectHierarchyFolderOnDisk(projectSlug, folderID string) (string, error) {
return service.deleteProjectFolderOnDisk(projectSlug, folderID, projectHierarchyRootPath)
}
func (service *Service) deleteProjectTreeFolderOnDisk(projectSlug, folderID string) (string, error) {
return service.deleteProjectFolderOnDisk(projectSlug, folderID, projectTreeRootPath)
}
func (service *Service) renameProjectHierarchyFolderOnDisk(projectSlug, folderID, name string) (string, string, error) {
return service.renameProjectFolderOnDisk(projectSlug, folderID, name, projectHierarchyRootPath)
}
func (service *Service) renameProjectTreeFolderOnDisk(projectSlug, folderID, name string) (string, string, error) {
return service.renameProjectFolderOnDisk(projectSlug, folderID, name, projectTreeRootPath)
}
func (service *Service) moveProjectHierarchyFolderOnDisk(projectSlug, folderID, parentFolderID string) (string, string, error) {
return service.moveProjectFolderOnDisk(projectSlug, folderID, parentFolderID, projectHierarchyRootPath)
}
func (service *Service) moveProjectTreeFolderOnDisk(projectSlug, folderID, parentFolderID string) (string, string, error) {
return service.moveProjectFolderOnDisk(projectSlug, folderID, parentFolderID, projectTreeRootPath)
}
func projectHierarchyRootPath(projectSlug string) string {
return filepath.ToSlash(filepath.Join("projects", slugDir("project", projectSlug), "children"))
}
func projectTreeRootPath(projectSlug string) string {
return filepath.ToSlash(filepath.Join("projects", slugDir("project", projectSlug), "tree"))
}
@@ -0,0 +1,196 @@
// 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 := 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")
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 := 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",
"title": project.Name,
}); err != nil {
return fmt.Errorf("write project home.json: %w", err)
}
if err := writeJSONFile(filepath.Join(projectPath, "acl.json"), map[string]any{
"version": 1,
"inherits": true,
"rules": []any{},
}); err != nil {
return fmt.Errorf("write project acl.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{
"admins": []map[string]string{{
"id": admin.ID,
"email": admin.Email,
"displayName": admin.DisplayName,
}},
}); err != nil {
return fmt.Errorf("write users data.json: %w", err)
}
if err := writeJSONFile(filepath.Join(personalHomePath, "settings.json"), map[string]any{
"type": "personal",
"name": personalName,
"slug": personalSlug,
}); err != nil {
return fmt.Errorf("write personal settings.json: %w", err)
}
if err := writeJSONFile(filepath.Join(personalHomePath, "layout.json"), map[string]any{
"version": 1,
"type": "personal-layout",
"home": map[string]any{
"defaultProjectSlug": project.Slug,
},
}); err != nil {
return fmt.Errorf("write personal layout.json: %w", err)
}
if err := writeJSONFile(filepath.Join(personalHomePath, "home.json"), map[string]any{
"type": "personal-home",
"title": personalHomeTitle(personalName),
}); err != nil {
return fmt.Errorf("write personal home.json: %w", err)
}
return nil
}
@@ -0,0 +1,252 @@
// Path: Backend/internal/bootstrap/project_disk_folders.go
package bootstrap
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
)
func (service *Service) createProjectFolderOnDisk(projectSlug, parentFolderID, name string, rootPathBuilder func(projectSlug string) string) (string, string, error) {
posixRoot := strings.TrimSpace(service.posixRoot)
if posixRoot == "" {
return "", "", fmt.Errorf("POSIX root is not configured")
}
trimmedName := strings.TrimSpace(name)
if trimmedName == "" {
return "", "", fmt.Errorf("folder name is required")
}
containerProjectionPath := rootPathBuilder(projectSlug)
parentDir := filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath))
if strings.TrimSpace(parentFolderID) != "" {
containerProjectionPath = filepath.ToSlash(filepath.Join(strings.TrimSpace(parentFolderID), "children"))
parentDir = filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath))
info, err := os.Stat(parentDir)
if err != nil {
if os.IsNotExist(err) {
return "", "", ErrProjectFolderNotFound
}
return "", "", fmt.Errorf("stat parent project folder: %w", err)
}
if !info.IsDir() {
return "", "", ErrProjectFolderNotFound
}
}
if err := os.MkdirAll(parentDir, 0o755); err != nil {
return "", "", fmt.Errorf("create parent project folder path: %w", err)
}
baseSlug := normalizePOSIXSlug(trimmedName)
folderName := slugDir("folder", baseSlug)
folderDir := filepath.Join(parentDir, folderName)
folderSlug := baseSlug
for attempt := 2; ; attempt += 1 {
if _, err := os.Stat(folderDir); os.IsNotExist(err) {
break
} else if err != nil {
return "", "", fmt.Errorf("stat candidate project folder: %w", err)
}
folderSlug = fmt.Sprintf("%s-%d", baseSlug, attempt)
folderName = slugDir("folder", folderSlug)
folderDir = filepath.Join(parentDir, folderName)
}
if err := os.MkdirAll(filepath.Join(folderDir, "children"), 0o755); err != nil {
return "", "", fmt.Errorf("create project hierarchy folder: %w", err)
}
folderID := uuid.NewString()
if err := writeJSONFile(filepath.Join(folderDir, "folder.json"), map[string]any{"id": folderID, "name": trimmedName, "slug": folderSlug, "type": "folder"}); err != nil {
return "", "", fmt.Errorf("write project folder.json: %w", err)
}
if err := writeJSONFile(filepath.Join(folderDir, "acl.json"), map[string]any{"version": 1, "inherits": true, "rules": []any{}}); err != nil {
return "", "", fmt.Errorf("write project acl.json: %w", 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 := readJSONFileMap(filepath.Join(destinationDir, "folder.json"))
folderMetadataID, _ := folderPayload["id"].(string)
if strings.TrimSpace(folderMetadataID) == "" {
folderMetadataID = uuid.NewString()
}
if err := writeJSONFile(filepath.Join(destinationDir, "folder.json"), map[string]any{"id": folderMetadataID, "name": trimmedName, "slug": folderSlug, "type": "folder"}); err != nil {
return "", "", fmt.Errorf("write renamed project folder.json: %w", 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 := readJSONFileMap(filepath.Join(folderDir, "folder.json"))
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 := writeJSONFile(filepath.Join(destinationDir, "folder.json"), folderPayload); err != nil {
return "", "", fmt.Errorf("write moved project folder.json: %w", err)
}
movedProjectionPath := filepath.ToSlash(filepath.Join(parentChildrenProjectionPath, folderDirName))
return folderProjectionPath, movedProjectionPath, nil
}
@@ -0,0 +1,138 @@
// Path: Backend/internal/bootstrap/project_disk_helpers.go
package bootstrap
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"unicode"
)
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 readJSONFileMap(path string) map[string]any {
data, err := os.ReadFile(path)
if err != nil {
return map[string]any{}
}
var payload map[string]any
if err := json.Unmarshal(data, &payload); err != nil || payload == nil {
return map[string]any{}
}
return payload
}
@@ -0,0 +1,189 @@
// Path: Backend/internal/bootstrap/project_disk_items.go
package bootstrap
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
)
func (service *Service) createProjectTreeItemOnDisk(projectSlug, parentFolderPath, name, itemType string) (string, error) {
posixRoot := strings.TrimSpace(service.posixRoot)
if posixRoot == "" {
return "", fmt.Errorf("POSIX root is not configured")
}
trimmedName := strings.TrimSpace(name)
if trimmedName == "" {
return "", fmt.Errorf("item name is required")
}
canonicalItemType := normalizeProjectTreeItemType(itemType)
containerProjectionPath := projectTreeRootPath(projectSlug)
parentDir := filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath))
if trimmedParentFolderPath := strings.TrimSpace(parentFolderPath); trimmedParentFolderPath != "" {
containerProjectionPath = trimmedParentFolderPath
parentDir = filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath))
info, err := os.Stat(parentDir)
if err != nil {
if os.IsNotExist(err) {
return "", ErrProjectFolderNotFound
}
return "", fmt.Errorf("stat parent project folder: %w", err)
}
if !info.IsDir() {
return "", ErrProjectFolderNotFound
}
}
if err := os.MkdirAll(parentDir, 0o755); err != nil {
return "", fmt.Errorf("create parent project item path: %w", err)
}
baseSlug := normalizePOSIXSlug(trimmedName)
itemDirName := slugDir("item", baseSlug)
itemDir := filepath.Join(parentDir, itemDirName)
itemSlug := baseSlug
for attempt := 2; ; attempt += 1 {
if _, err := os.Stat(itemDir); os.IsNotExist(err) {
break
} else if err != nil {
return "", fmt.Errorf("stat candidate project item: %w", err)
}
itemSlug = fmt.Sprintf("%s-%d", baseSlug, attempt)
itemDirName = slugDir("item", itemSlug)
itemDir = filepath.Join(parentDir, itemDirName)
}
if err := os.MkdirAll(itemDir, 0o755); err != nil {
return "", fmt.Errorf("create project item: %w", err)
}
itemID := uuid.NewString()
if err := writeJSONFile(filepath.Join(itemDir, "item.json"), map[string]any{"id": itemID, "name": trimmedName, "slug": itemSlug, "type": canonicalItemType}); err != nil {
return "", fmt.Errorf("write project item.json: %w", err)
}
if err := writeJSONFile(filepath.Join(itemDir, "schema.json"), defaultProjectTreeItemSchema(canonicalItemType)); err != nil {
return "", fmt.Errorf("write project schema.json: %w", err)
}
if err := writeJSONFile(filepath.Join(itemDir, "data.json"), defaultProjectTreeItemData(canonicalItemType, trimmedName)); err != nil {
return "", fmt.Errorf("write project data.json: %w", 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 := readJSONFileMap(filepath.Join(itemDir, "item.json"))
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 := writeJSONFile(filepath.Join(destinationDir, "item.json"), itemPayload); err != nil {
return "", "", fmt.Errorf("write moved project item.json: %w", err)
}
return itemProjectionPath, movedProjectionPath, nil
}
@@ -0,0 +1,491 @@
// Path: Backend/internal/bootstrap/project_mutations.go
package bootstrap
import (
"context"
"fmt"
"strings"
)
func (service *Service) CreateProjectFolder(ctx context.Context, input CreateProjectFolderInput) (CreateProjectFolderResult, error) {
return service.createProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.createProjectHierarchyFolderOnDisk)
}
func (service *Service) CreateProjectTreeFolder(ctx context.Context, input CreateProjectFolderInput) (CreateProjectFolderResult, error) {
return service.createProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.createProjectTreeFolderOnDisk)
}
func (service *Service) DeleteProjectFolder(ctx context.Context, input DeleteProjectFolderInput) (DeleteProjectFolderResult, error) {
return service.deleteProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.deleteProjectHierarchyFolderOnDisk)
}
func (service *Service) DeleteProjectTreeFolder(ctx context.Context, input DeleteProjectFolderInput) (DeleteProjectFolderResult, error) {
return service.deleteProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.deleteProjectTreeFolderOnDisk)
}
func (service *Service) RenameProjectFolder(ctx context.Context, input RenameProjectFolderInput) (RenameProjectFolderResult, error) {
return service.renameProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.renameProjectHierarchyFolderOnDisk)
}
func (service *Service) RenameProjectTreeFolder(ctx context.Context, input RenameProjectFolderInput) (RenameProjectFolderResult, error) {
return service.renameProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.renameProjectTreeFolderOnDisk)
}
func (service *Service) MoveProjectFolder(ctx context.Context, input MoveProjectFolderInput) (MoveProjectFolderResult, error) {
return service.moveProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.moveProjectHierarchyFolderOnDisk)
}
func (service *Service) MoveProjectTreeFolder(ctx context.Context, input MoveProjectFolderInput) (MoveProjectFolderResult, error) {
return service.moveProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.moveProjectTreeFolderOnDisk)
}
func (service *Service) CreateProjectTreeItem(ctx context.Context, input CreateProjectItemInput) (CreateProjectItemResult, error) {
return service.createProjectTreeItem(ctx, input, projectTreeRootPath)
}
func (service *Service) DeleteProjectTreeItem(ctx context.Context, input DeleteProjectItemInput) (DeleteProjectItemResult, error) {
return service.deleteProjectTreeItem(ctx, input, projectTreeRootPath)
}
func (service *Service) MoveProjectTreeItem(ctx context.Context, input MoveProjectItemInput) (MoveProjectItemResult, error) {
return service.moveProjectTreeItem(ctx, input, projectTreeRootPath)
}
func (service *Service) createProjectHierarchyFolder(
ctx context.Context,
input CreateProjectFolderInput,
rootPath func(projectSlug string) string,
createOnDisk func(projectSlug, parentFolderPath, name string) (string, string, error),
) (CreateProjectFolderResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil {
return CreateProjectFolderResult{}, err
}
currentFolders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return CreateProjectFolderResult{}, err
}
parentOrderID := ""
trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
if trimmedParentFolderPath != "" {
parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderPath)
if !found {
return CreateProjectFolderResult{}, ErrProjectFolderNotFound
}
parentOrderID = parentFolder.ID
}
createdPath, _, err := createOnDisk(project.Slug, strings.TrimSpace(input.ParentFolderPath), input.Name)
if err != nil {
return CreateProjectFolderResult{}, err
}
if err := service.rebuildProjection(ctx); err != nil {
return CreateProjectFolderResult{}, err
}
folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return CreateProjectFolderResult{}, err
}
createdFolder, ok := findProjectHierarchyFolderByPath(folders, createdPath)
if !ok {
return CreateProjectFolderResult{}, fmt.Errorf("created project folder missing from projection")
}
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
seedFolderOrderParent(folderOrder, currentFolders, parentOrderID)
insertFolderOrder(folderOrder, parentOrderID, createdFolder.ID, len(folderOrderChildren(folderOrder, parentOrderID)))
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
return CreateProjectFolderResult{}, err
}
folders, err = service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return CreateProjectFolderResult{}, err
}
createdFolder, ok = findProjectHierarchyFolderByPath(folders, createdPath)
if !ok {
return CreateProjectFolderResult{}, fmt.Errorf("created project folder missing from ordered projection")
}
return CreateProjectFolderResult{ProjectID: project.ID, CreatedFolder: createdFolder, Folders: folders}, nil
}
func (service *Service) deleteProjectHierarchyFolder(
ctx context.Context,
input DeleteProjectFolderInput,
rootPath func(projectSlug string) string,
deleteOnDisk func(projectSlug, folderPath string) (string, error),
) (DeleteProjectFolderResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil {
return DeleteProjectFolderResult{}, err
}
currentFolders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return DeleteProjectFolderResult{}, err
}
deletedFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderPath))
if !found {
return DeleteProjectFolderResult{}, ErrProjectFolderNotFound
}
deletedFolderPath, err := deleteOnDisk(project.Slug, input.FolderPath)
if err != nil {
return DeleteProjectFolderResult{}, err
}
if err := service.rebuildProjection(ctx); err != nil {
return DeleteProjectFolderResult{}, err
}
folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return DeleteProjectFolderResult{}, err
}
if _, found := findProjectHierarchyFolderByPath(folders, deletedFolderPath); found {
return DeleteProjectFolderResult{}, fmt.Errorf("deleted project folder still present in projection")
}
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
removeFolderOrder(folderOrder, deletedFolder.ID)
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
return DeleteProjectFolderResult{}, err
}
folders, err = service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return DeleteProjectFolderResult{}, err
}
return DeleteProjectFolderResult{ProjectID: project.ID, DeletedFolderStableID: deletedFolder.ID, DeletedFolderPath: deletedFolderPath, Folders: folders}, nil
}
func (service *Service) renameProjectHierarchyFolder(
ctx context.Context,
input RenameProjectFolderInput,
rootPath func(projectSlug string) string,
renameOnDisk func(projectSlug, folderPath, name string) (string, string, error),
) (RenameProjectFolderResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil {
return RenameProjectFolderResult{}, err
}
previousFolderPath, renamedFolderPath, err := renameOnDisk(project.Slug, input.FolderPath, input.Name)
if err != nil {
return RenameProjectFolderResult{}, err
}
if err := service.rebuildProjection(ctx); err != nil {
return RenameProjectFolderResult{}, err
}
folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return RenameProjectFolderResult{}, err
}
renamedFolder, found := findProjectHierarchyFolderByPath(folders, renamedFolderPath)
if !found {
return RenameProjectFolderResult{}, fmt.Errorf("renamed project folder missing from projection")
}
if _, found := findProjectHierarchyFolderByPath(folders, previousFolderPath); found {
return RenameProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection")
}
return RenameProjectFolderResult{ProjectID: project.ID, PreviousFolderStableID: renamedFolder.ID, PreviousFolderPath: previousFolderPath, RenamedFolder: renamedFolder, Folders: folders}, nil
}
func (service *Service) moveProjectHierarchyFolder(
ctx context.Context,
input MoveProjectFolderInput,
rootPath func(projectSlug string) string,
moveOnDisk func(projectSlug, folderPath, parentFolderPath string) (string, string, error),
) (MoveProjectFolderResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil {
return MoveProjectFolderResult{}, err
}
currentFolders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return MoveProjectFolderResult{}, err
}
currentFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderPath))
if !found {
return MoveProjectFolderResult{}, ErrProjectFolderNotFound
}
movedFolderStableID := currentFolder.ID
providedFolderStableID := strings.TrimSpace(input.FolderStableID)
if providedFolderStableID != "" && providedFolderStableID != movedFolderStableID {
return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove
}
parentOrderID := ""
trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
providedParentStableID := strings.TrimSpace(input.ParentStableID)
if trimmedParentFolderPath != "" {
parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderPath)
if !found {
return MoveProjectFolderResult{}, ErrProjectFolderNotFound
}
parentOrderID = parentFolder.ID
if providedParentStableID != "" && providedParentStableID != parentOrderID {
return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove
}
} else if providedParentStableID != "" {
return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove
}
previousFolderPath, movedFolderPath, err := moveOnDisk(project.Slug, input.FolderPath, input.ParentFolderPath)
if err != nil {
return MoveProjectFolderResult{}, err
}
if err := service.rebuildProjection(ctx); err != nil {
return MoveProjectFolderResult{}, err
}
folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return MoveProjectFolderResult{}, err
}
movedFolder, found := findProjectHierarchyFolderByPath(folders, movedFolderPath)
if !found {
return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from projection")
}
if previousFolderPath != movedFolderPath {
if _, found := findProjectHierarchyFolderByPath(folders, previousFolderPath); found {
return MoveProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection")
}
}
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
seedFolderOrderParent(folderOrder, currentFolders, parentOrderID)
removeFolderOrderReference(folderOrder, movedFolderStableID)
removeFolderOrderReference(folderOrder, movedFolder.ID)
insertFolderOrder(folderOrder, parentOrderID, movedFolder.ID, input.TargetIndex)
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
return MoveProjectFolderResult{}, err
}
folders, err = service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return MoveProjectFolderResult{}, err
}
movedFolder, found = findProjectHierarchyFolderByPath(folders, movedFolderPath)
if !found {
return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from ordered projection")
}
return MoveProjectFolderResult{ProjectID: project.ID, PreviousFolderStableID: movedFolder.ID, PreviousFolderPath: previousFolderPath, MovedFolder: movedFolder, Folders: folders}, nil
}
func (service *Service) createProjectTreeItem(ctx context.Context, input CreateProjectItemInput, rootPath func(projectSlug string) string) (CreateProjectItemResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil {
return CreateProjectItemResult{}, err
}
currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return CreateProjectItemResult{}, err
}
parentOrderID := ""
trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
if trimmedParentFolderPath != "" {
parentFolder, found := findProjectTreeFolderByPath(currentNodes, trimmedParentFolderPath)
if !found {
return CreateProjectItemResult{}, ErrProjectFolderNotFound
}
parentOrderID = parentFolder.ID
}
createdPath, err := service.createProjectTreeItemOnDisk(project.Slug, trimmedParentFolderPath, input.Name, input.ItemType)
if err != nil {
return CreateProjectItemResult{}, err
}
if err := service.rebuildProjection(ctx); err != nil {
return CreateProjectItemResult{}, err
}
nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return CreateProjectItemResult{}, err
}
createdItem, ok := findProjectTreeNodeByPath(nodes, createdPath)
if !ok || createdItem.Kind != "item" {
return CreateProjectItemResult{}, fmt.Errorf("created project item missing from projection")
}
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
seedProjectTreeOrderParent(folderOrder, currentNodes, parentOrderID)
insertFolderOrder(folderOrder, parentOrderID, createdItem.ID, len(folderOrderChildren(folderOrder, parentOrderID)))
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
return CreateProjectItemResult{}, err
}
nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return CreateProjectItemResult{}, err
}
createdItem, ok = findProjectTreeNodeByPath(nodes, createdPath)
if !ok || createdItem.Kind != "item" {
return CreateProjectItemResult{}, fmt.Errorf("created project item missing from ordered projection")
}
return CreateProjectItemResult{ProjectID: project.ID, CreatedItem: createdItem, Nodes: nodes}, nil
}
func (service *Service) deleteProjectTreeItem(ctx context.Context, input DeleteProjectItemInput, rootPath func(projectSlug string) string) (DeleteProjectItemResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil {
return DeleteProjectItemResult{}, err
}
currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return DeleteProjectItemResult{}, err
}
deletedItem, found := findProjectTreeNodeByPath(currentNodes, strings.TrimSpace(input.ItemPath))
if !found || deletedItem.Kind != "item" {
return DeleteProjectItemResult{}, ErrProjectItemNotFound
}
deletedItemPath, err := service.deleteProjectTreeItemOnDisk(project.Slug, input.ItemPath)
if err != nil {
return DeleteProjectItemResult{}, err
}
if err := service.rebuildProjection(ctx); err != nil {
return DeleteProjectItemResult{}, err
}
nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return DeleteProjectItemResult{}, err
}
if _, found := findProjectTreeNodeByPath(nodes, deletedItemPath); found {
return DeleteProjectItemResult{}, fmt.Errorf("deleted project item still present in projection")
}
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
removeFolderOrderReference(folderOrder, deletedItem.ID)
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
return DeleteProjectItemResult{}, err
}
nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return DeleteProjectItemResult{}, err
}
return DeleteProjectItemResult{ProjectID: project.ID, DeletedItemStableID: deletedItem.ID, DeletedItemPath: deletedItemPath, Nodes: nodes}, nil
}
func (service *Service) moveProjectTreeItem(ctx context.Context, input MoveProjectItemInput, rootPath func(projectSlug string) string) (MoveProjectItemResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil {
return MoveProjectItemResult{}, err
}
currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return MoveProjectItemResult{}, err
}
currentItem, found := findProjectTreeNodeByPath(currentNodes, strings.TrimSpace(input.ItemPath))
if !found || currentItem.Kind != "item" {
return MoveProjectItemResult{}, ErrProjectItemNotFound
}
movedItemStableID := currentItem.ID
providedItemStableID := strings.TrimSpace(input.ItemStableID)
if providedItemStableID != "" && providedItemStableID != movedItemStableID {
return MoveProjectItemResult{}, ErrInvalidProjectItemMove
}
parentOrderID := ""
trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
providedParentStableID := strings.TrimSpace(input.ParentStableID)
if trimmedParentFolderPath != "" {
parentFolder, found := findProjectTreeFolderByPath(currentNodes, trimmedParentFolderPath)
if !found {
return MoveProjectItemResult{}, ErrProjectFolderNotFound
}
parentOrderID = parentFolder.ID
if providedParentStableID != "" && providedParentStableID != parentOrderID {
return MoveProjectItemResult{}, ErrInvalidProjectItemMove
}
} else if providedParentStableID != "" {
return MoveProjectItemResult{}, ErrInvalidProjectItemMove
}
previousItemPath, movedItemPath, err := service.moveProjectTreeItemOnDisk(project.Slug, input.ItemPath, input.ParentFolderPath)
if err != nil {
return MoveProjectItemResult{}, err
}
if err := service.rebuildProjection(ctx); err != nil {
return MoveProjectItemResult{}, err
}
nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return MoveProjectItemResult{}, err
}
movedItem, found := findProjectTreeNodeByPath(nodes, movedItemPath)
if !found || movedItem.Kind != "item" {
return MoveProjectItemResult{}, fmt.Errorf("moved project item missing from projection")
}
if previousItemPath != movedItemPath {
if _, found := findProjectTreeNodeByPath(nodes, previousItemPath); found {
return MoveProjectItemResult{}, fmt.Errorf("previous project item path still present in projection")
}
}
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
seedProjectTreeOrderParent(folderOrder, currentNodes, parentOrderID)
removeFolderOrderReference(folderOrder, movedItemStableID)
removeFolderOrderReference(folderOrder, movedItem.ID)
insertFolderOrder(folderOrder, parentOrderID, movedItem.ID, input.TargetIndex)
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
return MoveProjectItemResult{}, err
}
nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return MoveProjectItemResult{}, err
}
movedItem, found = findProjectTreeNodeByPath(nodes, movedItemPath)
if !found || movedItem.Kind != "item" {
return MoveProjectItemResult{}, fmt.Errorf("moved project item missing from ordered projection")
}
return MoveProjectItemResult{ProjectID: project.ID, PreviousItemStableID: movedItem.ID, PreviousItemPath: previousItemPath, MovedItem: movedItem, Nodes: nodes}, nil
}
+329
View File
@@ -0,0 +1,329 @@
// 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 := readJSONFileMap(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 := readJSONFileMap(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 := writeJSONFile(settingsPath, settingsPayload); err != nil { return fmt.Errorf("write project settings.json: %w", err) }
return nil
}
func (service *Service) projectSettingsPath(projectSlug string) string {
return filepath.Join(strings.TrimSpace(service.posixRoot), "projects", slugDir("project", projectSlug), "settings.json")
}
func projectFolderOrderScope(rootProjectionPath string) string {
if strings.HasSuffix(rootProjectionPath, "/tree") { return projectFolderOrderTree }
return projectFolderOrderHierarchy
}
func applyProjectHierarchyFolderOrdering(folders []ProjectHierarchyFolderRecord, folderOrder map[string][]string) []ProjectHierarchyFolderRecord {
return applyProjectHierarchyFolderOrderingForParent(folders, "", folderOrder)
}
func applyProjectHierarchyFolderOrderingForParent(folders []ProjectHierarchyFolderRecord, parentID string, folderOrder map[string][]string) []ProjectHierarchyFolderRecord {
if len(folders) == 0 { return folders }
nextFolders := make([]ProjectHierarchyFolderRecord, len(folders))
copy(nextFolders, folders)
for index := range nextFolders { nextFolders[index].Children = applyProjectHierarchyFolderOrderingForParent(nextFolders[index].Children, nextFolders[index].ID, folderOrder) }
orderIDs := folderOrder[projectFolderOrderParentKey(parentID)]
if len(orderIDs) == 0 { return nextFolders }
rankByID := make(map[string]int, len(orderIDs))
for index, id := range orderIDs { if _, exists := rankByID[id]; !exists { rankByID[id] = index } }
sort.SliceStable(nextFolders, func(left, right int) bool {
leftRank, leftOrdered := rankByID[nextFolders[left].ID]
rightRank, rightOrdered := rankByID[nextFolders[right].ID]
if leftOrdered && rightOrdered { return leftRank < rightRank }
if leftOrdered != rightOrdered { return leftOrdered }
return false
})
return nextFolders
}
func removeFolderOrder(folderOrder map[string][]string, folderID string) {
removeFolderOrderReference(folderOrder, folderID)
trimmedFolderID := strings.TrimSpace(folderID)
if trimmedFolderID == "" { return }
delete(folderOrder, projectFolderOrderParentKey(trimmedFolderID))
}
func removeFolderOrderReference(folderOrder map[string][]string, folderID string) {
trimmedFolderID := strings.TrimSpace(folderID)
if trimmedFolderID == "" { return }
for key, ids := range folderOrder {
nextIDs := ids[:0]
for _, id := range ids {
if strings.TrimSpace(id) == trimmedFolderID { continue }
nextIDs = append(nextIDs, id)
}
if len(nextIDs) == 0 { delete(folderOrder, key); continue }
folderOrder[key] = append([]string(nil), nextIDs...)
}
}
func insertFolderOrder(folderOrder map[string][]string, parentID, folderID string, index int) {
trimmedFolderID := strings.TrimSpace(folderID)
if trimmedFolderID == "" { return }
removeFolderOrderReference(folderOrder, trimmedFolderID)
parentKey := projectFolderOrderParentKey(parentID)
children := append([]string(nil), folderOrder[parentKey]...)
if index < 0 { index = 0 }
if index > len(children) { index = len(children) }
children = slicesInsert(children, index, trimmedFolderID)
folderOrder[parentKey] = children
}
func seedFolderOrderParent(folderOrder map[string][]string, folders []ProjectHierarchyFolderRecord, parentID string) {
children := folders
trimmedParentID := strings.TrimSpace(parentID)
if trimmedParentID != "" {
parent, found := findProjectHierarchyFolder(folders, trimmedParentID)
if !found { return }
children = parent.Children
}
parentKey := projectFolderOrderParentKey(trimmedParentID)
seeded := make([]string, 0, len(children))
for _, child := range children {
childID := strings.TrimSpace(child.ID)
if childID == "" || slicesContains(seeded, childID) { continue }
seeded = append(seeded, childID)
}
if len(seeded) == 0 { delete(folderOrder, parentKey); return }
folderOrder[parentKey] = seeded
}
func folderOrderChildren(folderOrder map[string][]string, parentID string) []string {
return append([]string(nil), folderOrder[projectFolderOrderParentKey(parentID)]...)
}
func projectFolderOrderParentKey(parentID string) string {
trimmedParentID := strings.TrimSpace(parentID)
if trimmedParentID == "" { return projectFolderOrderRootKey }
return trimmedParentID
}
func stringSliceValue(value any) []string {
items, ok := value.([]any)
if !ok {
if typed, ok := value.([]string); ok { return typed }
return nil
}
result := make([]string, 0, len(items))
for _, item := range items { if text, ok := item.(string); ok { result = append(result, text) } }
return result
}
func slicesContains(values []string, value string) bool {
for _, existing := range values { if existing == value { return true } }
return false
}
func slicesInsert(values []string, index int, value string) []string {
values = append(values, "")
copy(values[index+1:], values[index:])
values[index] = value
return values
}
func buildProjectHierarchyFolderTree(rows []projectHierarchyFolderRow, rootParentPath string) []ProjectHierarchyFolderRecord {
if len(rows) == 0 { return nil }
nodesByPath := make(map[string]*ProjectHierarchyFolderRecord, len(rows))
childrenByParent := make(map[string][]string)
for _, row := range rows {
folderID := strings.TrimSpace(row.ID)
if folderID == "" { folderID = row.Path }
label := strings.TrimSpace(row.Label)
if label == "" { label = fallbackFolderLabel(row.Path) }
nodesByPath[row.Path] = &ProjectHierarchyFolderRecord{ID: folderID, Path: row.Path, Label: label, Children: []ProjectHierarchyFolderRecord{}}
childrenByParent[row.ParentPath] = append(childrenByParent[row.ParentPath], row.Path)
}
var build func(parentPath string) []ProjectHierarchyFolderRecord
build = func(parentPath string) []ProjectHierarchyFolderRecord {
childPaths := childrenByParent[parentPath]
if len(childPaths) == 0 { return nil }
folders := make([]ProjectHierarchyFolderRecord, 0, len(childPaths))
for _, childPath := range childPaths {
node := nodesByPath[childPath]
if node == nil { continue }
folder := ProjectHierarchyFolderRecord{ID: node.ID, Path: node.Path, Label: node.Label, Children: build(filepath.ToSlash(filepath.Join(childPath, "children")))}
folders = append(folders, folder)
}
return folders
}
return build(rootParentPath)
}
func buildProjectTreeNodeTree(rows []projectTreeNodeRow, rootParentPath string) []ProjectTreeNodeRecord {
if len(rows) == 0 { return nil }
nodesByPath := make(map[string]*ProjectTreeNodeRecord, len(rows))
childrenByParent := make(map[string][]string)
for _, row := range rows {
nodeKind := normalizeProjectTreeNodeKind(row.Kind)
nodeID := strings.TrimSpace(row.ID)
if nodeID == "" { nodeID = row.Path }
label := strings.TrimSpace(row.Label)
if label == "" { if nodeKind == "item" { label = fallbackItemLabel(row.Path) } else { label = fallbackFolderLabel(row.Path) } }
nodesByPath[row.Path] = &ProjectTreeNodeRecord{ID: nodeID, Path: row.Path, Label: label, Kind: nodeKind, ItemType: normalizeProjectTreeItemType(row.ItemType), Children: []ProjectTreeNodeRecord{}}
parentKey := normalizeProjectTreeParentPath(nodeKind, row.ParentPath)
childrenByParent[parentKey] = append(childrenByParent[parentKey], row.Path)
}
var build func(parentPath string) []ProjectTreeNodeRecord
build = func(parentPath string) []ProjectTreeNodeRecord {
childPaths := childrenByParent[parentPath]
if len(childPaths) == 0 { return nil }
nodes := make([]ProjectTreeNodeRecord, 0, len(childPaths))
for _, childPath := range childPaths {
node := nodesByPath[childPath]
if node == nil { continue }
nextNode := ProjectTreeNodeRecord{ID: node.ID, Path: node.Path, Label: node.Label, Kind: node.Kind, ItemType: node.ItemType}
if node.Kind == "folder" { nextNode.Children = build(node.Path) }
nodes = append(nodes, nextNode)
}
return nodes
}
return build(rootParentPath)
}
func normalizeProjectTreeParentPath(kind, parentPath string) string {
if kind == "folder" && strings.HasSuffix(parentPath, "/children") { return filepath.ToSlash(filepath.Dir(parentPath)) }
return parentPath
}
func normalizeProjectTreeNodeKind(kind string) string {
switch strings.TrimSpace(kind) {
case "item":
return "item"
case "folder", "hierarchy_folder":
return "folder"
default:
return "folder"
}
}
func applyProjectTreeNodeOrdering(nodes []ProjectTreeNodeRecord, folderOrder map[string][]string) []ProjectTreeNodeRecord {
return applyProjectTreeNodeOrderingForParent(nodes, "", folderOrder)
}
func applyProjectTreeNodeOrderingForParent(nodes []ProjectTreeNodeRecord, parentID string, folderOrder map[string][]string) []ProjectTreeNodeRecord {
if len(nodes) == 0 { return nodes }
nextNodes := make([]ProjectTreeNodeRecord, len(nodes))
copy(nextNodes, nodes)
for index := range nextNodes { if nextNodes[index].Kind == "folder" { nextNodes[index].Children = applyProjectTreeNodeOrderingForParent(nextNodes[index].Children, nextNodes[index].ID, folderOrder) } }
orderIDs := folderOrder[projectFolderOrderParentKey(parentID)]
if len(orderIDs) == 0 { return nextNodes }
rankByID := make(map[string]int, len(orderIDs))
for index, id := range orderIDs { if _, exists := rankByID[id]; !exists { rankByID[id] = index } }
sort.SliceStable(nextNodes, func(left, right int) bool {
leftRank, leftOrdered := rankByID[nextNodes[left].ID]
rightRank, rightOrdered := rankByID[nextNodes[right].ID]
if leftOrdered && rightOrdered { return leftRank < rightRank }
if leftOrdered != rightOrdered { return leftOrdered }
return false
})
return nextNodes
}
func findProjectHierarchyFolder(folders []ProjectHierarchyFolderRecord, folderID string) (ProjectHierarchyFolderRecord, bool) {
for _, folder := range folders {
if folder.ID == folderID { return folder, true }
if child, ok := findProjectHierarchyFolder(folder.Children, folderID); ok { return child, true }
}
return ProjectHierarchyFolderRecord{}, false
}
func findProjectHierarchyFolderByPath(folders []ProjectHierarchyFolderRecord, folderPath string) (ProjectHierarchyFolderRecord, bool) {
for _, folder := range folders {
if folder.Path == folderPath { return folder, true }
if child, ok := findProjectHierarchyFolderByPath(folder.Children, folderPath); ok { return child, true }
}
return ProjectHierarchyFolderRecord{}, false
}
func findProjectTreeNode(nodes []ProjectTreeNodeRecord, nodeID string) (ProjectTreeNodeRecord, bool) {
for _, node := range nodes {
if node.ID == nodeID { return node, true }
if child, ok := findProjectTreeNode(node.Children, nodeID); ok { return child, true }
}
return ProjectTreeNodeRecord{}, false
}
func findProjectTreeNodeByPath(nodes []ProjectTreeNodeRecord, nodePath string) (ProjectTreeNodeRecord, bool) {
for _, node := range nodes {
if node.Path == nodePath { return node, true }
if child, ok := findProjectTreeNodeByPath(node.Children, nodePath); ok { return child, true }
}
return ProjectTreeNodeRecord{}, false
}
func findProjectTreeFolderByPath(nodes []ProjectTreeNodeRecord, folderPath string) (ProjectTreeNodeRecord, bool) {
node, ok := findProjectTreeNodeByPath(nodes, folderPath)
if !ok || node.Kind != "folder" { return ProjectTreeNodeRecord{}, false }
return node, true
}
func seedProjectTreeOrderParent(folderOrder map[string][]string, nodes []ProjectTreeNodeRecord, parentID string) {
children := nodes
trimmedParentID := strings.TrimSpace(parentID)
if trimmedParentID != "" {
parent, found := findProjectTreeNode(nodes, trimmedParentID)
if !found || parent.Kind != "folder" { return }
children = parent.Children
}
parentKey := projectFolderOrderParentKey(parentID)
if len(folderOrder[parentKey]) > 0 { return }
orderedIDs := make([]string, 0, len(children))
for _, child := range children {
trimmedChildID := strings.TrimSpace(child.ID)
if trimmedChildID == "" || slicesContains(orderedIDs, trimmedChildID) { continue }
orderedIDs = append(orderedIDs, trimmedChildID)
}
if len(orderedIDs) > 0 { folderOrder[parentKey] = orderedIDs }
}
@@ -0,0 +1,347 @@
// 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.json'
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.path = directories.path || CASE
WHEN directories.logical_type = 'hierarchy_folder' THEN '/folder.json'
WHEN directories.logical_type = 'item' THEN '/item.json'
ELSE ''
END
AND node_meta.node_kind = 'file'::posix_node_kind
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()
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,127 @@
// Path: Backend/internal/httpx/api_project_decode.go
package httpx
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
)
type createProjectFolderRequest struct {
Name string `json:"name"`
ParentFolderPath string `json:"parentFolderId"`
}
type renameProjectFolderRequest struct {
FolderPath string `json:"folderId"`
Name string `json:"name"`
}
type deleteProjectFolderRequest struct {
FolderPath string `json:"folderId"`
}
// Keep the existing JSON contract for the frontend, but use clearer path-vs-stable-ID
// names internally so the move flow is easier to reason about.
type moveProjectFolderRequest struct {
FolderPath string `json:"folderId"`
FolderStableID string `json:"folderNodeId"`
ParentFolderPath string `json:"parentFolderId"`
ParentStableID string `json:"parentNodeId"`
TargetIndex int `json:"targetIndex"`
}
type createProjectItemRequest struct {
Name string `json:"name"`
ParentFolderPath string `json:"parentFolderId"`
ItemType string `json:"itemType"`
}
type deleteProjectItemRequest struct {
ItemPath string `json:"itemId"`
}
type moveProjectItemRequest struct {
ItemPath string `json:"itemId"`
ItemStableID string `json:"itemNodeId"`
ParentFolderPath string `json:"parentFolderId"`
ParentStableID string `json:"parentNodeId"`
TargetIndex int `json:"targetIndex"`
}
func decodeMoveProjectFolderRequest(w http.ResponseWriter, r *http.Request) (moveProjectFolderRequest, bool) {
var payload moveProjectFolderRequest
if !decodeJSONObjectBody(w, r, &payload) {
return payload, false
}
return payload, true
}
func decodeDeleteProjectFolderRequest(r *http.Request) deleteProjectFolderRequest {
return deleteProjectFolderRequest{
FolderPath: strings.TrimSpace(r.URL.Query().Get("folderId")),
}
}
func decodeDeleteProjectItemRequest(r *http.Request) deleteProjectItemRequest {
return deleteProjectItemRequest{
ItemPath: strings.TrimSpace(r.URL.Query().Get("itemId")),
}
}
func decodeRenameProjectFolderRequest(w http.ResponseWriter, r *http.Request) (renameProjectFolderRequest, bool) {
var payload renameProjectFolderRequest
if !decodeJSONObjectBody(w, r, &payload) {
return payload, false
}
return payload, true
}
func decodeProjectFolderRequest(w http.ResponseWriter, r *http.Request) (createProjectFolderRequest, bool) {
var payload createProjectFolderRequest
if !decodeJSONObjectBody(w, r, &payload) {
return payload, false
}
return payload, true
}
func decodeProjectItemRequest(w http.ResponseWriter, r *http.Request) (createProjectItemRequest, bool) {
var payload createProjectItemRequest
if !decodeJSONObjectBody(w, r, &payload) {
return payload, false
}
return payload, true
}
func decodeMoveProjectItemRequest(w http.ResponseWriter, r *http.Request) (moveProjectItemRequest, bool) {
var payload moveProjectItemRequest
if !decodeJSONObjectBody(w, r, &payload) {
return payload, false
}
return payload, true
}
func decodeJSONObjectBody(w http.ResponseWriter, r *http.Request, target any) bool {
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
if errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body is required and must be valid JSON.")
return false
}
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.")
return false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must contain a single JSON object.")
return false
}
return true
}
@@ -0,0 +1,42 @@
// Path: Backend/internal/httpx/api_project_errors.go
package httpx
import (
"errors"
"net/http"
bootstrapservice "moku-backend/internal/bootstrap"
)
func (routes apiRoutes) writeProjectFolderError(w http.ResponseWriter, r *http.Request, err error, operation string) {
switch {
case errors.Is(err, bootstrapservice.ErrProjectNotFound), errors.Is(err, bootstrapservice.ErrProjectFolderNotFound):
WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", err.Error())
case errors.Is(err, bootstrapservice.ErrInvalidProjectFolderMove):
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", err.Error())
default:
routes.cfg.Logger.Error(operation+" project folder", "error", err, "path", r.URL.Path)
message := "Failed to " + operation + " project folder."
if routes.cfg.Config.IsDevelopment() {
message = message + " " + err.Error()
}
WriteError(w, http.StatusInternalServerError, RequestIDFromContext(r.Context()), "project_folder_"+operation+"_failed", message)
}
}
func (routes apiRoutes) writeProjectTreeError(w http.ResponseWriter, r *http.Request, err error, operation string) {
switch {
case errors.Is(err, bootstrapservice.ErrProjectNotFound), errors.Is(err, bootstrapservice.ErrProjectFolderNotFound), errors.Is(err, bootstrapservice.ErrProjectItemNotFound):
WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", err.Error())
case errors.Is(err, bootstrapservice.ErrInvalidProjectFolderMove), errors.Is(err, bootstrapservice.ErrInvalidProjectItemMove):
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", err.Error())
default:
routes.cfg.Logger.Error(operation+" project tree", "error", err, "path", r.URL.Path)
message := "Failed to " + operation + " project tree."
if routes.cfg.Config.IsDevelopment() {
message = message + " " + err.Error()
}
WriteError(w, http.StatusInternalServerError, RequestIDFromContext(r.Context()), "project_tree_"+operation+"_failed", message)
}
}
+2 -209
View File
@@ -1,9 +1,8 @@
// Path: Backend/internal/httpx/api_project_routes.go
package httpx
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
@@ -12,48 +11,6 @@ import (
"github.com/go-chi/chi/v5"
)
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 (routes apiRoutes) handleProjectFolders(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" {
@@ -551,167 +508,3 @@ func (routes apiRoutes) handleMoveProjectTreeItem(w http.ResponseWriter, r *http
},
})
}
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)
}
}
func decodeMoveProjectFolderRequest(w http.ResponseWriter, r *http.Request) (moveProjectFolderRequest, bool) {
var payload moveProjectFolderRequest
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.")
return payload, false
}
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.")
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
}
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
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.")
return payload, false
}
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.")
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
}
func decodeProjectFolderRequest(w http.ResponseWriter, r *http.Request) (createProjectFolderRequest, bool) {
var payload createProjectFolderRequest
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.")
return payload, false
}
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.")
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
}
func decodeProjectItemRequest(w http.ResponseWriter, r *http.Request) (createProjectItemRequest, bool) {
var payload createProjectItemRequest
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.")
return payload, false
}
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.")
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
}
func decodeMoveProjectItemRequest(w http.ResponseWriter, r *http.Request) (moveProjectItemRequest, bool) {
var payload moveProjectItemRequest
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.")
return payload, false
}
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.")
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
}
+2
View File
@@ -1,3 +1,5 @@
// Path: Backend/internal/jobs/store.go
package jobs
import (
+5 -454
View File
@@ -1,72 +1,15 @@
// Path: Backend/internal/posixproj/projector.go
package posixproj
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"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
}
func NewProjector(db *database.DB, root string) *Projector {
return &Projector{db: db, root: strings.TrimSpace(root)}
}
@@ -145,9 +88,9 @@ func (projector *Projector) RebuildWithSummary(ctx context.Context) (RebuildSumm
node.TeamSlug,
node.ProjectSlug,
node.PersonalSlug,
node.ContentJSON,
node.SizeBytes,
node.Checksum,
node.ContentJSON,
node.SizeBytes,
node.Checksum,
); err != nil {
return RebuildSummary{}, fmt.Errorf("insert posix node %s: %w", node.Path, err)
}
@@ -159,395 +102,3 @@ func (projector *Projector) RebuildWithSummary(ctx context.Context) (RebuildSumm
return summary, nil
}
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, "settings.json")
content, err := os.ReadFile(settingsPath)
if err != nil {
if errorsIsNotExist(err) {
return Scope{}, nil
}
return Scope{}, fmt.Errorf("read root settings.json: %w", err)
}
var payload map[string]any
if err := json.Unmarshal(content, &payload); err != nil {
return Scope{}, fmt.Errorf("decode root settings.json: %w", err)
}
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 strings.EqualFold(filepath.Ext(entry.Name()), ".json") {
var payload map[string]any
if err := json.Unmarshal(content, &payload); 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 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]
if !isDir {
fileRole = strings.TrimSuffix(name, filepath.Ext(name))
}
hasChildrenAncestor := pathContainsSegment(parts, "children")
hasTreeAncestor := pathContainsSegment(parts, "tree")
parentName := ""
if len(parts) >= 2 {
parentName = parts[len(parts)-2]
}
switch {
case relPath == "settings.json":
return "tenant", "settings"
case relPath == "layout.json":
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 ""
}
func summarizeNodes(nodes []Node) RebuildSummary {
summary := RebuildSummary{TotalNodes: len(nodes)}
for _, node := range nodes {
switch node.NodeKind {
case NodeKindDirectory:
summary.DirectoryCount++
case NodeKindFile:
summary.FileCount++
}
}
return summary
}
func errorsIsNotExist(err error) bool {
return err != nil && os.IsNotExist(err)
}
@@ -0,0 +1,175 @@
// 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]
if !isDir {
fileRole = strings.TrimSuffix(name, filepath.Ext(name))
}
hasChildrenAncestor := pathContainsSegment(parts, "children")
hasTreeAncestor := pathContainsSegment(parts, "tree")
parentName := ""
if len(parts) >= 2 {
parentName = parts[len(parts)-2]
}
switch {
case relPath == "settings.json":
return "tenant", "settings"
case relPath == "layout.json":
return "tenant", "layout"
case len(parts) >= 1 && parts[0] == "catalog":
if isDir {
if len(parts) == 1 {
return "catalog", ""
}
if len(parts) >= 2 && parts[1] == "packs" {
if len(parts) == 2 { return "catalog_packs", "" }
if len(parts) == 3 { return "catalog_pack", "" }
if len(parts) >= 4 && parts[3] == "entries" { return "catalog_pack_entries", "" }
return "catalog_entry", ""
}
if len(parts) >= 2 && parts[1] == "standalone" {
if len(parts) == 2 { return "catalog_standalone", "" }
return "catalog_entry", ""
}
}
return "catalog", fileRole
case len(parts) >= 2 && parts[0] == "departments" && strings.HasPrefix(parts[1], "department-"):
if isDir {
if len(parts) == 2 { return "department", "" }
if len(parts) == 3 && parts[2] == "teams" { return "department_teams", "" }
if len(parts) >= 4 && strings.HasPrefix(parts[3], "team-") { return "team", "" }
}
if len(parts) >= 4 && strings.HasPrefix(parts[3], "team-") { return "team", fileRole }
return "department", fileRole
case len(parts) >= 2 && parts[0] == "projects" && strings.HasPrefix(parts[1], "project-"):
if isDir {
if strings.HasPrefix(name, "project-") { return "project", "" }
if name == "children" { return "project_children", "" }
if name == "tree" { return "project_tree", "" }
if hasChildrenAncestor && strings.HasPrefix(name, "folder-") { return "hierarchy_folder", "" }
if hasTreeAncestor && strings.HasPrefix(name, "folder-") { return "hierarchy_folder", "" }
if hasTreeAncestor && strings.HasPrefix(name, "item-") { return "item", "" }
}
if hasChildrenAncestor && strings.HasPrefix(parentName, "folder-") { return "hierarchy_folder", fileRole }
if hasTreeAncestor {
if strings.HasPrefix(parentName, "item-") { return "item", fileRole }
if strings.HasPrefix(parentName, "folder-") { return "hierarchy_folder", fileRole }
}
return "project", fileRole
case len(parts) >= 1 && parts[0] == "users":
if isDir {
if len(parts) == 1 { return "users", "" }
if len(parts) == 2 && parts[1] == "personals" { return "personals", "" }
if len(parts) >= 3 && parts[1] == "personals" && strings.HasPrefix(parts[2], "personal-") { return "personal", "" }
if strings.Contains(relPath, "/tree/") || strings.HasSuffix(relPath, "/tree") {
if strings.HasPrefix(name, "folder-") { return "folder", "" }
if strings.HasPrefix(name, "item-") { return "item", "" }
}
}
if len(parts) >= 3 && parts[1] == "personals" && strings.HasPrefix(parts[2], "personal-") {
if strings.Contains(relPath, "/tree/") {
if strings.HasPrefix(parts[len(parts)-2], "item-") { return "item", fileRole }
if strings.HasPrefix(parts[len(parts)-2], "folder-") { return "folder", fileRole }
}
return "personal", fileRole
}
return "users", fileRole
default:
if isDir { return "directory", "" }
return "file", fileRole
}
}
func pathContainsSegment(parts []string, target string) bool {
for _, part := range parts {
if part == target {
return true
}
}
return false
}
func projectionParentPath(relPath string) *string {
if relPath == "" || relPath == rootProjectionPath {
return nil
}
parent := filepath.ToSlash(filepath.Dir(relPath))
if parent == "." || parent == "" {
root := rootProjectionPath
return &root
}
return &parent
}
func inferredResourceSlug(logicalType string, scope Scope) string {
switch logicalType {
case "department":
return scope.DepartmentSlug
case "team":
return scope.TeamSlug
case "project":
return scope.ProjectSlug
case "personal":
return scope.PersonalSlug
default:
return ""
}
}
func parentEntityName(logicalType string, scope Scope) string {
switch logicalType {
case "department":
return scope.DepartmentSlug
case "team":
return scope.TeamSlug
case "project":
return scope.ProjectSlug
case "personal":
return scope.PersonalSlug
default:
return ""
}
}
func stringValue(value any) string {
stringValue, _ := value.(string)
return strings.TrimSpace(stringValue)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
@@ -0,0 +1,189 @@
// Path: Backend/internal/posixproj/projector_scan.go
package posixproj
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
)
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, "settings.json")
content, err := os.ReadFile(settingsPath)
if err != nil {
if errorsIsNotExist(err) {
return Scope{}, nil
}
return Scope{}, fmt.Errorf("read root settings.json: %w", err)
}
var payload map[string]any
if err := json.Unmarshal(content, &payload); err != nil {
return Scope{}, fmt.Errorf("decode root settings.json: %w", err)
}
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 strings.EqualFold(filepath.Ext(entry.Name()), ".json") {
var payload map[string]any
if err := json.Unmarshal(content, &payload); 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 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,3 +1,5 @@
// Path: Backend/internal/posixproj/projector_test.go
package posixproj
import (
@@ -0,0 +1,58 @@
// Path: Backend/internal/posixproj/projector_types.go
package posixproj
import "moku-backend/internal/database"
const rootProjectionPath = "/"
type Projector struct {
db *database.DB
root string
}
type RebuildSummary struct {
TotalNodes int
DirectoryCount int
FileCount int
}
type NodeKind string
const (
NodeKindDirectory NodeKind = "directory"
NodeKindFile NodeKind = "file"
)
type Scope struct {
InstallationID string
OrganizationID string
OrganizationSlug string
DepartmentSlug string
TeamSlug string
ProjectSlug string
PersonalSlug string
}
type Node struct {
Path string
ParentPath *string
Name string
Depth int
NodeKind NodeKind
LogicalType string
FileRole string
ResourceID string
ResourceName string
ResourceSlug string
InstallationID string
OrganizationID string
OrganizationSlug string
DepartmentSlug string
TeamSlug string
ProjectSlug string
PersonalSlug string
ContentJSON []byte
SizeBytes int64
Checksum string
}
+2
View File
@@ -1,3 +1,5 @@
// Path: Backend/internal/worker/runner.go
package worker
import (
+2
View File
@@ -1,3 +1,5 @@
// Path: Backend/internal/worker/runner_test.go
package worker
import (