From cf1449b1f9922e0a35616e93b25a81171f8391d8 Mon Sep 17 00:00:00 2001 From: Mazyar Date: Sat, 30 May 2026 12:13:46 +0330 Subject: [PATCH] fixed blocking issues --- cmd/web/router/routes.go | 4 +- internal/kanban/errors.go | 11 ++ internal/kanban/handler.go | 147 +++++++++++----- internal/kanban/handler_errors.go | 40 +++++ internal/kanban/repository.go | 10 +- internal/kanban/service.go | 268 ++++++++++++++++++++---------- 6 files changed, 349 insertions(+), 131 deletions(-) create mode 100644 internal/kanban/errors.go create mode 100644 internal/kanban/handler_errors.go diff --git a/cmd/web/router/routes.go b/cmd/web/router/routes.go index 91f5fd6..a7b3321 100644 --- a/cmd/web/router/routes.go +++ b/cmd/web/router/routes.go @@ -190,7 +190,7 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler cmsGP.DELETE("/:id", cmsHandler.Delete) } - // Kanban Routes + // Kanban Routes — paths are /admin/v1/kanban/* (RegisterRoutes expects this /kanban group) kanbanGP := adminV1.Group("/kanban") { kanbanGP.Use(userHandler.AuthMiddleware()) @@ -318,7 +318,7 @@ func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tende cmsGP.GET("/:key", cmsHandler.GetByKey) } - // Kanban (mobile bid workflow board) + // Kanban (mobile bid workflow board) — paths are /api/v1/kanban/* kanbanGP := v1.Group("/kanban") { kanbanGP.Use(customerHandler.AuthMiddleware()) diff --git a/internal/kanban/errors.go b/internal/kanban/errors.go new file mode 100644 index 0000000..6596bbe --- /dev/null +++ b/internal/kanban/errors.go @@ -0,0 +1,11 @@ +package kanban + +import "errors" + +var ( + // ErrAccessDenied is returned when the resource exists but does not belong to the caller. + ErrAccessDenied = errors.New("kanban: access denied") + + // ErrWorkflowUnavailable is returned when GoRules is configured but cannot supply workflow data. + ErrWorkflowUnavailable = errors.New("kanban: workflow rules unavailable") +) diff --git a/internal/kanban/handler.go b/internal/kanban/handler.go index b3d9168..deaa54d 100644 --- a/internal/kanban/handler.go +++ b/internal/kanban/handler.go @@ -20,7 +20,9 @@ func NewHandler(service Service) *Handler { } } -// RegisterRoutes registers kanban routes on the provided group (e.g. /admin/v1/kanban). +// RegisterRoutes registers kanban routes on the Echo group passed by the caller. +// The caller MUST mount a /kanban subgroup first (e.g. adminV1.Group("/kanban") or v1.Group("/kanban")) +// so paths resolve to .../kanban/boards, not .../boards. func (h *Handler) RegisterRoutes(router *echo.Group) { router.POST("/boards", h.CreateBoard) router.GET("/boards", h.GetBoardsByUser) @@ -66,8 +68,8 @@ func (h *Handler) CreateBoard(c echo.Context) error { req.UserID = ownerID board, err := h.service.CreateBoard(c.Request().Context(), req) - if err != nil { - return response.InternalServerError(c, "Failed to create board") + if err := handleServiceError(c, err, "Failed to create board"); err != nil { + return err } return response.Created(c, board, "Board created successfully") @@ -87,9 +89,14 @@ func (h *Handler) CreateBoard(c echo.Context) error { func (h *Handler) GetBoardByID(c echo.Context) error { id := c.Param("id") - board, err := h.service.GetBoardByID(c.Request().Context(), id) - if err != nil { - return response.InternalServerError(c, "Failed to get board") + ownerID, exists := ownerIDFromContext(c) + if !exists { + return response.Unauthorized(c, "User not authenticated") + } + + board, err := h.service.GetBoardByID(c.Request().Context(), id, ownerID) + if err := handleServiceError(c, err, "Failed to get board"); err != nil { + return err } return response.Success(c, board, "Board retrieved successfully") @@ -111,8 +118,8 @@ func (h *Handler) GetBoardsByUser(c echo.Context) error { } boards, err := h.service.GetBoardsByUserID(c.Request().Context(), ownerID) - if err != nil { - return response.InternalServerError(c, "Failed to get boards") + if err := handleServiceError(c, err, "Failed to get boards"); err != nil { + return err } return response.Success(c, boards, "Boards retrieved successfully") @@ -135,8 +142,8 @@ func (h *Handler) GetDefaultBoard(c echo.Context) error { } board, err := h.service.GetDefaultBoard(c.Request().Context(), ownerID) - if err != nil { - return response.InternalServerError(c, "Failed to get default board") + if err := handleServiceError(c, err, "Failed to get default board"); err != nil { + return err } return response.Success(c, board, "Default board retrieved successfully") @@ -163,9 +170,14 @@ func (h *Handler) UpdateBoard(c echo.Context) error { req.ID = c.Param("id") - board, err := h.service.UpdateBoard(c.Request().Context(), req) - if err != nil { - return response.InternalServerError(c, "Failed to update board") + ownerID, exists := ownerIDFromContext(c) + if !exists { + return response.Unauthorized(c, "User not authenticated") + } + + board, err := h.service.UpdateBoard(c.Request().Context(), req, ownerID) + if err := handleServiceError(c, err, "Failed to update board"); err != nil { + return err } return response.Success(c, board, "Board updated successfully") @@ -185,8 +197,13 @@ func (h *Handler) UpdateBoard(c echo.Context) error { func (h *Handler) DeleteBoard(c echo.Context) error { id := c.Param("id") - if err := h.service.DeleteBoard(c.Request().Context(), id); err != nil { - return response.InternalServerError(c, "Failed to delete board") + ownerID, exists := ownerIDFromContext(c) + if !exists { + return response.Unauthorized(c, "User not authenticated") + } + + if err := h.service.DeleteBoard(c.Request().Context(), id, ownerID); err != nil { + return handleServiceError(c, err, "Failed to delete board") } return c.NoContent(http.StatusNoContent) @@ -209,9 +226,14 @@ func (h *Handler) CreateColumn(c echo.Context) error { return response.ValidationError(c, "Invalid request data", err.Error()) } - column, err := h.service.CreateColumn(c.Request().Context(), req) - if err != nil { - return response.InternalServerError(c, "Failed to create column") + ownerID, exists := ownerIDFromContext(c) + if !exists { + return response.Unauthorized(c, "User not authenticated") + } + + column, err := h.service.CreateColumn(c.Request().Context(), req, ownerID) + if err := handleServiceError(c, err, "Failed to create column"); err != nil { + return err } return response.Created(c, column, "Column created successfully") @@ -238,9 +260,14 @@ func (h *Handler) UpdateColumn(c echo.Context) error { req.ID = c.Param("id") - column, err := h.service.UpdateColumn(c.Request().Context(), req) - if err != nil { - return response.InternalServerError(c, "Failed to update column") + ownerID, exists := ownerIDFromContext(c) + if !exists { + return response.Unauthorized(c, "User not authenticated") + } + + column, err := h.service.UpdateColumn(c.Request().Context(), req, ownerID) + if err := handleServiceError(c, err, "Failed to update column"); err != nil { + return err } return response.Success(c, column, "Column updated successfully") @@ -260,8 +287,13 @@ func (h *Handler) UpdateColumn(c echo.Context) error { func (h *Handler) DeleteColumn(c echo.Context) error { id := c.Param("id") - if err := h.service.DeleteColumn(c.Request().Context(), id); err != nil { - return response.InternalServerError(c, "Failed to delete column") + ownerID, exists := ownerIDFromContext(c) + if !exists { + return response.Unauthorized(c, "User not authenticated") + } + + if err := h.service.DeleteColumn(c.Request().Context(), id, ownerID); err != nil { + return handleServiceError(c, err, "Failed to delete column") } return c.NoContent(http.StatusNoContent) @@ -284,8 +316,13 @@ func (h *Handler) ReorderColumns(c echo.Context) error { return response.ValidationError(c, "Invalid request data", err.Error()) } - if err := h.service.ReorderColumns(c.Request().Context(), req); err != nil { - return response.InternalServerError(c, "Failed to reorder columns") + ownerID, exists := ownerIDFromContext(c) + if !exists { + return response.Unauthorized(c, "User not authenticated") + } + + if err := h.service.ReorderColumns(c.Request().Context(), req, ownerID); err != nil { + return handleServiceError(c, err, "Failed to reorder columns") } return response.Success(c, map[string]string{"message": "Columns reordered successfully"}, "Columns reordered successfully") @@ -327,9 +364,14 @@ func (h *Handler) CreateCard(c echo.Context) error { } } - card, err := h.service.CreateCard(c.Request().Context(), req) - if err != nil { - return response.InternalServerError(c, "Failed to create card") + ownerID, exists := ownerIDFromContext(c) + if !exists { + return response.Unauthorized(c, "User not authenticated") + } + + card, err := h.service.CreateCard(c.Request().Context(), req, ownerID) + if err := handleServiceError(c, err, "Failed to create card"); err != nil { + return err } return response.Created(c, card, "Card created successfully") @@ -349,9 +391,14 @@ func (h *Handler) CreateCard(c echo.Context) error { func (h *Handler) GetCardByID(c echo.Context) error { id := c.Param("id") - card, err := h.service.GetCardByID(c.Request().Context(), id) - if err != nil { - return response.InternalServerError(c, "Failed to get card") + ownerID, exists := ownerIDFromContext(c) + if !exists { + return response.Unauthorized(c, "User not authenticated") + } + + card, err := h.service.GetCardByID(c.Request().Context(), id, ownerID) + if err := handleServiceError(c, err, "Failed to get card"); err != nil { + return err } return response.Success(c, card, "Card retrieved successfully") @@ -385,9 +432,14 @@ func (h *Handler) UpdateCard(c echo.Context) error { } } - card, err := h.service.UpdateCard(c.Request().Context(), req) - if err != nil { - return response.InternalServerError(c, "Failed to update card") + ownerID, exists := ownerIDFromContext(c) + if !exists { + return response.Unauthorized(c, "User not authenticated") + } + + card, err := h.service.UpdateCard(c.Request().Context(), req, ownerID) + if err := handleServiceError(c, err, "Failed to update card"); err != nil { + return err } return response.Success(c, card, "Card updated successfully") @@ -407,8 +459,13 @@ func (h *Handler) UpdateCard(c echo.Context) error { func (h *Handler) DeleteCard(c echo.Context) error { id := c.Param("id") - if err := h.service.DeleteCard(c.Request().Context(), id); err != nil { - return response.InternalServerError(c, "Failed to delete card") + ownerID, exists := ownerIDFromContext(c) + if !exists { + return response.Unauthorized(c, "User not authenticated") + } + + if err := h.service.DeleteCard(c.Request().Context(), id, ownerID); err != nil { + return handleServiceError(c, err, "Failed to delete card") } return c.NoContent(http.StatusNoContent) @@ -432,8 +489,13 @@ func (h *Handler) MoveCard(c echo.Context) error { return response.ValidationError(c, "Invalid request data", err.Error()) } - if err := h.service.MoveCard(c.Request().Context(), req); err != nil { - return response.InternalServerError(c, "Failed to move card") + ownerID, exists := ownerIDFromContext(c) + if !exists { + return response.Unauthorized(c, "User not authenticated") + } + + if err := h.service.MoveCard(c.Request().Context(), req, ownerID); err != nil { + return handleServiceError(c, err, "Failed to move card") } return response.Success(c, map[string]string{"message": "Card moved successfully"}, "Card moved successfully") @@ -456,8 +518,13 @@ func (h *Handler) ReorderCards(c echo.Context) error { return response.ValidationError(c, "Invalid request data", err.Error()) } - if err := h.service.ReorderCards(c.Request().Context(), req); err != nil { - return response.InternalServerError(c, "Failed to reorder cards") + ownerID, exists := ownerIDFromContext(c) + if !exists { + return response.Unauthorized(c, "User not authenticated") + } + + if err := h.service.ReorderCards(c.Request().Context(), req, ownerID); err != nil { + return handleServiceError(c, err, "Failed to reorder cards") } return response.Success(c, map[string]string{"message": "Cards reordered successfully"}, "Cards reordered successfully") diff --git a/internal/kanban/handler_errors.go b/internal/kanban/handler_errors.go new file mode 100644 index 0000000..010628b --- /dev/null +++ b/internal/kanban/handler_errors.go @@ -0,0 +1,40 @@ +package kanban + +import ( + "errors" + "strings" + + "tm/pkg/response" + + "github.com/labstack/echo/v4" +) + +func handleServiceError(c echo.Context, err error, fallback string) error { + if err == nil { + return nil + } + + if errors.Is(err, ErrAccessDenied) { + return response.NotFound(c, "Resource not found") + } + + if errors.Is(err, ErrWorkflowUnavailable) { + return response.BadRequest(c, "Workflow rules are temporarily unavailable", err.Error()) + } + + msg := err.Error() + if strings.Contains(msg, "not allowed") || + strings.Contains(msg, "cannot be changed") || + strings.Contains(msg, "cannot be deleted") || + strings.Contains(msg, "cannot be reordered") || + strings.Contains(msg, "fixed") || + strings.Contains(msg, "only column color") { + return response.BadRequest(c, msg, "") + } + + if strings.Contains(msg, "not found") { + return response.NotFound(c, "Resource not found") + } + + return response.InternalServerError(c, fallback) +} diff --git a/internal/kanban/repository.go b/internal/kanban/repository.go index 67b9f3d..afbe11b 100644 --- a/internal/kanban/repository.go +++ b/internal/kanban/repository.go @@ -176,9 +176,9 @@ func (r *boardRepository) CreateBoard(ctx context.Context, board *Board) error { err := r.ormRepo.Create(ctx, board) if err != nil { r.logger.Error("Failed to create board", map[string]interface{}{ - "error": err.Error(), - "name": board.Name, - "user_id": board.UserID, + "error": err.Error(), + "name": board.Name, + "user_id": board.UserID, }) return err } @@ -572,8 +572,8 @@ func (r *cardRepository) UpdateCardOrder(ctx context.Context, columnID string, c card, err := r.ormRepo.FindByID(ctx, cardID) if err != nil { r.logger.Error("Failed to find card for order update", map[string]interface{}{ - "card_id": cardID, - "error": err.Error(), + "card_id": cardID, + "error": err.Error(), }) return err } diff --git a/internal/kanban/service.go b/internal/kanban/service.go index 4ea2ace..a04239c 100644 --- a/internal/kanban/service.go +++ b/internal/kanban/service.go @@ -13,25 +13,25 @@ import ( type Service interface { // Board operations CreateBoard(ctx context.Context, req CreateBoardRequest) (*BoardResponse, error) - GetBoardByID(ctx context.Context, id string) (*BoardDetailResponse, error) + GetBoardByID(ctx context.Context, id, ownerID string) (*BoardDetailResponse, error) GetBoardsByUserID(ctx context.Context, userID string) (*BoardsListResponse, error) GetDefaultBoard(ctx context.Context, userID string) (*BoardDetailResponse, error) - UpdateBoard(ctx context.Context, req UpdateBoardRequest) (*BoardResponse, error) - DeleteBoard(ctx context.Context, id string) error + UpdateBoard(ctx context.Context, req UpdateBoardRequest, ownerID string) (*BoardResponse, error) + DeleteBoard(ctx context.Context, id, ownerID string) error // Column operations - CreateColumn(ctx context.Context, req CreateColumnRequest) (*ColumnResponse, error) - UpdateColumn(ctx context.Context, req UpdateColumnRequest) (*ColumnResponse, error) - DeleteColumn(ctx context.Context, id string) error - ReorderColumns(ctx context.Context, req ReorderColumnsRequest) error + CreateColumn(ctx context.Context, req CreateColumnRequest, ownerID string) (*ColumnResponse, error) + UpdateColumn(ctx context.Context, req UpdateColumnRequest, ownerID string) (*ColumnResponse, error) + DeleteColumn(ctx context.Context, id, ownerID string) error + ReorderColumns(ctx context.Context, req ReorderColumnsRequest, ownerID string) error // Card operations - CreateCard(ctx context.Context, req CreateCardRequest) (*CardResponse, error) - GetCardByID(ctx context.Context, id string) (*CardResponse, error) - UpdateCard(ctx context.Context, req UpdateCardRequest) (*CardResponse, error) - DeleteCard(ctx context.Context, id string) error - MoveCard(ctx context.Context, req MoveCardRequest) error - ReorderCards(ctx context.Context, req ReorderCardsRequest) error + CreateCard(ctx context.Context, req CreateCardRequest, ownerID string) (*CardResponse, error) + GetCardByID(ctx context.Context, id, ownerID string) (*CardResponse, error) + UpdateCard(ctx context.Context, req UpdateCardRequest, ownerID string) (*CardResponse, error) + DeleteCard(ctx context.Context, id, ownerID string) error + MoveCard(ctx context.Context, req MoveCardRequest, ownerID string) error + ReorderCards(ctx context.Context, req ReorderCardsRequest, ownerID string) error } // kanbanService implements KanbanService interface @@ -96,12 +96,13 @@ func (s *kanbanService) CreateBoard(ctx context.Context, req CreateBoardRequest) } // GetBoardByID retrieves a board with full column and card details -func (s *kanbanService) GetBoardByID(ctx context.Context, id string) (*BoardDetailResponse, error) { +func (s *kanbanService) GetBoardByID(ctx context.Context, id, ownerID string) (*BoardDetailResponse, error) { s.logger.Info("Retrieving board by ID", map[string]interface{}{ "board_id": id, + "owner_id": ownerID, }) - board, err := s.boardRepository.GetBoardByID(ctx, id) + board, err := s.getBoardForOwner(ctx, id, ownerID) if err != nil { return nil, err } @@ -180,16 +181,17 @@ func (s *kanbanService) GetDefaultBoard(ctx context.Context, userID string) (*Bo return nil, err } - return s.GetBoardByID(ctx, board.ID.Hex()) + return s.GetBoardByID(ctx, board.ID.Hex(), userID) } // UpdateBoard updates an existing board -func (s *kanbanService) UpdateBoard(ctx context.Context, req UpdateBoardRequest) (*BoardResponse, error) { +func (s *kanbanService) UpdateBoard(ctx context.Context, req UpdateBoardRequest, ownerID string) (*BoardResponse, error) { s.logger.Info("Updating board", map[string]interface{}{ "board_id": req.ID, + "owner_id": ownerID, }) - board, err := s.boardRepository.GetBoardByID(ctx, req.ID) + board, err := s.getBoardForOwner(ctx, req.ID, ownerID) if err != nil { return nil, err } @@ -222,11 +224,16 @@ func (s *kanbanService) UpdateBoard(ctx context.Context, req UpdateBoardRequest) } // DeleteBoard deletes a board and all its columns and cards -func (s *kanbanService) DeleteBoard(ctx context.Context, id string) error { +func (s *kanbanService) DeleteBoard(ctx context.Context, id, ownerID string) error { s.logger.Info("Deleting board", map[string]interface{}{ "board_id": id, + "owner_id": ownerID, }) + if _, err := s.getBoardForOwner(ctx, id, ownerID); err != nil { + return err + } + // Get all columns for this board columns, err := s.columnRepository.GetColumnsByBoardID(ctx, id) if err != nil { @@ -267,40 +274,46 @@ func (s *kanbanService) DeleteBoard(ctx context.Context, id string) error { } // CreateColumn creates a new column in a board -func (s *kanbanService) CreateColumn(ctx context.Context, req CreateColumnRequest) (*ColumnResponse, error) { +func (s *kanbanService) CreateColumn(ctx context.Context, req CreateColumnRequest, ownerID string) (*ColumnResponse, error) { s.logger.Info("Creating new column", map[string]interface{}{ "name": req.Name, "board_id": req.BoardID, "order": req.Order, }) + if _, err := s.getBoardForOwner(ctx, req.BoardID, ownerID); err != nil { + return nil, err + } + return nil, fmt.Errorf("kanban board uses a fixed bid workflow; columns are created automatically from GoRules") } // UpdateColumn updates an existing column -func (s *kanbanService) UpdateColumn(ctx context.Context, req UpdateColumnRequest) (*ColumnResponse, error) { +func (s *kanbanService) UpdateColumn(ctx context.Context, req UpdateColumnRequest, ownerID string) (*ColumnResponse, error) { s.logger.Info("Updating column", map[string]interface{}{ "column_id": req.ID, + "owner_id": ownerID, }) - column, err := s.columnRepository.GetColumnByID(ctx, req.ID) + column, _, err := s.getColumnForOwner(ctx, req.ID, ownerID) if err != nil { return nil, err } - // Update fields if req.Name != nil { - return nil, fmt.Errorf("column name cannot be changed for fixed status boards") - } - if req.Color != nil { - column.Color = *req.Color + return nil, fmt.Errorf("column name cannot be changed on a fixed workflow board") } if req.Order != nil { - column.Order = *req.Order + return nil, fmt.Errorf("column order cannot be changed on a fixed workflow board") } if req.Limit != nil { - column.Limit = req.Limit + return nil, fmt.Errorf("column WIP limit cannot be changed on a fixed workflow board") } + if req.Color == nil { + return nil, fmt.Errorf("only column color can be updated on a fixed workflow board") + } + + column.Color = *req.Color if err := s.columnRepository.UpdateColumn(ctx, column); err != nil { return nil, err @@ -310,7 +323,7 @@ func (s *kanbanService) UpdateColumn(ctx context.Context, req UpdateColumnReques } // DeleteColumn deletes a column and moves all its cards to the next column -func (s *kanbanService) DeleteColumn(ctx context.Context, id string) error { +func (s *kanbanService) DeleteColumn(ctx context.Context, id, ownerID string) error { s.logger.Info("Deleting column", map[string]interface{}{ "column_id": id, }) @@ -319,28 +332,31 @@ func (s *kanbanService) DeleteColumn(ctx context.Context, id string) error { } // ReorderColumns reorders columns within a board -func (s *kanbanService) ReorderColumns(ctx context.Context, req ReorderColumnsRequest) error { +func (s *kanbanService) ReorderColumns(ctx context.Context, req ReorderColumnsRequest, ownerID string) error { s.logger.Info("Reordering columns", map[string]interface{}{ "board_id": req.BoardID, "count": len(req.ColumnOrders), }) + if _, err := s.getBoardForOwner(ctx, req.BoardID, ownerID); err != nil { + return err + } + return fmt.Errorf("columns are fixed and cannot be reordered") } // CreateCard creates a new card in a column -func (s *kanbanService) CreateCard(ctx context.Context, req CreateCardRequest) (*CardResponse, error) { +func (s *kanbanService) CreateCard(ctx context.Context, req CreateCardRequest, ownerID string) (*CardResponse, error) { s.logger.Info("Creating new card", map[string]interface{}{ "title": req.Title, "tender_id": req.TenderID, "column_id": req.ColumnID, "order": req.Order, + "owner_id": ownerID, }) - // Validate that the column exists - _, err := s.columnRepository.GetColumnByID(ctx, req.ColumnID) - if err != nil { - return nil, fmt.Errorf("column not found: %w", err) + if _, _, err := s.getColumnForOwner(ctx, req.ColumnID, ownerID); err != nil { + return nil, err } // Check for duplicate card (same tender in same column) @@ -373,12 +389,13 @@ func (s *kanbanService) CreateCard(ctx context.Context, req CreateCardRequest) ( } // GetCardByID retrieves a card by ID -func (s *kanbanService) GetCardByID(ctx context.Context, id string) (*CardResponse, error) { +func (s *kanbanService) GetCardByID(ctx context.Context, id, ownerID string) (*CardResponse, error) { s.logger.Info("Retrieving card by ID", map[string]interface{}{ - "card_id": id, + "card_id": id, + "owner_id": ownerID, }) - card, err := s.cardRepository.GetCardByID(ctx, id) + card, _, _, err := s.getCardForOwner(ctx, id, ownerID) if err != nil { return nil, err } @@ -387,12 +404,13 @@ func (s *kanbanService) GetCardByID(ctx context.Context, id string) (*CardRespon } // UpdateCard updates an existing card -func (s *kanbanService) UpdateCard(ctx context.Context, req UpdateCardRequest) (*CardResponse, error) { +func (s *kanbanService) UpdateCard(ctx context.Context, req UpdateCardRequest, ownerID string) (*CardResponse, error) { s.logger.Info("Updating card", map[string]interface{}{ - "card_id": req.ID, + "card_id": req.ID, + "owner_id": ownerID, }) - card, err := s.cardRepository.GetCardByID(ctx, req.ID) + card, _, _, err := s.getCardForOwner(ctx, req.ID, ownerID) if err != nil { return nil, err } @@ -419,39 +437,43 @@ func (s *kanbanService) UpdateCard(ctx context.Context, req UpdateCardRequest) ( } // DeleteCard deletes a card -func (s *kanbanService) DeleteCard(ctx context.Context, id string) error { +func (s *kanbanService) DeleteCard(ctx context.Context, id, ownerID string) error { s.logger.Info("Deleting card", map[string]interface{}{ - "card_id": id, + "card_id": id, + "owner_id": ownerID, }) + if _, _, _, err := s.getCardForOwner(ctx, id, ownerID); err != nil { + return err + } + return s.cardRepository.DeleteCard(ctx, id) } // MoveCard moves a card to a different column -func (s *kanbanService) MoveCard(ctx context.Context, req MoveCardRequest) error { +func (s *kanbanService) MoveCard(ctx context.Context, req MoveCardRequest, ownerID string) error { s.logger.Info("Moving card", map[string]interface{}{ "card_id": req.CardID, "column_id": req.ColumnID, "new_order": req.NewOrder, + "owner_id": ownerID, }) - // Validate that the target column exists - targetColumn, err := s.columnRepository.GetColumnByID(ctx, req.ColumnID) + card, currentColumn, board, err := s.getCardForOwner(ctx, req.CardID, ownerID) if err != nil { - return fmt.Errorf("target column not found: %w", err) + return err } - card, err := s.cardRepository.GetCardByID(ctx, req.CardID) + targetColumn, _, err := s.getColumnForOwner(ctx, req.ColumnID, ownerID) if err != nil { - return fmt.Errorf("card not found: %w", err) + return err } - currentColumn, err := s.columnRepository.GetColumnByID(ctx, card.ColumnID) - if err != nil { - return fmt.Errorf("current column not found: %w", err) + if targetColumn.BoardID != board.ID.Hex() { + return ErrAccessDenied } - allowedStatuses, err := s.availableStatuses(ctx, currentColumn.Status) + allowedStatuses, err := s.availableStatuses(ctx, board.ID.Hex(), currentColumn.Status) if err != nil { return err } @@ -461,16 +483,22 @@ func (s *kanbanService) MoveCard(ctx context.Context, req MoveCardRequest) error return fmt.Errorf("status transition from %q to %q is not allowed", currentColumn.Status, targetColumn.Status) } + _ = card return s.cardRepository.MoveCard(ctx, req.CardID, req.ColumnID, req.NewOrder) } // ReorderCards reorders cards within a column -func (s *kanbanService) ReorderCards(ctx context.Context, req ReorderCardsRequest) error { +func (s *kanbanService) ReorderCards(ctx context.Context, req ReorderCardsRequest, ownerID string) error { s.logger.Info("Reordering cards", map[string]interface{}{ "column_id": req.ColumnID, "count": len(req.CardOrders), + "owner_id": ownerID, }) + if _, _, err := s.getColumnForOwner(ctx, req.ColumnID, ownerID); err != nil { + return err + } + return s.cardRepository.UpdateCardOrder(ctx, req.ColumnID, req.CardOrders) } @@ -502,7 +530,7 @@ func (s *kanbanService) ensureDefaultColumns(ctx context.Context, boardID string return nil } - statuses, err := s.fetchBoardStatuses(ctx) + statuses, err := s.resolveWorkflowColumns(ctx) if err != nil { return err } @@ -550,24 +578,26 @@ func dedupeStatuses(statuses []gorules.StatusWithCategory) []gorules.StatusWithC return out } -func (s *kanbanService) fetchBoardStatuses(ctx context.Context) ([]gorules.StatusWithCategory, error) { - defaultStatuses := s.defaultBidWorkflowStatuses() - - if s.goRulesClient == nil || s.goRulesRuleID == "" { - return defaultStatuses, nil +// resolveWorkflowColumns returns column definitions to persist. +// When GoRules is configured, only a successful GoRules response is persisted. +// When GoRules is not configured, built-in bid workflow defaults are used (explicit offline mode). +func (s *kanbanService) resolveWorkflowColumns(ctx context.Context) ([]gorules.StatusWithCategory, error) { + if !s.goRulesEnabled() { + s.logger.Info("GoRules not configured; using built-in bid workflow columns", map[string]interface{}{}) + return s.defaultBidWorkflowStatuses(), nil } fromRules, err := s.goRulesClient.EvaluateAvailableStatuses(ctx, "", s.goRulesRuleID) if err != nil { - s.logger.Warn("Failed to fetch statuses from GoRules, using default bid workflow", map[string]interface{}{ + s.logger.Warn("GoRules unavailable; workflow columns not persisted", map[string]interface{}{ "error": err.Error(), }) - return defaultStatuses, nil + return nil, fmt.Errorf("%w: %v", ErrWorkflowUnavailable, err) } normalized := dedupeStatuses(fromRules) if len(normalized) == 0 { - return defaultStatuses, nil + return nil, fmt.Errorf("%w: empty status list from rules engine", ErrWorkflowUnavailable) } sort.SliceStable(normalized, func(i, j int) bool { @@ -577,43 +607,113 @@ func (s *kanbanService) fetchBoardStatuses(ctx context.Context) ([]gorules.Statu return normalized, nil } -func (s *kanbanService) availableStatuses(ctx context.Context, currentStatus string) (map[string]struct{}, error) { - allowed := map[string]struct{}{} - - defaultStatuses, err := s.fetchBoardStatuses(ctx) +// availableStatuses returns allowed target column statuses for a move. +// With GoRules configured, only statuses returned by the rules engine are allowed (strict). +// Without GoRules, a linear fallback allows the current stage and the next stage only. +func (s *kanbanService) availableStatuses(ctx context.Context, boardID, currentStatus string) (map[string]struct{}, error) { + columns, err := s.columnRepository.GetColumnsByBoardID(ctx, boardID) if err != nil { return nil, err } - for _, status := range defaultStatuses { - allowed[s.normalizeStatus(status.Status)] = struct{}{} - } - if s.goRulesClient == nil || s.goRulesRuleID == "" { - return allowed, nil + boardStatuses := columnStatusSet(columns) + + if !s.goRulesEnabled() { + s.logger.Warn("GoRules not configured; using linear workflow fallback for card moves (current + next stage only)", map[string]interface{}{ + "board_id": boardID, + "current_status": currentStatus, + }) + return s.linearTransitionFallback(columns, currentStatus), nil } fromRules, err := s.goRulesClient.EvaluateAvailableStatuses(ctx, currentStatus, s.goRulesRuleID) if err != nil { - s.logger.Warn("Failed to validate transition with GoRules, using allowed board statuses", map[string]interface{}{ - "error": err.Error(), - "current_status": currentStatus, - }) - return allowed, nil + return nil, fmt.Errorf("%w: %v", ErrWorkflowUnavailable, err) } - ruleAllowed := map[string]struct{}{} + allowed := map[string]struct{}{} for _, item := range fromRules { normalized := s.normalizeStatus(item.Status) - if _, exists := allowed[normalized]; exists { - ruleAllowed[normalized] = struct{}{} + if _, onBoard := boardStatuses[normalized]; onBoard { + allowed[normalized] = struct{}{} } } - if len(ruleAllowed) == 0 { - return allowed, nil + if len(allowed) == 0 { + return nil, fmt.Errorf("no allowed transitions from %q per workflow rules", currentStatus) } - return ruleAllowed, nil + return allowed, nil +} + +func (s *kanbanService) goRulesEnabled() bool { + return s.goRulesClient != nil && strings.TrimSpace(s.goRulesRuleID) != "" +} + +func columnStatusSet(columns []Column) map[string]struct{} { + set := make(map[string]struct{}, len(columns)) + for _, col := range columns { + set[strings.ToLower(strings.TrimSpace(col.Status))] = struct{}{} + } + return set +} + +// linearTransitionFallback allows staying on the current stage or moving to the next ordered column only. +func (s *kanbanService) linearTransitionFallback(columns []Column, currentStatus string) map[string]struct{} { + sorted := append([]Column(nil), columns...) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Order < sorted[j].Order + }) + + current := s.normalizeStatus(currentStatus) + allowed := map[string]struct{}{current: {}} + + for i, col := range sorted { + if s.normalizeStatus(col.Status) != current { + continue + } + if i+1 < len(sorted) { + allowed[s.normalizeStatus(sorted[i+1].Status)] = struct{}{} + } + break + } + + return allowed +} + +func (s *kanbanService) getBoardForOwner(ctx context.Context, boardID, ownerID string) (*Board, error) { + board, err := s.boardRepository.GetBoardByID(ctx, boardID) + if err != nil { + return nil, err + } + if board.UserID != ownerID { + return nil, ErrAccessDenied + } + return board, nil +} + +func (s *kanbanService) getColumnForOwner(ctx context.Context, columnID, ownerID string) (*Column, *Board, error) { + column, err := s.columnRepository.GetColumnByID(ctx, columnID) + if err != nil { + return nil, nil, err + } + board, err := s.getBoardForOwner(ctx, column.BoardID, ownerID) + if err != nil { + return nil, nil, err + } + return column, board, nil +} + +func (s *kanbanService) getCardForOwner(ctx context.Context, cardID, ownerID string) (*Card, *Column, *Board, error) { + card, err := s.cardRepository.GetCardByID(ctx, cardID) + if err != nil { + return nil, nil, nil, err + } + column, board, err := s.getColumnForOwner(ctx, card.ColumnID, ownerID) + if err != nil { + return nil, nil, nil, err + } + return card, column, board, nil } func (s *kanbanService) normalizeStatus(status string) string {