Merge pull request 'kanban dashboard refactor' (#21) from kanban into develop

Reviewed-on: https://repo.ravanertebat.com/TM/tm_back/pulls/21
Reviewed-by: Hadi Barzegar <barzagarhadi@gmail.com>
This commit is contained in:
m.nazemi
2026-05-30 14:19:24 +03:30
15 changed files with 764 additions and 265 deletions
+14
View File
@@ -0,0 +1,14 @@
package kanban
import "github.com/labstack/echo/v4"
// ownerIDFromContext returns the board owner ID from admin (user_id) or mobile (customer_id) auth.
func ownerIDFromContext(c echo.Context) (string, bool) {
if id, ok := c.Get("customer_id").(string); ok && id != "" {
return id, true
}
if id, ok := c.Get("user_id").(string); ok && id != "" {
return id, true
}
return "", false
}
+19 -17
View File
@@ -7,9 +7,9 @@ import (
// Board represents a kanban board
type Board struct {
mongo.Model `bson:",inline"`
Name string `bson:"name" json:"name"`
Description string `bson:"description" json:"description"`
mongo.Model `bson:",inline"`
Name string `bson:"name" json:"name"`
Description string `bson:"description" json:"description"`
Columns []Column `bson:"columns" json:"columns"`
IsDefault bool `bson:"is_default" json:"is_default"` // Whether this is the default board
UserID string `bson:"user_id" json:"user_id"` // User who owns this board
@@ -17,24 +17,26 @@ type Board struct {
// Column represents a column on a kanban board
type Column struct {
mongo.Model `bson:",inline"`
BoardID string `bson:"board_id" json:"board_id"`
Name string `bson:"name" json:"name"`
Order int `bson:"order" json:"order"` // Display order in the board
Color string `bson:"color" json:"color"` // Hex color code for the column
Limit *int `bson:"limit,omitempty" json:"limit,omitempty"` // WIP limit (optional)
mongo.Model `bson:",inline"`
BoardID string `bson:"board_id" json:"board_id"`
Name string `bson:"name" json:"name"`
Status string `bson:"status" json:"status"`
StatusCategory string `bson:"status_category" json:"status_category"`
Order int `bson:"order" json:"order"` // Display order in the board
Color string `bson:"color" json:"color"` // Hex color code for the column
Limit *int `bson:"limit,omitempty" json:"limit,omitempty"` // WIP limit (optional)
}
// Card represents a card on a kanban board (references a tender)
type Card struct {
mongo.Model `bson:",inline"`
ColumnID string `bson:"column_id" json:"column_id"`
TenderID string `bson:"tender_id" json:"tender_id"` // Reference to the tender
Title string `bson:"title" json:"title"` // Cached title from tender
Order int `bson:"order" json:"order"` // Position within the column
Priority CardPriority `bson:"priority" json:"priority"`
Labels []string `bson:"labels,omitempty" json:"labels,omitempty"`
DueDate *int64 `bson:"due_date,omitempty" json:"due_date,omitempty"` // Unix timestamp
mongo.Model `bson:",inline"`
ColumnID string `bson:"column_id" json:"column_id"`
TenderID string `bson:"tender_id" json:"tender_id"` // Reference to the tender
Title string `bson:"title" json:"title"` // Cached title from tender
Order int `bson:"order" json:"order"` // Position within the column
Priority CardPriority `bson:"priority" json:"priority"`
Labels []string `bson:"labels,omitempty" json:"labels,omitempty"`
DueDate *int64 `bson:"due_date,omitempty" json:"due_date,omitempty"` // Unix timestamp
}
// CardPriority represents the priority level of a card
+11
View File
@@ -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")
)
+52 -48
View File
@@ -29,22 +29,22 @@ type CreateColumnRequest struct {
// UpdateColumnRequest represents a request to update an existing column
type UpdateColumnRequest struct {
ID string `json:"id" valid:"required~Column ID is required"`
Name *string `json:"name,omitempty" valid:"stringlength(1|50)~Column name must be between 1 and 50 characters"`
Color *string `json:"color,omitempty" valid:"hexcolor~Color must be a valid hex color"`
Order *int `json:"order,omitempty"`
Limit *int `json:"limit,omitempty" valid:"range(1|100)~WIP limit must be between 1 and 100"`
ID string `json:"id" valid:"required~Column ID is required"`
Name *string `json:"name,omitempty" valid:"stringlength(1|50)~Column name must be between 1 and 50 characters"`
Color *string `json:"color,omitempty" valid:"hexcolor~Color must be a valid hex color"`
Order *int `json:"order,omitempty"`
Limit *int `json:"limit,omitempty" valid:"range(1|100)~WIP limit must be between 1 and 100"`
}
// CreateCardRequest represents a request to create a new card
type CreateCardRequest struct {
ColumnID string `json:"column_id" valid:"required~Column ID is required"`
TenderID string `json:"tender_id" valid:"required~Tender ID is required"`
Title string `json:"title" valid:"required~Title is required,stringlength(1|200)~Title must be between 1 and 200 characters"`
Order int `json:"order"`
Priority CardPriority `json:"priority"`
Labels []string `json:"labels,omitempty"`
DueDate *int64 `json:"due_date,omitempty"` // Unix timestamp
ColumnID string `json:"column_id" valid:"required~Column ID is required"`
TenderID string `json:"tender_id" valid:"required~Tender ID is required"`
Title string `json:"title" valid:"required~Title is required,stringlength(1|200)~Title must be between 1 and 200 characters"`
Order int `json:"order"`
Priority CardPriority `json:"priority"`
Labels []string `json:"labels,omitempty"`
DueDate *int64 `json:"due_date,omitempty"` // Unix timestamp
}
// UpdateCardRequest represents a request to update an existing card
@@ -58,15 +58,15 @@ type UpdateCardRequest struct {
// MoveCardRequest represents a request to move a card to a different column
type MoveCardRequest struct {
CardID string `json:"card_id" valid:"required~Card ID is required"`
ColumnID string `json:"column_id" valid:"required~Column ID is required"`
NewOrder int `json:"new_order"`
CardID string `json:"card_id" valid:"required~Card ID is required"`
ColumnID string `json:"column_id" valid:"required~Column ID is required"`
NewOrder int `json:"new_order"`
}
// ReorderCardsRequest represents a request to reorder cards within a column
type ReorderCardsRequest struct {
ColumnID string `json:"column_id" valid:"required~Column ID is required"`
CardOrders map[string]int `json:"card_orders" valid:"required~Card orders are required"`
ColumnID string `json:"column_id" valid:"required~Column ID is required"`
CardOrders map[string]int `json:"card_orders" valid:"required~Card orders are required"`
}
// ReorderColumnsRequest represents a request to reorder columns within a board
@@ -77,29 +77,31 @@ type ReorderColumnsRequest struct {
// BoardResponse represents a board in API responses
type BoardResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Columns []ColumnResponse `json:"columns"`
IsDefault bool `json:"is_default"`
UserID string `json:"user_id"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Columns []ColumnResponse `json:"columns"`
IsDefault bool `json:"is_default"`
UserID string `json:"user_id"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// ColumnResponse represents a column in API responses
type ColumnResponse struct {
ID string `json:"id"`
BoardID string `json:"board_id"`
Name string `json:"name"`
Order int `json:"order"`
Color string `json:"color"`
Limit *int `json:"limit,omitempty"`
Cards []CardResponse `json:"cards"`
CardCount int `json:"card_count"`
IsOverLimit bool `json:"is_over_limit"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
ID string `json:"id"`
BoardID string `json:"board_id"`
Name string `json:"name"`
Status string `json:"status"`
StatusCategory string `json:"status_category"`
Order int `json:"order"`
Color string `json:"color"`
Limit *int `json:"limit,omitempty"`
Cards []CardResponse `json:"cards"`
CardCount int `json:"card_count"`
IsOverLimit bool `json:"is_over_limit"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// CardResponse represents a card in API responses
@@ -124,7 +126,7 @@ type BoardsListResponse struct {
// BoardDetailResponse represents detailed board response with full column and card data
type BoardDetailResponse struct {
Board BoardResponse `json:"board"`
Board BoardResponse `json:"board"`
Meta *response.Meta `json:"-"`
}
@@ -145,17 +147,19 @@ func (b *Board) ToResponse() *BoardResponse {
// ToColumnResponse converts a Column entity to ColumnResponse
func (c *Column) ToResponse() *ColumnResponse {
return &ColumnResponse{
ID: c.ID.Hex(),
BoardID: c.BoardID,
Name: c.Name,
Order: c.Order,
Color: c.Color,
Limit: c.Limit,
Cards: make([]CardResponse, 0), // Will be populated by service
CardCount: 0, // Will be populated by service
IsOverLimit: false, // Will be calculated by service
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
ID: c.ID.Hex(),
BoardID: c.BoardID,
Name: c.Name,
Status: c.Status,
StatusCategory: c.StatusCategory,
Order: c.Order,
Color: c.Color,
Limit: c.Limit,
Cards: make([]CardResponse, 0), // Will be populated by service
CardCount: 0, // Will be populated by service
IsOverLimit: false, // Will be calculated by service
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
}
+129 -69
View File
@@ -20,32 +20,28 @@ func NewHandler(service Service) *Handler {
}
}
// RegisterRoutes registers kanban routes
// 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) {
kanban := router.Group("/kanban")
{
// Board routes
kanban.POST("/boards", h.CreateBoard)
kanban.GET("/boards", h.GetBoardsByUser)
kanban.GET("/boards/default", h.GetDefaultBoard)
kanban.GET("/boards/:id", h.GetBoardByID)
kanban.PUT("/boards/:id", h.UpdateBoard)
kanban.DELETE("/boards/:id", h.DeleteBoard)
router.POST("/boards", h.CreateBoard)
router.GET("/boards", h.GetBoardsByUser)
router.GET("/boards/default", h.GetDefaultBoard)
router.GET("/boards/:id", h.GetBoardByID)
router.PUT("/boards/:id", h.UpdateBoard)
router.DELETE("/boards/:id", h.DeleteBoard)
// Column routes
kanban.POST("/columns", h.CreateColumn)
kanban.PUT("/columns/:id", h.UpdateColumn)
kanban.DELETE("/columns/:id", h.DeleteColumn)
kanban.PUT("/columns/reorder", h.ReorderColumns)
router.POST("/columns", h.CreateColumn)
router.PUT("/columns/:id", h.UpdateColumn)
router.DELETE("/columns/:id", h.DeleteColumn)
router.PUT("/columns/reorder", h.ReorderColumns)
// Card routes
kanban.POST("/cards", h.CreateCard)
kanban.GET("/cards/:id", h.GetCardByID)
kanban.PUT("/cards/:id", h.UpdateCard)
kanban.DELETE("/cards/:id", h.DeleteCard)
kanban.PUT("/cards/move", h.MoveCard)
kanban.PUT("/cards/reorder", h.ReorderCards)
}
router.POST("/cards", h.CreateCard)
router.GET("/cards/:id", h.GetCardByID)
router.PUT("/cards/:id", h.UpdateCard)
router.DELETE("/cards/:id", h.DeleteCard)
router.PUT("/cards/move", h.MoveCard)
router.PUT("/cards/reorder", h.ReorderCards)
}
// CreateBoard creates a new kanban board
@@ -65,16 +61,15 @@ func (h *Handler) CreateBoard(c echo.Context) error {
return response.ValidationError(c, "Invalid request data", err.Error())
}
// Get user ID from context (assuming auth middleware sets this)
userID, exists := c.Get("user_id").(string)
ownerID, exists := ownerIDFromContext(c)
if !exists {
return response.Unauthorized(c, "User not authenticated")
}
req.UserID = userID
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")
@@ -94,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")
@@ -112,14 +112,14 @@ func (h *Handler) GetBoardByID(c echo.Context) error {
// @Failure 500 {object} response.Response
// @Router /kanban/boards [get]
func (h *Handler) GetBoardsByUser(c echo.Context) error {
userID, exists := c.Get("user_id").(string)
ownerID, exists := ownerIDFromContext(c)
if !exists {
return response.Unauthorized(c, "User not authenticated")
}
boards, err := h.service.GetBoardsByUserID(c.Request().Context(), userID)
if err != nil {
return response.InternalServerError(c, "Failed to get boards")
boards, err := h.service.GetBoardsByUserID(c.Request().Context(), ownerID)
if err := handleServiceError(c, err, "Failed to get boards"); err != nil {
return err
}
return response.Success(c, boards, "Boards retrieved successfully")
@@ -136,14 +136,14 @@ func (h *Handler) GetBoardsByUser(c echo.Context) error {
// @Failure 500 {object} response.Response
// @Router /kanban/boards/default [get]
func (h *Handler) GetDefaultBoard(c echo.Context) error {
userID, exists := c.Get("user_id").(string)
ownerID, exists := ownerIDFromContext(c)
if !exists {
return response.Unauthorized(c, "User not authenticated")
}
board, err := h.service.GetDefaultBoard(c.Request().Context(), userID)
if err != nil {
return response.InternalServerError(c, "Failed to get default board")
board, err := h.service.GetDefaultBoard(c.Request().Context(), ownerID)
if err := handleServiceError(c, err, "Failed to get default board"); err != nil {
return err
}
return response.Success(c, board, "Default board retrieved successfully")
@@ -170,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")
@@ -192,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)
@@ -216,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")
@@ -245,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")
@@ -267,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)
@@ -291,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")
@@ -334,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")
@@ -356,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")
@@ -392,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")
@@ -414,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)
@@ -439,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")
@@ -463,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")
+40
View File
@@ -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)
}
+5 -5
View File
@@ -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
}
+350 -121
View File
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"sort"
"strings"
"tm/pkg/gorules"
"tm/pkg/logger"
)
@@ -11,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
@@ -37,24 +39,30 @@ type kanbanService struct {
boardRepository BoardRepository
columnRepository ColumnRepository
cardRepository CardRepository
goRulesClient gorules.Client
goRulesRuleID string
logger logger.Logger
}
// NewService creates a new kanban service
func NewService(boardRepo BoardRepository, columnRepo ColumnRepository, cardRepo CardRepository, logger logger.Logger) Service {
func NewService(boardRepo BoardRepository, columnRepo ColumnRepository, cardRepo CardRepository, goRulesClient gorules.Client, goRulesRuleID string, logger logger.Logger) Service {
return &kanbanService{
boardRepository: boardRepo,
columnRepository: columnRepo,
cardRepository: cardRepo,
goRulesClient: goRulesClient,
goRulesRuleID: goRulesRuleID,
logger: logger,
}
}
var defaultColumnColors = []string{"#64748B", "#0EA5E9", "#22C55E", "#A855F7", "#F59E0B"}
// CreateBoard creates a new kanban board
func (s *kanbanService) CreateBoard(ctx context.Context, req CreateBoardRequest) (*BoardResponse, error) {
s.logger.Info("Creating new board", map[string]interface{}{
"name": req.Name,
"user_id": req.UserID,
"name": req.Name,
"user_id": req.UserID,
"is_default": req.IsDefault,
})
@@ -80,20 +88,29 @@ func (s *kanbanService) CreateBoard(ctx context.Context, req CreateBoardRequest)
return nil, err
}
if err := s.ensureDefaultColumns(ctx, board.ID.Hex()); err != nil {
return nil, err
}
return board.ToResponse(), nil
}
// 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
}
if err := s.ensureDefaultColumns(ctx, id); err != nil {
return nil, err
}
boardResponse := board.ToResponse()
// Load columns with cards
@@ -164,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
}
@@ -206,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 {
@@ -251,52 +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,
})
column := &Column{
BoardID: req.BoardID,
Name: req.Name,
Color: req.Color,
Order: req.Order,
Limit: req.Limit,
}
if err := s.columnRepository.CreateColumn(ctx, column); err != nil {
if _, err := s.getBoardForOwner(ctx, req.BoardID, ownerID); err != nil {
return nil, err
}
return column.ToResponse(), nil
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 {
column.Name = *req.Name
}
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
@@ -306,91 +323,40 @@ 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,
})
column, err := s.columnRepository.GetColumnByID(ctx, id)
if err != nil {
return err
}
// Get all columns for this board to find the next one
columns, err := s.columnRepository.GetColumnsByBoardID(ctx, column.BoardID)
if err != nil {
return err
}
// Find the next column (one with higher order)
var nextColumn *Column
sort.Slice(columns, func(i, j int) bool {
return columns[i].Order < columns[j].Order
})
for _, col := range columns {
if col.Order > column.Order {
nextColumn = &col
break
}
}
// Move cards to next column or delete them if no next column exists
cards, err := s.cardRepository.GetCardsByColumnID(ctx, id)
if err != nil {
return err
}
if nextColumn != nil {
// Move cards to next column
for _, card := range cards {
if err := s.cardRepository.MoveCard(ctx, card.ID.Hex(), nextColumn.ID.Hex(), card.Order); err != nil {
s.logger.Warn("Failed to move card during column deletion", map[string]interface{}{
"card_id": card.ID.Hex(),
"target_column_id": nextColumn.ID.Hex(),
"error": err.Error(),
})
}
}
} else {
// Delete cards if no next column
for _, card := range cards {
if err := s.cardRepository.DeleteCard(ctx, card.ID.Hex()); err != nil {
s.logger.Warn("Failed to delete card during column deletion", map[string]interface{}{
"card_id": card.ID.Hex(),
"error": err.Error(),
})
}
}
}
// Delete the column
return s.columnRepository.DeleteColumn(ctx, id)
return fmt.Errorf("columns are fixed and cannot be deleted")
}
// 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),
})
return s.columnRepository.UpdateColumnOrder(ctx, req.BoardID, 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)
@@ -423,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
}
@@ -437,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
}
@@ -469,38 +437,68 @@ 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,
"card_id": req.CardID,
"column_id": req.ColumnID,
"new_order": req.NewOrder,
"owner_id": ownerID,
})
// Validate that the target column exists
_, 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
}
targetColumn, _, err := s.getColumnForOwner(ctx, req.ColumnID, ownerID)
if err != nil {
return err
}
if targetColumn.BoardID != board.ID.Hex() {
return ErrAccessDenied
}
allowedStatuses, err := s.availableStatuses(ctx, board.ID.Hex(), currentColumn.Status)
if err != nil {
return err
}
targetStatus := s.normalizeStatus(targetColumn.Status)
if _, exists := allowedStatuses[targetStatus]; !exists {
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)
}
@@ -522,3 +520,234 @@ func (s *kanbanService) unsetDefaultBoard(ctx context.Context, userID string) er
return nil
}
func (s *kanbanService) ensureDefaultColumns(ctx context.Context, boardID string) error {
columns, err := s.columnRepository.GetColumnsByBoardID(ctx, boardID)
if err != nil {
return err
}
if len(columns) > 0 {
return nil
}
statuses, err := s.resolveWorkflowColumns(ctx)
if err != nil {
return err
}
for idx, status := range statuses {
column := &Column{
BoardID: boardID,
Name: status.Status,
Status: status.Status,
StatusCategory: status.StatusCategory,
Color: s.columnColor(status, idx),
Order: idx + 1,
}
if err := s.columnRepository.CreateColumn(ctx, column); err != nil {
return err
}
}
return nil
}
func (s *kanbanService) defaultBidWorkflowStatuses() []gorules.StatusWithCategory {
return []gorules.StatusWithCategory{
{Status: "Qualification", StatusCategory: "qualification"},
{Status: "Proposal", StatusCategory: "proposal"},
{Status: "Submission", StatusCategory: "submission"},
}
}
func dedupeStatuses(statuses []gorules.StatusWithCategory) []gorules.StatusWithCategory {
seen := make(map[string]struct{})
out := make([]gorules.StatusWithCategory, 0, len(statuses))
for _, item := range statuses {
if strings.TrimSpace(item.Status) == "" {
continue
}
key := strings.ToLower(strings.TrimSpace(item.Status))
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
out = append(out, item)
}
return out
}
// 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("GoRules unavailable; workflow columns not persisted", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("%w: %v", ErrWorkflowUnavailable, err)
}
normalized := dedupeStatuses(fromRules)
if len(normalized) == 0 {
return nil, fmt.Errorf("%w: empty status list from rules engine", ErrWorkflowUnavailable)
}
sort.SliceStable(normalized, func(i, j int) bool {
return s.statusRank(normalized[i].Status) < s.statusRank(normalized[j].Status)
})
return normalized, nil
}
// 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
}
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 {
return nil, fmt.Errorf("%w: %v", ErrWorkflowUnavailable, err)
}
allowed := map[string]struct{}{}
for _, item := range fromRules {
normalized := s.normalizeStatus(item.Status)
if _, onBoard := boardStatuses[normalized]; onBoard {
allowed[normalized] = struct{}{}
}
}
if len(allowed) == 0 {
return nil, fmt.Errorf("no allowed transitions from %q per workflow rules", currentStatus)
}
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 {
return strings.ToLower(strings.TrimSpace(status))
}
func (s *kanbanService) statusRank(status string) int {
switch s.normalizeStatus(status) {
case "qualification", "to do", "identification":
return 1
case "proposal", "in progress":
return 2
case "submission", "done", "analysis":
return 3
default:
return 99
}
}
func (s *kanbanService) columnColor(status gorules.StatusWithCategory, index int) string {
byCategory := map[string]string{
"qualification": "#64748B",
"to_do": "#64748B",
"proposal": "#0EA5E9",
"in_progress": "#0EA5E9",
"submission": "#22C55E",
"done": "#22C55E",
"analysis": "#22C55E",
}
if c, ok := byCategory[strings.ToLower(strings.TrimSpace(status.StatusCategory))]; ok {
return c
}
if index >= 0 && index < len(defaultColumnColors) {
return defaultColumnColors[index]
}
return defaultColumnColors[0]
}