kanban dashboard refactor
This commit is contained in:
+206
-77
@@ -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]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user