diff --git a/api/handlers.go b/api/handlers.go index 79333a5..c89a79a 100644 --- a/api/handlers.go +++ b/api/handlers.go @@ -1933,6 +1933,95 @@ func (h *Handlers) ListRequests(w http.ResponseWriter, r *http.Request) { // ImportRequests handles POST /api/projects/{projectID}/requests/import // Accepts multipart form with CSV/XLSX file, mode (add/replace), section_filter, create_workstreams +// CreateSection creates a new section under a request_list. +func (h *Handlers) CreateSection(w http.ResponseWriter, r *http.Request) { + actorID := UserIDFromContext(r.Context()) + projectID := chi.URLParam(r, "projectID") + if err := lib.CheckAccessWrite(h.DB, actorID, projectID, ""); err != nil { + ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied"); return + } + var body struct { + Name string `json:"name"` + ParentID string `json:"parent_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" || body.ParentID == "" { + ErrorResponse(w, http.StatusBadRequest, "invalid_body", "name and parent_id required"); return + } + projectKey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID) + if err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "key error"); return } + + var maxSort int + h.DB.Conn.QueryRow( + `SELECT COALESCE(MAX(sort_order),0) FROM entries WHERE parent_id=? AND deleted_at IS NULL`, body.ParentID, + ).Scan(&maxSort) + + now := time.Now().UTC().Format(time.RFC3339) + secID := uuid.New().String() + name := strings.TrimSpace(body.Name) + secData := lib.SectionData{Name: name} + secDataJSON, _ := json.Marshal(secData) + sumPacked, _ := lib.Pack(projectKey, name) + dataPacked, _ := lib.Pack(projectKey, string(secDataJSON)) + + _, err = h.DB.Conn.Exec( + `INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order, + search_key, search_key2, summary, data, stage, + assignee_id, return_to_id, origin_id, version, deleted_at, deleted_by, key_version, created_at, updated_at, created_by) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + secID, projectID, body.ParentID, lib.TypeSection, 2, maxSort+1000, + nil, nil, sumPacked, dataPacked, lib.StagePreDataroom, + "", "", "", 1, nil, nil, 1, now, now, actorID, + ) + if err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create section"); return } + JSONResponse(w, http.StatusOK, map[string]any{"entry_id": secID, "name": name}) +} + +// CreateRequest creates a new blank request under a section or request_list. +func (h *Handlers) CreateRequest(w http.ResponseWriter, r *http.Request) { + actorID := UserIDFromContext(r.Context()) + projectID := chi.URLParam(r, "projectID") + if err := lib.CheckAccessWrite(h.DB, actorID, projectID, ""); err != nil { + ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied"); return + } + var body struct { + Title string `json:"title"` + ParentID string `json:"parent_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Title) == "" || body.ParentID == "" { + ErrorResponse(w, http.StatusBadRequest, "invalid_body", "title and parent_id required"); return + } + projectKey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID) + if err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "key error"); return } + + // Determine depth from parent + var parentDepth int + h.DB.Conn.QueryRow(`SELECT depth FROM entries WHERE entry_id=?`, body.ParentID).Scan(&parentDepth) + var maxSort int + h.DB.Conn.QueryRow( + `SELECT COALESCE(MAX(sort_order),0) FROM entries WHERE parent_id=? AND deleted_at IS NULL`, body.ParentID, + ).Scan(&maxSort) + + now := time.Now().UTC().Format(time.RFC3339) + reqID := uuid.New().String() + title := strings.TrimSpace(body.Title) + reqData := lib.RequestData{Title: title, Status: "open", Priority: "medium"} + reqDataJSON, _ := json.Marshal(reqData) + sumPacked, _ := lib.Pack(projectKey, title) + dataPacked, _ := lib.Pack(projectKey, string(reqDataJSON)) + + _, err = h.DB.Conn.Exec( + `INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order, + search_key, search_key2, summary, data, stage, + assignee_id, return_to_id, origin_id, version, deleted_at, deleted_by, key_version, created_at, updated_at, created_by) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + reqID, projectID, body.ParentID, lib.TypeRequest, parentDepth+1, maxSort+100, + nil, nil, sumPacked, dataPacked, lib.StagePreDataroom, + "", "", "", 1, nil, nil, 1, now, now, actorID, + ) + if err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create request"); return } + JSONResponse(w, http.StatusOK, map[string]any{"entry_id": reqID, "title": title}) +} + // CreateRequestList creates a new empty request list for a project. func (h *Handlers) CreateRequestList(w http.ResponseWriter, r *http.Request) { actorID := UserIDFromContext(r.Context()) @@ -1961,6 +2050,13 @@ func (h *Handlers) CreateRequestList(w http.ResponseWriter, r *http.Request) { listName := strings.TrimSpace(body.Name) requestListID := uuid.New().String() + // Compute sort_order: place after last existing list + var maxSort int + h.DB.Conn.QueryRow( + `SELECT COALESCE(MAX(sort_order),0) FROM entries WHERE project_id=? AND type='request_list' AND deleted_at IS NULL`, projectID, + ).Scan(&maxSort) + sortOrder := maxSort + 1000 + rlData := lib.RequestListData{Name: listName} rlDataJSON, _ := json.Marshal(rlData) rlSummaryPacked, _ := lib.Pack(projectKey, listName) @@ -1973,7 +2069,7 @@ func (h *Handlers) CreateRequestList(w http.ResponseWriter, r *http.Request) { version, deleted_at, deleted_by, key_version, created_at, updated_at, created_by) VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`, - requestListID, projectID, projectID, lib.TypeRequestList, 1, 0, + requestListID, projectID, projectID, lib.TypeRequestList, 1, sortOrder, nil, nil, rlSummaryPacked, rlDataPacked, lib.StagePreDataroom, "", "", "", 1, nil, nil, 1, diff --git a/api/routes.go b/api/routes.go index 2a63df8..dd6079c 100644 --- a/api/routes.go +++ b/api/routes.go @@ -71,6 +71,8 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs. r.Get("/projects/{projectID}/requests", h.ListRequests) r.Get("/projects/{projectID}/requests/tree", h.ListRequestTree) r.Post("/projects/{projectID}/requests", h.CreateRequestList) + r.Post("/projects/{projectID}/requests/new", h.CreateRequest) + r.Post("/projects/{projectID}/sections", h.CreateSection) r.Post("/projects/{projectID}/requests/import", h.ImportRequests) // Request detail diff --git a/data/dealspace.db-shm b/data/dealspace.db-shm index 80ebd3f..4394049 100644 Binary files a/data/dealspace.db-shm and b/data/dealspace.db-shm differ diff --git a/dealspace b/dealspace index 8bb2e9e..2fc8fd5 100755 Binary files a/dealspace and b/dealspace differ diff --git a/portal/templates/app/project.html b/portal/templates/app/project.html index b3db51b..56581fe 100644 --- a/portal/templates/app/project.html +++ b/portal/templates/app/project.html @@ -24,7 +24,7 @@