Feat: persist project tree hierarchy
This commit is contained in:
@@ -841,19 +841,34 @@ func (service *Service) loadProjectByID(ctx context.Context, projectID string) (
|
||||
}
|
||||
|
||||
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) 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 path, COALESCE(parent_path, ''), COALESCE(resource_name, '')
|
||||
FROM posix_nodes
|
||||
WHERE node_kind = 'directory'::posix_node_kind
|
||||
AND logical_type = 'hierarchy_folder'
|
||||
AND project_slug = $1
|
||||
AND path LIKE $2
|
||||
ORDER BY depth ASC, path ASC;
|
||||
`, project.Slug)
|
||||
`, project.Slug, rootParentPath+"/%")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -872,16 +887,29 @@ func (service *Service) GetProjectHierarchyFolders(ctx context.Context, projectI
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buildProjectHierarchyFolderTree(folderRows, projectHierarchyRootPath(project.Slug)), nil
|
||||
return buildProjectHierarchyFolderTree(folderRows, rootParentPath), nil
|
||||
}
|
||||
|
||||
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) createProjectHierarchyFolder(
|
||||
ctx context.Context,
|
||||
input CreateProjectFolderInput,
|
||||
rootPath func(projectSlug string) string,
|
||||
createOnDisk func(projectSlug, parentFolderID, name string) (string, string, error),
|
||||
) (CreateProjectFolderResult, error) {
|
||||
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
|
||||
if err != nil {
|
||||
return CreateProjectFolderResult{}, err
|
||||
}
|
||||
|
||||
createdPath, _, err := service.createProjectHierarchyFolderOnDisk(project.Slug, strings.TrimSpace(input.ParentFolderID), input.Name)
|
||||
createdPath, _, err := createOnDisk(project.Slug, strings.TrimSpace(input.ParentFolderID), input.Name)
|
||||
if err != nil {
|
||||
return CreateProjectFolderResult{}, err
|
||||
}
|
||||
@@ -890,7 +918,7 @@ func (service *Service) CreateProjectFolder(ctx context.Context, input CreatePro
|
||||
return CreateProjectFolderResult{}, fmt.Errorf("rebuild POSIX projection: %w", err)
|
||||
}
|
||||
|
||||
folders, err := service.GetProjectHierarchyFolders(ctx, project.ID)
|
||||
folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
|
||||
if err != nil {
|
||||
return CreateProjectFolderResult{}, err
|
||||
}
|
||||
@@ -1183,8 +1211,19 @@ func (service *Service) ensureBootstrapPOSIXSkeleton(
|
||||
}
|
||||
|
||||
func (service *Service) createProjectHierarchyFolderOnDisk(projectSlug, parentFolderID, name string) (string, string, error) {
|
||||
rootPath := strings.TrimSpace(service.posixRoot)
|
||||
if rootPath == "" {
|
||||
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) 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")
|
||||
}
|
||||
|
||||
@@ -1193,14 +1232,12 @@ func (service *Service) createProjectHierarchyFolderOnDisk(projectSlug, parentFo
|
||||
return "", "", fmt.Errorf("folder name is required")
|
||||
}
|
||||
|
||||
projectRoot := filepath.Join(rootPath, "projects", slugDir("project", projectSlug))
|
||||
childrenRoot := filepath.Join(projectRoot, "children")
|
||||
parentDir := childrenRoot
|
||||
containerProjectionPath := projectHierarchyRootPath(projectSlug)
|
||||
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(rootPath, filepath.FromSlash(containerProjectionPath))
|
||||
parentDir = filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath))
|
||||
info, err := os.Stat(parentDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -1324,6 +1361,10 @@ 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"))
|
||||
}
|
||||
|
||||
func normalizePOSIXSlug(value string) string {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(value))
|
||||
if trimmed == "" {
|
||||
|
||||
@@ -161,6 +161,56 @@ func TestCreateProjectHierarchyFolderOnDiskCreatesExpectedFolderShape(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProjectTreeFolderOnDiskCreatesExpectedFolderShape(t *testing.T) {
|
||||
rootPath := filepath.Join(t.TempDir(), "POSIX")
|
||||
service := NewService(nil, rootPath)
|
||||
|
||||
err := service.ensureBootstrapPOSIXSkeleton(
|
||||
InstallationRecord{ID: "installation-1", Name: "MangoPig", Mode: "personal", Access: "local", Protocol: "http", Host: "localhost", IsBootstrapped: true},
|
||||
AdminSummary{ID: "admin-1", Email: "ronald@example.com", DisplayName: "Ronald"},
|
||||
namedRecord{ID: "org-1", Name: "Primary Organization", Slug: "primary-organization"},
|
||||
namedRecord{ID: "dept-1", Name: "Primary Department", Slug: "primary-department"},
|
||||
namedRecord{ID: "team-1", Name: "Primary Team", Slug: "primary-team"},
|
||||
namedRecord{ID: "project-1", Name: "Primary Project", Slug: "primary-project"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ensure bootstrap POSIX skeleton: %v", err)
|
||||
}
|
||||
|
||||
createdPath, createdSlug, err := service.createProjectTreeFolderOnDisk("primary-project", "", "Docs")
|
||||
if err != nil {
|
||||
t.Fatalf("createProjectTreeFolderOnDisk root folder: %v", err)
|
||||
}
|
||||
if createdPath != "projects/project-primary-project/tree/folder-docs" {
|
||||
t.Fatalf("unexpected created path: %s", createdPath)
|
||||
}
|
||||
if createdSlug != "docs" {
|
||||
t.Fatalf("unexpected created slug: %s", createdSlug)
|
||||
}
|
||||
|
||||
createdFolderPath := filepath.Join(rootPath, "projects", "project-primary-project", "tree", "folder-docs")
|
||||
for _, path := range []string{
|
||||
filepath.Join(createdFolderPath, "folder.json"),
|
||||
filepath.Join(createdFolderPath, "acl.json"),
|
||||
filepath.Join(createdFolderPath, "children"),
|
||||
} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected path to exist %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
nestedPath, nestedSlug, err := service.createProjectTreeFolderOnDisk("primary-project", createdPath, "Research")
|
||||
if err != nil {
|
||||
t.Fatalf("createProjectTreeFolderOnDisk nested folder: %v", err)
|
||||
}
|
||||
if nestedPath != "projects/project-primary-project/tree/folder-docs/children/folder-research" {
|
||||
t.Fatalf("unexpected nested path: %s", nestedPath)
|
||||
}
|
||||
if nestedSlug != "research" {
|
||||
t.Fatalf("unexpected nested slug: %s", nestedSlug)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProjectHierarchyFolderTreeBuildsNestedStructure(t *testing.T) {
|
||||
rows := []projectHierarchyFolderRow{
|
||||
{Path: "projects/project-primary-project/children/folder-design", ParentPath: projectHierarchyRootPath("primary-project"), Label: "Design"},
|
||||
|
||||
@@ -79,6 +79,68 @@ func (routes apiRoutes) handleCreateProjectFolder(w http.ResponseWriter, r *http
|
||||
})
|
||||
}
|
||||
|
||||
func (routes apiRoutes) handleProjectTreeFolders(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
|
||||
if projectID == "" {
|
||||
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
|
||||
return
|
||||
}
|
||||
|
||||
folders, err := routes.bootstrapService().GetProjectTreeFolders(r.Context(), projectID)
|
||||
if err != nil {
|
||||
routes.writeProjectFolderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{
|
||||
"projectId": projectID,
|
||||
"folders": folders,
|
||||
},
|
||||
"meta": map[string]any{
|
||||
"resource": "project-tree-folders",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (routes apiRoutes) handleCreateProjectTreeFolder(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
|
||||
if projectID == "" {
|
||||
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
|
||||
return
|
||||
}
|
||||
|
||||
payload, ok := decodeProjectFolderRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
payload.Name = strings.TrimSpace(payload.Name)
|
||||
payload.ParentFolderID = strings.TrimSpace(payload.ParentFolderID)
|
||||
if payload.Name == "" {
|
||||
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := routes.bootstrapService().CreateProjectTreeFolder(r.Context(), bootstrapservice.CreateProjectFolderInput{
|
||||
ProjectID: projectID,
|
||||
ParentFolderID: payload.ParentFolderID,
|
||||
Name: payload.Name,
|
||||
})
|
||||
if err != nil {
|
||||
routes.writeProjectFolderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
WriteJSON(w, http.StatusCreated, map[string]any{
|
||||
"data": result,
|
||||
"meta": map[string]any{
|
||||
"resource": "project-tree-folder-create",
|
||||
"persisted": true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (routes apiRoutes) writeProjectFolderError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, bootstrapservice.ErrProjectNotFound), errors.Is(err, bootstrapservice.ErrProjectFolderNotFound):
|
||||
|
||||
@@ -36,6 +36,8 @@ func (routes apiRoutes) Register(router chi.Router) {
|
||||
apiRouter.Route("/projects/{projectId}", func(projectRouter chi.Router) {
|
||||
projectRouter.Get("/folders", routes.handleProjectFolders)
|
||||
projectRouter.Post("/folders", routes.handleCreateProjectFolder)
|
||||
projectRouter.Get("/tree/folders", routes.handleProjectTreeFolders)
|
||||
projectRouter.Post("/tree/folders", routes.handleCreateProjectTreeFolder)
|
||||
})
|
||||
|
||||
if routes.cfg.Config.IsDevelopment() {
|
||||
|
||||
@@ -411,7 +411,7 @@ func classifyPath(relPath string, isDir bool) (logicalType, fileRole string) {
|
||||
return "hierarchy_folder", ""
|
||||
}
|
||||
if hasTreeAncestor && strings.HasPrefix(name, "folder-") {
|
||||
return "folder", ""
|
||||
return "hierarchy_folder", ""
|
||||
}
|
||||
if hasTreeAncestor && strings.HasPrefix(name, "item-") {
|
||||
return "item", ""
|
||||
@@ -425,7 +425,7 @@ func classifyPath(relPath string, isDir bool) (logicalType, fileRole string) {
|
||||
return "item", fileRole
|
||||
}
|
||||
if strings.HasPrefix(parentName, "folder-") {
|
||||
return "folder", fileRole
|
||||
return "hierarchy_folder", fileRole
|
||||
}
|
||||
}
|
||||
return "project", fileRole
|
||||
|
||||
@@ -16,6 +16,7 @@ func TestScanRootBuildsProjectedNodesFromBootstrapShape(t *testing.T) {
|
||||
mustMkdirAll(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "children", "project-web", "children"))
|
||||
mustMkdirAll(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "children", "project-web", "tree"))
|
||||
mustMkdirAll(t, filepath.Join(root, "projects", "project-primary-project", "tree"))
|
||||
mustMkdirAll(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", "children"))
|
||||
mustMkdirAll(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", "item-roadmap"))
|
||||
mustMkdirAll(t, filepath.Join(root, "users", "personals"))
|
||||
|
||||
@@ -185,10 +186,15 @@ func TestScanRootBuildsProjectedNodesFromBootstrapShape(t *testing.T) {
|
||||
}
|
||||
|
||||
treeFolder := index["projects/project-primary-project/tree/folder-docs"]
|
||||
if treeFolder.LogicalType != "folder" || treeFolder.ProjectSlug != "primary-project" {
|
||||
if treeFolder.LogicalType != "hierarchy_folder" || treeFolder.ProjectSlug != "primary-project" {
|
||||
t.Fatalf("unexpected tree folder node: %#v", treeFolder)
|
||||
}
|
||||
|
||||
treeFolderACL := index["projects/project-primary-project/tree/folder-docs/folder.json"]
|
||||
if treeFolderACL.LogicalType != "hierarchy_folder" || treeFolderACL.FileRole != "folder" {
|
||||
t.Fatalf("unexpected tree folder file classification: %#v", treeFolderACL)
|
||||
}
|
||||
|
||||
treeItem := index["projects/project-primary-project/tree/folder-docs/item-roadmap/item.json"]
|
||||
if treeItem.LogicalType != "item" || treeItem.FileRole != "item" {
|
||||
t.Fatalf("unexpected tree item classification: %#v", treeItem)
|
||||
|
||||
Reference in New Issue
Block a user