package handlers import "database/sql" import "errors" import "net/http" import "time" import "codit/internal/db" import "codit/internal/middleware" import "codit/internal/models" // boardRoleRank returns a numeric rank for board roles (higher = more access). func boardRoleRank(role string) int { switch role { case models.RoleAdmin: return 3 case models.RoleWriter: return 2 case models.RoleViewer: return 1 default: return 0 } } // boardRoleAllows checks whether actual satisfies the required board role. // Hierarchy: admin > writer > viewer. func boardRoleAllows(actual, required string) bool { return boardRoleRank(actual) >= boardRoleRank(required) } func (api *API) boardRoleForUser(r *http.Request, boardID string, user models.User) (string, error) { var role string var boardRole string var boardErr error var projectID string var projRole string var err error if user.IsAdmin { return models.RoleAdmin, nil } projectID, err = api.store(r).GetBoardProjectID(boardID) if err != nil { return "", err } projRole, err = api.store(r).GetProjectRoleForUser(projectID, user.ID) if err == nil { role = projectRoleToBoardRole(projRole) } else if err != sql.ErrNoRows { return "", err } boardRole, boardErr = api.store(r).GetBoardMemberRole(boardID, user.ID) if boardErr == nil && boardRoleRank(boardRole) > boardRoleRank(role) { role = boardRole } else if boardErr != nil && boardErr != sql.ErrNoRows { return "", boardErr } return role, nil } func projectRoleToBoardRole(projectRole string) string { switch projectRole { case models.RoleAdmin: return models.RoleAdmin case models.RoleWriter: return models.RoleWriter default: return models.RoleViewer } } // normalizeBoardRole validates and returns the canonical role name. // Returns "" for unknown roles. func normalizeBoardRole(role string) string { switch role { case models.RoleAdmin, models.RoleWriter, models.RoleViewer: return role default: return "" } } // requireBoardRole checks that the caller has at least the required board role. // Board membership and project role are both checked; the higher access wins. func (api *API) requireBoardRole(w http.ResponseWriter, r *http.Request, boardID, required string) bool { var user models.User var principal models.ServicePrincipal var ok bool var role string var err error var projectID string var projRole string user, ok = middleware.UserFromContext(r.Context()) if ok { role, err = api.boardRoleForUser(r, boardID, user) if err != nil { w.WriteHeader(http.StatusForbidden) return false } if role == "" { w.WriteHeader(http.StatusForbidden) return false } if !boardRoleAllows(role, required) { w.WriteHeader(http.StatusForbidden) return false } return true } principal, ok = middleware.PrincipalFromContext(r.Context()) if !ok || principal.Disabled { w.WriteHeader(http.StatusUnauthorized) return false } if principal.IsAdmin { return true } projectID, err = api.store(r).GetBoardProjectID(boardID) if err != nil { w.WriteHeader(http.StatusForbidden) return false } projRole, err = api.store(r).GetPrincipalProjectRole(principal.ID, projectID) if err != nil || !boardRoleAllows(projectRoleToBoardRole(projRole), required) { w.WriteHeader(http.StatusForbidden) return false } return true } // ----- boards ----- type createBoardRequest struct { Title string `json:"title"` Description string `json:"description"` Icon string `json:"icon"` ShowDescription bool `json:"show_description"` CardProperties string `json:"card_properties"` IsTemplate bool `json:"is_template"` SeedDefaultFields bool `json:"seed_default_fields"` } type updateBoardRequest struct { Title *string `json:"title"` Description *string `json:"description"` Icon *string `json:"icon"` ShowDescription *bool `json:"show_description"` CardProperties *string `json:"card_properties"` } func (api *API) ListAllBoards(w http.ResponseWriter, r *http.Request, params map[string]string) { var boards []models.Board var err error var user models.User var userOK bool user, userOK = middleware.UserFromContext(r.Context()) if !userOK { w.WriteHeader(http.StatusUnauthorized) return } if user.IsAdmin { boards, err = api.store(r).ListAllBoardsAdmin() } else { boards, err = api.store(r).ListAllBoardsForUser(user.ID) } if err != nil { WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } if boards == nil { boards = []models.Board{} } WriteJSON(w, http.StatusOK, boards) } func (api *API) ListBoards(w http.ResponseWriter, r *http.Request, params map[string]string) { var boards []models.Board var err error var projRoleErr error var user models.User var principal models.ServicePrincipal var userOK bool var principalOK bool user, userOK = middleware.UserFromContext(r.Context()) principal, principalOK = middleware.PrincipalFromContext(r.Context()) if !userOK && (!principalOK || principal.Disabled) { w.WriteHeader(http.StatusUnauthorized) return } if (userOK && user.IsAdmin) || (principalOK && !principal.Disabled && principal.IsAdmin) { boards, err = api.store(r).ListBoards(params["projectId"]) } else if userOK { _, projRoleErr = api.store(r).GetProjectRoleForUser(params["projectId"], user.ID) if projRoleErr == nil { // Project member: see all boards in the project. boards, err = api.store(r).ListBoards(params["projectId"]) } else { // No project role: return only boards with an explicit membership entry. boards, err = api.store(r).ListBoardsWhereExplicitMember(params["projectId"], user.ID) if err == nil && len(boards) == 0 { w.WriteHeader(http.StatusForbidden) return } } } else { // Service principal: project-role access only; no per-board membership. _, projRoleErr = api.store(r).GetPrincipalProjectRole(principal.ID, params["projectId"]) if projRoleErr != nil { w.WriteHeader(http.StatusForbidden) return } boards, err = api.store(r).ListBoards(params["projectId"]) } if err != nil { WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } if boards == nil { boards = []models.Board{} } WriteJSON(w, http.StatusOK, boards) } func (api *API) CreateBoard(w http.ResponseWriter, r *http.Request, params map[string]string) { var req createBoardRequest var err error var user models.User var ok bool var board models.Board var created models.Board // Board mutations are attributed to a user account; service principals cannot create boards. user, ok = middleware.UserFromContext(r.Context()) if !ok { WriteJSONWithErrorReason(w, r, http.StatusForbidden, "board creation requires a user account") return } if !api.requireProjectRole(w, r, params["projectId"], models.RoleWriter) { return } err = DecodeJSON(r, &req) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json") return } if req.Title == "" { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "title is required") return } board = models.Board{ ProjectID: params["projectId"], Title: req.Title, Description: req.Description, Icon: req.Icon, ShowDescription: req.ShowDescription, CardProperties: req.CardProperties, IsTemplate: req.IsTemplate, CreatedBy: user.ID, } created, err = api.store(r).CreateBoard(r.Context(), board) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } if req.SeedDefaultFields { err = api.store(r).SeedDefaultBoardFieldValues(r.Context(), created.ID) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } } WriteJSON(w, http.StatusCreated, created) } func (api *API) GetBoard(w http.ResponseWriter, r *http.Request, params map[string]string) { var board models.Board var err error board, err = api.store(r).GetBoard(params["boardId"]) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusNotFound, "board not found") return } if !api.requireBoardRole(w, r, board.ID, models.RoleViewer) { return } WriteJSON(w, http.StatusOK, board) } func (api *API) UpdateBoard(w http.ResponseWriter, r *http.Request, params map[string]string) { var req updateBoardRequest var err error var board models.Board var updated models.Board var user models.User var ok bool user, ok = middleware.UserFromContext(r.Context()) if !ok { WriteJSONWithErrorReason(w, r, http.StatusForbidden, "board updates require a user account") return } board, err = api.store(r).GetBoard(params["boardId"]) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusNotFound, "board not found") return } if !api.requireBoardRole(w, r, board.ID, models.RoleWriter) { return } err = DecodeJSON(r, &req) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json") return } if req.Title != nil { board.Title = *req.Title } if req.Description != nil { board.Description = *req.Description } if req.Icon != nil { board.Icon = *req.Icon } if req.ShowDescription != nil { board.ShowDescription = *req.ShowDescription } if req.CardProperties != nil { board.CardProperties = *req.CardProperties } board.UpdatedBy = user.ID updated, err = api.store(r).UpdateBoard(board) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } WriteJSON(w, http.StatusOK, updated) } func (api *API) DeleteBoard(w http.ResponseWriter, r *http.Request, params map[string]string) { var err error var board models.Board var user models.User var ok bool user, ok = middleware.UserFromContext(r.Context()) if !ok { WriteJSONWithErrorReason(w, r, http.StatusForbidden, "board deletion requires a user account") return } board, err = api.store(r).GetBoard(params["boardId"]) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusNotFound, "board not found") return } if !api.requireBoardRole(w, r, board.ID, models.RoleAdmin) { return } err = api.store(r).DeleteBoard(board.ID, user.ID) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } w.WriteHeader(http.StatusNoContent) } // ----- blocks ----- type createBlockRequest struct { Type string `json:"type"` ParentID string `json:"parent_id"` Title string `json:"title"` Fields string `json:"fields"` } type updateBlockRequest struct { ParentID *string `json:"parent_id"` Title *string `json:"title"` Fields *string `json:"fields"` Completed *bool `json:"completed"` } func (api *API) ListBlocks(w http.ResponseWriter, r *http.Request, params map[string]string) { var blocks []models.Block var err error var blockType string if !api.requireBoardRole(w, r, params["boardId"], models.RoleViewer) { return } blockType = r.URL.Query().Get("type") if blockType != "" { blocks, err = api.store(r).ListBlocksByType(params["boardId"], blockType) } else { blocks, err = api.store(r).ListBlocks(params["boardId"]) } if err != nil { WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } if blocks == nil { blocks = []models.Block{} } WriteJSON(w, http.StatusOK, blocks) } func (api *API) CreateBlock(w http.ResponseWriter, r *http.Request, params map[string]string) { var req createBlockRequest var err error var user models.User var ok bool var block models.Block var created models.Block user, ok = middleware.UserFromContext(r.Context()) if !ok { WriteJSONWithErrorReason(w, r, http.StatusForbidden, "block creation requires a user account") return } if !api.requireBoardRole(w, r, params["boardId"], models.RoleWriter) { return } err = DecodeJSON(r, &req) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json") return } if req.Type == "" { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "type is required") return } block = models.Block{ BoardID: params["boardId"], ParentID: req.ParentID, Type: req.Type, Title: req.Title, Fields: req.Fields, CreatedBy: user.ID, UpdatedBy: user.ID, } created, err = api.store(r).CreateBlock(r.Context(), block) if err != nil { if errors.Is(err, db.ErrParentNotInBoard) { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error()) return } WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } WriteJSON(w, http.StatusCreated, created) } func (api *API) GetBlock(w http.ResponseWriter, r *http.Request, params map[string]string) { var block models.Block var err error if !api.requireBoardRole(w, r, params["boardId"], models.RoleViewer) { return } block, err = api.store(r).GetBlock(params["boardId"], params["blockId"]) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusNotFound, "block not found") return } WriteJSON(w, http.StatusOK, block) } func (api *API) UpdateBlock(w http.ResponseWriter, r *http.Request, params map[string]string) { var req updateBlockRequest var err error var block models.Block var updated models.Block var user models.User var ok bool user, ok = middleware.UserFromContext(r.Context()) if !ok { WriteJSONWithErrorReason(w, r, http.StatusForbidden, "block updates require a user account") return } if !api.requireBoardRole(w, r, params["boardId"], models.RoleWriter) { return } block, err = api.store(r).GetBlock(params["boardId"], params["blockId"]) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusNotFound, "block not found") return } err = DecodeJSON(r, &req) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json") return } if req.ParentID != nil { block.ParentID = *req.ParentID } if req.Title != nil { block.Title = *req.Title } if req.Fields != nil { block.Fields = *req.Fields } if req.Completed != nil { if *req.Completed { block.CompletedAt = time.Now().UTC().Unix() block.CompletedBy = user.ID } else { block.CompletedAt = 0 block.CompletedBy = "" } } block.UpdatedBy = user.ID updated, err = api.store(r).UpdateBlock(r.Context(), block) if err != nil { if errors.Is(err, db.ErrParentNotInBoard) || errors.Is(err, db.ErrParentCycle) { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error()) return } WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } WriteJSON(w, http.StatusOK, updated) } func (api *API) PatchBlocks(w http.ResponseWriter, r *http.Request, params map[string]string) { var patches []models.BlockPatch var err error var user models.User var ok bool user, ok = middleware.UserFromContext(r.Context()) if !ok { WriteJSONWithErrorReason(w, r, http.StatusForbidden, "block updates require a user account") return } if !api.requireBoardRole(w, r, params["boardId"], models.RoleWriter) { return } err = DecodeJSON(r, &patches) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json") return } err = api.store(r).PatchBlocks(r.Context(), params["boardId"], patches, user.ID) if err != nil { if err == sql.ErrNoRows { WriteJSONWithErrorReason(w, r, http.StatusNotFound, "block not found") return } if errors.Is(err, db.ErrParentNotInBoard) || errors.Is(err, db.ErrParentCycle) { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error()) return } WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } w.WriteHeader(http.StatusNoContent) } func (api *API) DeleteBlock(w http.ResponseWriter, r *http.Request, params map[string]string) { var err error var user models.User var ok bool user, ok = middleware.UserFromContext(r.Context()) if !ok { WriteJSONWithErrorReason(w, r, http.StatusForbidden, "block deletion requires a user account") return } if !api.requireBoardRole(w, r, params["boardId"], models.RoleWriter) { return } err = api.store(r).DeleteBlock(r.Context(), params["boardId"], params["blockId"], user.ID) if err != nil { if err == sql.ErrNoRows { WriteJSONWithErrorReason(w, r, http.StatusNotFound, "block not found") return } WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } w.WriteHeader(http.StatusNoContent) } // ----- board members ----- type upsertBoardMemberRequest struct { UserID string `json:"user_id"` Role string `json:"role"` } func (api *API) ListBoardMembers(w http.ResponseWriter, r *http.Request, params map[string]string) { var members []models.BoardMember var err error if !api.requireBoardRole(w, r, params["boardId"], models.RoleAdmin) { return } members, err = api.store(r).ListBoardMembers(params["boardId"]) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } if members == nil { members = []models.BoardMember{} } WriteJSON(w, http.StatusOK, members) } func (api *API) ListBoardAssignableUsers(w http.ResponseWriter, r *http.Request, params map[string]string) { var users []models.BoardAssignableUser var err error if !api.requireBoardRole(w, r, params["boardId"], models.RoleViewer) { return } users, err = api.store(r).ListBoardAssignableUsers(params["boardId"]) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } if users == nil { users = []models.BoardAssignableUser{} } WriteJSON(w, http.StatusOK, users) } func (api *API) AddBoardMember(w http.ResponseWriter, r *http.Request, params map[string]string) { var req upsertBoardMemberRequest var err error var member models.BoardMember var role string if !api.requireBoardRole(w, r, params["boardId"], models.RoleAdmin) { return } err = DecodeJSON(r, &req) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json") return } if req.UserID == "" { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "user_id is required") return } if req.Role == "" { req.Role = models.RoleWriter } role = normalizeBoardRole(req.Role) if role == "" { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "role must be admin, writer, or viewer") return } member = models.BoardMember{ BoardID: params["boardId"], UserID: req.UserID, Role: role, } err = api.store(r).UpsertBoardMember(r.Context(), member) if err != nil { if errors.Is(err, db.ErrBoardNotFound) || errors.Is(err, db.ErrUserNotFound) { WriteJSONWithErrorReason(w, r, http.StatusNotFound, err.Error()) return } WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } w.WriteHeader(http.StatusNoContent) } func (api *API) UpdateBoardMember(w http.ResponseWriter, r *http.Request, params map[string]string) { var req upsertBoardMemberRequest var err error var member models.BoardMember var role string if !api.requireBoardRole(w, r, params["boardId"], models.RoleAdmin) { return } err = DecodeJSON(r, &req) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json") return } role = normalizeBoardRole(req.Role) if role == "" { WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "role must be admin, writer, or viewer") return } member = models.BoardMember{ BoardID: params["boardId"], UserID: params["userId"], Role: role, } err = api.store(r).UpsertBoardMember(r.Context(), member) if err != nil { if errors.Is(err, db.ErrBoardNotFound) || errors.Is(err, db.ErrUserNotFound) { WriteJSONWithErrorReason(w, r, http.StatusNotFound, err.Error()) return } WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } w.WriteHeader(http.StatusNoContent) } func (api *API) RemoveBoardMember(w http.ResponseWriter, r *http.Request, params map[string]string) { var err error if !api.requireBoardRole(w, r, params["boardId"], models.RoleAdmin) { return } err = api.store(r).DeleteBoardMember(params["boardId"], params["userId"]) if err != nil { WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) return } w.WriteHeader(http.StatusNoContent) }