146 lines
4.3 KiB
Go
146 lines
4.3 KiB
Go
package httpapi
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"net/http"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"github.com/go-chi/chi/v5"
|
||
|
|
|
||
|
|
"github.com/kristianwind/verdande/internal/store"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Groups are the foldable headings over the projects in the sidebar.
|
||
|
|
//
|
||
|
|
// They sit outside /projects rather than under it: a group is not a project and
|
||
|
|
// has no members, no tasks and no roles. Every route here is scoped to the caller
|
||
|
|
// in the query itself, so there is no permission middleware to forget — a group
|
||
|
|
// that is not yours is not found.
|
||
|
|
|
||
|
|
type projectGroupJSON struct {
|
||
|
|
ID string `json:"id"`
|
||
|
|
Name string `json:"name"`
|
||
|
|
Collapsed bool `json:"collapsed"`
|
||
|
|
SortOrder float64 `json:"sort_order"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func toProjectGroupJSON(g store.ProjectGroup) projectGroupJSON {
|
||
|
|
return projectGroupJSON{ID: g.ID, Name: g.Name, Collapsed: g.Collapsed, SortOrder: g.SortOrder}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleListProjectGroups(w http.ResponseWriter, r *http.Request) {
|
||
|
|
groups, err := s.db.ListProjectGroups(r.Context(), userFrom(r.Context()).ID)
|
||
|
|
if err != nil {
|
||
|
|
s.internal(w, "list project groups", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
out := make([]projectGroupJSON, 0, len(groups))
|
||
|
|
for _, g := range groups {
|
||
|
|
out = append(out, toProjectGroupJSON(g))
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, map[string]any{"groups": out})
|
||
|
|
}
|
||
|
|
|
||
|
|
type projectGroupRequest struct {
|
||
|
|
Name *string `json:"name"`
|
||
|
|
Collapsed *bool `json:"collapsed"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleCreateProjectGroup(w http.ResponseWriter, r *http.Request) {
|
||
|
|
var req projectGroupRequest
|
||
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
name := ""
|
||
|
|
if req.Name != nil {
|
||
|
|
name = strings.TrimSpace(*req.Name)
|
||
|
|
}
|
||
|
|
if fields := validateGroupName(name); len(fields) > 0 {
|
||
|
|
writeFieldErrors(w, fields)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
g := &store.ProjectGroup{Name: name, OwnerID: userFrom(r.Context()).ID}
|
||
|
|
if req.Collapsed != nil {
|
||
|
|
g.Collapsed = *req.Collapsed
|
||
|
|
}
|
||
|
|
if err := s.db.CreateProjectGroup(r.Context(), g); err != nil {
|
||
|
|
s.internal(w, "create project group", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
s.publishToOwner(r, "project_group.created", toProjectGroupJSON(*g))
|
||
|
|
writeJSON(w, http.StatusCreated, toProjectGroupJSON(*g))
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleUpdateProjectGroup(w http.ResponseWriter, r *http.Request) {
|
||
|
|
var req projectGroupRequest
|
||
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
groupID := chi.URLParam(r, "groupID")
|
||
|
|
userID := userFrom(r.Context()).ID
|
||
|
|
|
||
|
|
if req.Name != nil {
|
||
|
|
name := strings.TrimSpace(*req.Name)
|
||
|
|
if fields := validateGroupName(name); len(fields) > 0 {
|
||
|
|
writeFieldErrors(w, fields)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
req.Name = &name
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := s.db.UpdateProjectGroup(r.Context(), groupID, userID, store.ProjectGroupUpdate{
|
||
|
|
Name: req.Name, Collapsed: req.Collapsed,
|
||
|
|
}); err != nil {
|
||
|
|
s.storeError(w, "update project group", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
g, err := s.db.GetProjectGroup(r.Context(), groupID, userID)
|
||
|
|
if err != nil {
|
||
|
|
s.storeError(w, "get project group", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
s.publishToOwner(r, "project_group.updated", toProjectGroupJSON(*g))
|
||
|
|
writeJSON(w, http.StatusOK, toProjectGroupJSON(*g))
|
||
|
|
}
|
||
|
|
|
||
|
|
// handleDeleteProjectGroup removes the heading. The projects under it come back
|
||
|
|
// out as ungrouped, which is why this is not behind a confirmation about losing
|
||
|
|
// work: there is none to lose.
|
||
|
|
func (s *Server) handleDeleteProjectGroup(w http.ResponseWriter, r *http.Request) {
|
||
|
|
groupID := chi.URLParam(r, "groupID")
|
||
|
|
if err := s.db.DeleteProjectGroup(r.Context(), groupID, userFrom(r.Context()).ID); err != nil {
|
||
|
|
s.storeError(w, "delete project group", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
s.publishToOwner(r, "project_group.deleted", map[string]string{"id": groupID})
|
||
|
|
w.WriteHeader(http.StatusNoContent)
|
||
|
|
}
|
||
|
|
|
||
|
|
// handleReorderProjectGroups takes the whole list, like the projects do — see
|
||
|
|
// handleReorderProjects for why.
|
||
|
|
func (s *Server) handleReorderProjectGroups(w http.ResponseWriter, r *http.Request) {
|
||
|
|
var req reorderProjectsRequest
|
||
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if len(req.IDs) > 500 {
|
||
|
|
writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if err := s.db.ReorderProjectGroups(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
|
||
|
|
s.internal(w, "reorder project groups", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
w.WriteHeader(http.StatusNoContent)
|
||
|
|
}
|
||
|
|
|
||
|
|
func validateGroupName(name string) map[string]string {
|
||
|
|
switch {
|
||
|
|
case name == "":
|
||
|
|
return map[string]string{"name": "required"}
|
||
|
|
case len([]rune(name)) > 200:
|
||
|
|
return map[string]string{"name": "must be 200 characters or fewer"}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|