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"` ParentFolderID string `json:"parentFolderId"` } type renameProjectFolderRequest struct { FolderID string `json:"folderId"` Name string `json:"name"` } type deleteProjectFolderRequest struct { FolderID string `json:"folderId"` } type moveProjectFolderRequest struct { FolderID string `json:"folderId"` ParentFolderID string `json:"parentFolderId"` } 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.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().CreateProjectFolder(r.Context(), bootstrapservice.CreateProjectFolderInput{ ProjectID: projectID, ParentFolderID: payload.ParentFolderID, 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.FolderID) == "" { 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, FolderID: payload.FolderID, }) 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.FolderID = strings.TrimSpace(payload.FolderID) payload.Name = strings.TrimSpace(payload.Name) if payload.FolderID == "" { 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, FolderID: payload.FolderID, 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.FolderID = strings.TrimSpace(payload.FolderID) payload.ParentFolderID = strings.TrimSpace(payload.ParentFolderID) if payload.FolderID == "" { 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, FolderID: payload.FolderID, ParentFolderID: payload.ParentFolderID, }) 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) 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, "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.FolderID) == "" { 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, FolderID: payload.FolderID, }) 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.FolderID = strings.TrimSpace(payload.FolderID) payload.Name = strings.TrimSpace(payload.Name) if payload.FolderID == "" { 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, FolderID: payload.FolderID, 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.FolderID = strings.TrimSpace(payload.FolderID) payload.ParentFolderID = strings.TrimSpace(payload.ParentFolderID) if payload.FolderID == "" { 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, FolderID: payload.FolderID, ParentFolderID: payload.ParentFolderID, }) 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) 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 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{ FolderID: strings.TrimSpace(r.URL.Query().Get("folderId")), } } 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 }