kanban dashboard refactor

This commit is contained in:
Mazyar
2026-05-27 22:06:23 +03:30
parent 42cd0452ce
commit ce2dc7a61b
12 changed files with 457 additions and 176 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
+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,
}
}
+23 -30
View File
@@ -20,32 +20,26 @@ func NewHandler(service Service) *Handler {
}
}
// RegisterRoutes registers kanban routes
// RegisterRoutes registers kanban routes on the provided group (e.g. /admin/v1/kanban).
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,12 +59,11 @@ 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 {
@@ -112,12 +105,12 @@ 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)
boards, err := h.service.GetBoardsByUserID(c.Request().Context(), ownerID)
if err != nil {
return response.InternalServerError(c, "Failed to get boards")
}
@@ -136,12 +129,12 @@ 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)
board, err := h.service.GetDefaultBoard(c.Request().Context(), ownerID)
if err != nil {
return response.InternalServerError(c, "Failed to get default board")
}
+206 -77
View File
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"sort"
"strings"
"tm/pkg/gorules"
"tm/pkg/logger"
)
@@ -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,6 +88,10 @@ 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
}
@@ -94,6 +106,10 @@ func (s *kanbanService) GetBoardByID(ctx context.Context, id string) (*BoardDeta
return nil, err
}
if err := s.ensureDefaultColumns(ctx, id); err != nil {
return nil, err
}
boardResponse := board.ToResponse()
// Load columns with cards
@@ -258,19 +274,7 @@ func (s *kanbanService) CreateColumn(ctx context.Context, req CreateColumnReques
"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 {
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
@@ -286,7 +290,7 @@ func (s *kanbanService) UpdateColumn(ctx context.Context, req UpdateColumnReques
// Update fields
if req.Name != nil {
column.Name = *req.Name
return nil, fmt.Errorf("column name cannot be changed for fixed status boards")
}
if req.Color != nil {
column.Color = *req.Color
@@ -311,61 +315,7 @@ func (s *kanbanService) DeleteColumn(ctx context.Context, id string) error {
"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
@@ -375,7 +325,7 @@ func (s *kanbanService) ReorderColumns(ctx context.Context, req ReorderColumnsRe
"count": len(req.ColumnOrders),
})
return s.columnRepository.UpdateColumnOrder(ctx, req.BoardID, req.ColumnOrders)
return fmt.Errorf("columns are fixed and cannot be reordered")
}
// CreateCard creates a new card in a column
@@ -480,17 +430,37 @@ func (s *kanbanService) DeleteCard(ctx context.Context, id string) error {
// MoveCard moves a card to a different column
func (s *kanbanService) MoveCard(ctx context.Context, req MoveCardRequest) 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,
})
// Validate that the target column exists
_, err := s.columnRepository.GetColumnByID(ctx, req.ColumnID)
targetColumn, err := s.columnRepository.GetColumnByID(ctx, req.ColumnID)
if err != nil {
return fmt.Errorf("target column not found: %w", err)
}
card, err := s.cardRepository.GetCardByID(ctx, req.CardID)
if err != nil {
return fmt.Errorf("card not found: %w", err)
}
currentColumn, err := s.columnRepository.GetColumnByID(ctx, card.ColumnID)
if err != nil {
return fmt.Errorf("current column not found: %w", err)
}
allowedStatuses, err := s.availableStatuses(ctx, 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)
}
return s.cardRepository.MoveCard(ctx, req.CardID, req.ColumnID, req.NewOrder)
}
@@ -522,3 +492,162 @@ 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.fetchBoardStatuses(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
}
func (s *kanbanService) fetchBoardStatuses(ctx context.Context) ([]gorules.StatusWithCategory, error) {
defaultStatuses := s.defaultBidWorkflowStatuses()
if s.goRulesClient == nil || s.goRulesRuleID == "" {
return defaultStatuses, 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{}{
"error": err.Error(),
})
return defaultStatuses, nil
}
normalized := dedupeStatuses(fromRules)
if len(normalized) == 0 {
return defaultStatuses, nil
}
sort.SliceStable(normalized, func(i, j int) bool {
return s.statusRank(normalized[i].Status) < s.statusRank(normalized[j].Status)
})
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)
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
}
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
}
ruleAllowed := map[string]struct{}{}
for _, item := range fromRules {
normalized := s.normalizeStatus(item.Status)
if _, exists := allowed[normalized]; exists {
ruleAllowed[normalized] = struct{}{}
}
}
if len(ruleAllowed) == 0 {
return allowed, nil
}
return ruleAllowed, 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]
}