package httpx import ( "encoding/json" "errors" "io" "net/http" "strings" bootstrapservice "moku-backend/internal/bootstrap" "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 == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.") return } folders, err := routes.bootstrapService().GetProjectHierarchyFolders(r.Context(), projectID) if err != nil { routes.writeProjectFolderError(w, r, err, "load") return } WriteJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "projectId": projectID, "folders": folders, }, "meta": map[string]any{ "resource": "project-folders", }, }) } func (routes apiRoutes) handleCreateProjectFolder(w http.ResponseWriter, r *http.Request) { projectID := strings.TrimSpace(chi.URLParam(r, "projectId")) if projectID == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.") return } payload, ok := decodeProjectFolderRequest(w, r) if !ok { return } payload.Name = strings.TrimSpace(payload.Name) payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath) if payload.Name == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.") return } result, err := routes.bootstrapService().CreateProjectFolder(r.Context(), bootstrapservice.CreateProjectFolderInput{ ProjectID: projectID, ParentFolderPath: payload.ParentFolderPath, Name: payload.Name, }) if err != nil { routes.writeProjectFolderError(w, r, err, "persist") return } WriteJSON(w, http.StatusCreated, map[string]any{ "data": result, "meta": map[string]any{ "resource": "project-folder-create", "persisted": true, }, }) } func (routes apiRoutes) handleDeleteProjectFolder(w http.ResponseWriter, r *http.Request) { projectID := strings.TrimSpace(chi.URLParam(r, "projectId")) if projectID == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.") return } payload := decodeDeleteProjectFolderRequest(r) if strings.TrimSpace(payload.FolderPath) == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") return } result, err := routes.bootstrapService().DeleteProjectFolder(r.Context(), bootstrapservice.DeleteProjectFolderInput{ ProjectID: projectID, FolderPath: payload.FolderPath, }) if err != nil { routes.writeProjectFolderError(w, r, err, "delete") return } WriteJSON(w, http.StatusOK, map[string]any{ "data": result, "meta": map[string]any{ "resource": "project-folder-delete", "persisted": true, }, }) } func (routes apiRoutes) handleRenameProjectFolder(w http.ResponseWriter, r *http.Request) { projectID := strings.TrimSpace(chi.URLParam(r, "projectId")) if projectID == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.") return } payload, ok := decodeRenameProjectFolderRequest(w, r) if !ok { return } payload.FolderPath = strings.TrimSpace(payload.FolderPath) payload.Name = strings.TrimSpace(payload.Name) if payload.FolderPath == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") return } if payload.Name == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.") return } result, err := routes.bootstrapService().RenameProjectFolder(r.Context(), bootstrapservice.RenameProjectFolderInput{ ProjectID: projectID, FolderPath: payload.FolderPath, Name: payload.Name, }) if err != nil { routes.writeProjectFolderError(w, r, err, "rename") return } WriteJSON(w, http.StatusOK, map[string]any{ "data": result, "meta": map[string]any{ "resource": "project-folder-rename", "persisted": true, }, }) } func (routes apiRoutes) handleMoveProjectFolder(w http.ResponseWriter, r *http.Request) { projectID := strings.TrimSpace(chi.URLParam(r, "projectId")) if projectID == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.") return } payload, ok := decodeMoveProjectFolderRequest(w, r) if !ok { return } payload.FolderPath = strings.TrimSpace(payload.FolderPath) payload.FolderStableID = strings.TrimSpace(payload.FolderStableID) payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath) payload.ParentStableID = strings.TrimSpace(payload.ParentStableID) if payload.FolderPath == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") return } result, err := routes.bootstrapService().MoveProjectFolder(r.Context(), bootstrapservice.MoveProjectFolderInput{ ProjectID: projectID, FolderPath: payload.FolderPath, FolderStableID: payload.FolderStableID, ParentFolderPath: payload.ParentFolderPath, ParentStableID: payload.ParentStableID, TargetIndex: payload.TargetIndex, }) if err != nil { routes.writeProjectFolderError(w, r, err, "move") return } WriteJSON(w, http.StatusOK, map[string]any{ "data": result, "meta": map[string]any{ "resource": "project-folder-move", "persisted": true, }, }) } func (routes apiRoutes) handleProjectTreeFolders(w http.ResponseWriter, r *http.Request) { projectID := strings.TrimSpace(chi.URLParam(r, "projectId")) if projectID == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.") return } folders, err := routes.bootstrapService().GetProjectTreeFolders(r.Context(), projectID) if err != nil { routes.writeProjectFolderError(w, r, err, "load") return } WriteJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "projectId": projectID, "folders": folders, }, "meta": map[string]any{ "resource": "project-tree-folders", }, }) } func (routes apiRoutes) handleProjectTree(w http.ResponseWriter, r *http.Request) { projectID := strings.TrimSpace(chi.URLParam(r, "projectId")) if projectID == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.") return } nodes, err := routes.bootstrapService().GetProjectTreeNodes(r.Context(), projectID) if err != nil { routes.writeProjectTreeError(w, r, err, "load") return } WriteJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "projectId": projectID, "nodes": nodes, }, "meta": map[string]any{ "resource": "project-tree", }, }) } func (routes apiRoutes) handleCreateProjectTreeFolder(w http.ResponseWriter, r *http.Request) { projectID := strings.TrimSpace(chi.URLParam(r, "projectId")) if projectID == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.") return } payload, ok := decodeProjectFolderRequest(w, r) if !ok { return } payload.Name = strings.TrimSpace(payload.Name) payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath) if payload.Name == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.") return } result, err := routes.bootstrapService().CreateProjectTreeFolder(r.Context(), bootstrapservice.CreateProjectFolderInput{ ProjectID: projectID, ParentFolderPath: payload.ParentFolderPath, Name: payload.Name, }) if err != nil { routes.writeProjectFolderError(w, r, err, "persist") return } WriteJSON(w, http.StatusCreated, map[string]any{ "data": result, "meta": map[string]any{ "resource": "project-tree-folder-create", "persisted": true, }, }) } func (routes apiRoutes) handleDeleteProjectTreeFolder(w http.ResponseWriter, r *http.Request) { projectID := strings.TrimSpace(chi.URLParam(r, "projectId")) if projectID == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.") return } payload := decodeDeleteProjectFolderRequest(r) if strings.TrimSpace(payload.FolderPath) == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") return } result, err := routes.bootstrapService().DeleteProjectTreeFolder(r.Context(), bootstrapservice.DeleteProjectFolderInput{ ProjectID: projectID, FolderPath: payload.FolderPath, }) if err != nil { routes.writeProjectFolderError(w, r, err, "delete") return } WriteJSON(w, http.StatusOK, map[string]any{ "data": result, "meta": map[string]any{ "resource": "project-tree-folder-delete", "persisted": true, }, }) } func (routes apiRoutes) handleRenameProjectTreeFolder(w http.ResponseWriter, r *http.Request) { projectID := strings.TrimSpace(chi.URLParam(r, "projectId")) if projectID == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.") return } payload, ok := decodeRenameProjectFolderRequest(w, r) if !ok { return } payload.FolderPath = strings.TrimSpace(payload.FolderPath) payload.Name = strings.TrimSpace(payload.Name) if payload.FolderPath == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") return } if payload.Name == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.") return } result, err := routes.bootstrapService().RenameProjectTreeFolder(r.Context(), bootstrapservice.RenameProjectFolderInput{ ProjectID: projectID, FolderPath: payload.FolderPath, Name: payload.Name, }) if err != nil { routes.writeProjectFolderError(w, r, err, "rename") return } WriteJSON(w, http.StatusOK, map[string]any{ "data": result, "meta": map[string]any{ "resource": "project-tree-folder-rename", "persisted": true, }, }) } func (routes apiRoutes) handleMoveProjectTreeFolder(w http.ResponseWriter, r *http.Request) { projectID := strings.TrimSpace(chi.URLParam(r, "projectId")) if projectID == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.") return } payload, ok := decodeMoveProjectFolderRequest(w, r) if !ok { return } payload.FolderPath = strings.TrimSpace(payload.FolderPath) payload.FolderStableID = strings.TrimSpace(payload.FolderStableID) payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath) payload.ParentStableID = strings.TrimSpace(payload.ParentStableID) if payload.FolderPath == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") return } result, err := routes.bootstrapService().MoveProjectTreeFolder(r.Context(), bootstrapservice.MoveProjectFolderInput{ ProjectID: projectID, FolderPath: payload.FolderPath, FolderStableID: payload.FolderStableID, ParentFolderPath: payload.ParentFolderPath, ParentStableID: payload.ParentStableID, TargetIndex: payload.TargetIndex, }) if err != nil { routes.writeProjectFolderError(w, r, err, "move") return } WriteJSON(w, http.StatusOK, map[string]any{ "data": result, "meta": map[string]any{ "resource": "project-tree-folder-move", "persisted": true, }, }) } func (routes apiRoutes) handleCreateProjectTreeItem(w http.ResponseWriter, r *http.Request) { projectID := strings.TrimSpace(chi.URLParam(r, "projectId")) if projectID == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.") return } payload, ok := decodeProjectItemRequest(w, r) if !ok { return } payload.Name = strings.TrimSpace(payload.Name) payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath) payload.ItemType = strings.TrimSpace(payload.ItemType) if payload.Name == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Item name is required.") return } if payload.ItemType == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Item type is required.") return } result, err := routes.bootstrapService().CreateProjectTreeItem(r.Context(), bootstrapservice.CreateProjectItemInput{ ProjectID: projectID, ParentFolderPath: payload.ParentFolderPath, Name: payload.Name, ItemType: payload.ItemType, }) if err != nil { routes.writeProjectTreeError(w, r, err, "persist") return } WriteJSON(w, http.StatusCreated, map[string]any{ "data": result, "meta": map[string]any{ "resource": "project-tree-item-create", "persisted": true, }, }) } func (routes apiRoutes) handleDeleteProjectTreeItem(w http.ResponseWriter, r *http.Request) { projectID := strings.TrimSpace(chi.URLParam(r, "projectId")) if projectID == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.") return } payload := decodeDeleteProjectItemRequest(r) if strings.TrimSpace(payload.ItemPath) == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Item ID is required.") return } result, err := routes.bootstrapService().DeleteProjectTreeItem(r.Context(), bootstrapservice.DeleteProjectItemInput{ ProjectID: projectID, ItemPath: payload.ItemPath, }) if err != nil { routes.writeProjectTreeError(w, r, err, "delete") return } WriteJSON(w, http.StatusOK, map[string]any{ "data": result, "meta": map[string]any{ "resource": "project-tree-item-delete", "persisted": true, }, }) } func (routes apiRoutes) handleMoveProjectTreeItem(w http.ResponseWriter, r *http.Request) { projectID := strings.TrimSpace(chi.URLParam(r, "projectId")) if projectID == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.") return } payload, ok := decodeMoveProjectItemRequest(w, r) if !ok { return } payload.ItemPath = strings.TrimSpace(payload.ItemPath) payload.ItemStableID = strings.TrimSpace(payload.ItemStableID) payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath) payload.ParentStableID = strings.TrimSpace(payload.ParentStableID) if payload.ItemPath == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Item ID is required.") return } result, err := routes.bootstrapService().MoveProjectTreeItem(r.Context(), bootstrapservice.MoveProjectItemInput{ ProjectID: projectID, ItemPath: payload.ItemPath, ItemStableID: payload.ItemStableID, ParentFolderPath: payload.ParentFolderPath, ParentStableID: payload.ParentStableID, TargetIndex: payload.TargetIndex, }) if err != nil { routes.writeProjectTreeError(w, r, err, "move") return } WriteJSON(w, http.StatusOK, map[string]any{ "data": result, "meta": map[string]any{ "resource": "project-tree-item-move", "persisted": true, }, }) } 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 }