package kanban import ( "context" "fmt" "sort" "strings" "tm/pkg/gorules" "tm/pkg/logger" ) // KanbanService interface defines kanban business logic operations type Service interface { // Board operations CreateBoard(ctx context.Context, req CreateBoardRequest) (*BoardResponse, error) GetBoardByID(ctx context.Context, id 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 // 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 // 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 } // kanbanService implements KanbanService interface 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, 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, "is_default": req.IsDefault, }) // If this is set as default, unset any existing default board for this user if req.IsDefault { if err := s.unsetDefaultBoard(ctx, req.UserID); err != nil { s.logger.Warn("Failed to unset existing default board", map[string]interface{}{ "user_id": req.UserID, "error": err.Error(), }) } } board := &Board{ Name: req.Name, Description: req.Description, IsDefault: req.IsDefault, UserID: req.UserID, Columns: []Column{}, // Start with empty columns } if err := s.boardRepository.CreateBoard(ctx, board); err != nil { 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) { s.logger.Info("Retrieving board by ID", map[string]interface{}{ "board_id": id, }) board, err := s.boardRepository.GetBoardByID(ctx, id) if err != nil { return nil, err } if err := s.ensureDefaultColumns(ctx, id); err != nil { return nil, err } boardResponse := board.ToResponse() // Load columns with cards columns, err := s.columnRepository.GetColumnsByBoardID(ctx, id) if err != nil { return nil, err } for _, column := range columns { columnResponse := column.ToResponse() // Load cards for this column cards, err := s.cardRepository.GetCardsByColumnID(ctx, column.ID.Hex()) if err != nil { s.logger.Warn("Failed to load cards for column", map[string]interface{}{ "column_id": column.ID.Hex(), "error": err.Error(), }) continue } columnResponse.CardCount = len(cards) if column.Limit != nil && len(cards) > *column.Limit { columnResponse.IsOverLimit = true } for _, card := range cards { columnResponse.Cards = append(columnResponse.Cards, *card.ToResponse()) } boardResponse.Columns = append(boardResponse.Columns, *columnResponse) } return &BoardDetailResponse{ Board: *boardResponse, }, nil } // GetBoardsByUserID retrieves all boards for a user func (s *kanbanService) GetBoardsByUserID(ctx context.Context, userID string) (*BoardsListResponse, error) { s.logger.Info("Retrieving boards by user ID", map[string]interface{}{ "user_id": userID, }) boards, err := s.boardRepository.GetBoardsByUserID(ctx, userID) if err != nil { return nil, err } var boardResponses []BoardResponse for _, board := range boards { boardResponses = append(boardResponses, *board.ToResponse()) } return &BoardsListResponse{ Boards: boardResponses, }, nil } // GetDefaultBoard retrieves the default board for a user func (s *kanbanService) GetDefaultBoard(ctx context.Context, userID string) (*BoardDetailResponse, error) { s.logger.Info("Retrieving default board", map[string]interface{}{ "user_id": userID, }) board, err := s.boardRepository.GetDefaultBoard(ctx, userID) if err != nil { return nil, err } return s.GetBoardByID(ctx, board.ID.Hex()) } // UpdateBoard updates an existing board func (s *kanbanService) UpdateBoard(ctx context.Context, req UpdateBoardRequest) (*BoardResponse, error) { s.logger.Info("Updating board", map[string]interface{}{ "board_id": req.ID, }) board, err := s.boardRepository.GetBoardByID(ctx, req.ID) if err != nil { return nil, err } // Update fields if req.Name != nil { board.Name = *req.Name } if req.Description != nil { board.Description = *req.Description } if req.IsDefault != nil { // If setting as default, unset other default boards if *req.IsDefault && !board.IsDefault { if err := s.unsetDefaultBoard(ctx, board.UserID); err != nil { s.logger.Warn("Failed to unset existing default board", map[string]interface{}{ "user_id": board.UserID, "error": err.Error(), }) } } board.IsDefault = *req.IsDefault } if err := s.boardRepository.UpdateBoard(ctx, board); err != nil { return nil, err } return board.ToResponse(), nil } // DeleteBoard deletes a board and all its columns and cards func (s *kanbanService) DeleteBoard(ctx context.Context, id string) error { s.logger.Info("Deleting board", map[string]interface{}{ "board_id": id, }) // Get all columns for this board columns, err := s.columnRepository.GetColumnsByBoardID(ctx, id) if err != nil { return err } // Delete all cards in each column for _, column := range columns { cards, err := s.cardRepository.GetCardsByColumnID(ctx, column.ID.Hex()) if err != nil { s.logger.Warn("Failed to get cards for column during board deletion", map[string]interface{}{ "column_id": column.ID.Hex(), "error": err.Error(), }) continue } for _, card := range cards { if err := s.cardRepository.DeleteCard(ctx, card.ID.Hex()); err != nil { s.logger.Warn("Failed to delete card during board deletion", map[string]interface{}{ "card_id": card.ID.Hex(), "error": err.Error(), }) } } // Delete the column if err := s.columnRepository.DeleteColumn(ctx, column.ID.Hex()); err != nil { s.logger.Warn("Failed to delete column during board deletion", map[string]interface{}{ "column_id": column.ID.Hex(), "error": err.Error(), }) } } // Finally delete the board return s.boardRepository.DeleteBoard(ctx, id) } // CreateColumn creates a new column in a board func (s *kanbanService) CreateColumn(ctx context.Context, req CreateColumnRequest) (*ColumnResponse, error) { s.logger.Info("Creating new column", map[string]interface{}{ "name": req.Name, "board_id": req.BoardID, "order": req.Order, }) 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) { s.logger.Info("Updating column", map[string]interface{}{ "column_id": req.ID, }) column, err := s.columnRepository.GetColumnByID(ctx, req.ID) 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 } if req.Order != nil { column.Order = *req.Order } if req.Limit != nil { column.Limit = req.Limit } if err := s.columnRepository.UpdateColumn(ctx, column); err != nil { return nil, err } return column.ToResponse(), nil } // DeleteColumn deletes a column and moves all its cards to the next column func (s *kanbanService) DeleteColumn(ctx context.Context, id string) error { s.logger.Info("Deleting column", map[string]interface{}{ "column_id": 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 { s.logger.Info("Reordering columns", map[string]interface{}{ "board_id": req.BoardID, "count": len(req.ColumnOrders), }) 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) { s.logger.Info("Creating new card", map[string]interface{}{ "title": req.Title, "tender_id": req.TenderID, "column_id": req.ColumnID, "order": req.Order, }) // Validate that the column exists _, err := s.columnRepository.GetColumnByID(ctx, req.ColumnID) if err != nil { return nil, fmt.Errorf("column not found: %w", err) } // Check for duplicate card (same tender in same column) existingCards, err := s.cardRepository.GetCardsByTenderID(ctx, req.TenderID) if err != nil { return nil, err } for _, card := range existingCards { if card.ColumnID == req.ColumnID { return nil, fmt.Errorf("card for this tender already exists in the column") } } card := &Card{ ColumnID: req.ColumnID, TenderID: req.TenderID, Title: req.Title, Order: req.Order, Priority: req.Priority, Labels: req.Labels, DueDate: req.DueDate, } if err := s.cardRepository.CreateCard(ctx, card); err != nil { return nil, err } return card.ToResponse(), nil } // GetCardByID retrieves a card by ID func (s *kanbanService) GetCardByID(ctx context.Context, id string) (*CardResponse, error) { s.logger.Info("Retrieving card by ID", map[string]interface{}{ "card_id": id, }) card, err := s.cardRepository.GetCardByID(ctx, id) if err != nil { return nil, err } return card.ToResponse(), nil } // UpdateCard updates an existing card func (s *kanbanService) UpdateCard(ctx context.Context, req UpdateCardRequest) (*CardResponse, error) { s.logger.Info("Updating card", map[string]interface{}{ "card_id": req.ID, }) card, err := s.cardRepository.GetCardByID(ctx, req.ID) if err != nil { return nil, err } // Update fields if req.Title != nil { card.Title = *req.Title } if req.Priority != nil { card.Priority = *req.Priority } if req.Labels != nil { card.Labels = req.Labels } if req.DueDate != nil { card.DueDate = req.DueDate } if err := s.cardRepository.UpdateCard(ctx, card); err != nil { return nil, err } return card.ToResponse(), nil } // DeleteCard deletes a card func (s *kanbanService) DeleteCard(ctx context.Context, id string) error { s.logger.Info("Deleting card", map[string]interface{}{ "card_id": id, }) return s.cardRepository.DeleteCard(ctx, id) } // 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, }) // Validate that the target column exists 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) } // ReorderCards reorders cards within a column func (s *kanbanService) ReorderCards(ctx context.Context, req ReorderCardsRequest) error { s.logger.Info("Reordering cards", map[string]interface{}{ "column_id": req.ColumnID, "count": len(req.CardOrders), }) return s.cardRepository.UpdateCardOrder(ctx, req.ColumnID, req.CardOrders) } // unsetDefaultBoard removes the default flag from all boards for a user func (s *kanbanService) unsetDefaultBoard(ctx context.Context, userID string) error { boards, err := s.boardRepository.GetBoardsByUserID(ctx, userID) if err != nil { return err } for _, board := range boards { if board.IsDefault { board.IsDefault = false if err := s.boardRepository.UpdateBoard(ctx, &board); err != nil { return err } } } 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] }