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, 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, ownerID string) (*BoardResponse, error) DeleteBoard(ctx context.Context, id, ownerID string) error // Column operations 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, 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 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, ownerID string) (*BoardDetailResponse, error) { s.logger.Info("Retrieving board by ID", map[string]interface{}{ "board_id": id, "owner_id": ownerID, }) 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 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(), userID) } // UpdateBoard updates an existing board 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.getBoardForOwner(ctx, req.ID, ownerID) 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, 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 { 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, ownerID string) (*ColumnResponse, error) { s.logger.Info("Creating new column", map[string]interface{}{ "name": req.Name, "board_id": req.BoardID, "order": req.Order, }) if _, err := s.getBoardForOwner(ctx, req.BoardID, ownerID); err != nil { return nil, err } return nil, fmt.Errorf("kanban board uses a fixed bid workflow; columns are created automatically from GoRules") } // UpdateColumn updates an existing column func (s *kanbanService) UpdateColumn(ctx context.Context, req UpdateColumnRequest, ownerID string) (*ColumnResponse, error) { s.logger.Info("Updating column", map[string]interface{}{ "column_id": req.ID, "owner_id": ownerID, }) column, _, err := s.getColumnForOwner(ctx, req.ID, ownerID) if err != nil { return nil, err } if req.Name != nil { return nil, fmt.Errorf("column name cannot be changed on a fixed workflow board") } if req.Order != nil { return nil, fmt.Errorf("column order cannot be changed on a fixed workflow board") } if req.Limit != nil { 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 } 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, ownerID 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, ownerID string) error { s.logger.Info("Reordering columns", map[string]interface{}{ "board_id": req.BoardID, "count": len(req.ColumnOrders), }) if _, err := s.getBoardForOwner(ctx, req.BoardID, ownerID); err != nil { return err } return fmt.Errorf("columns are fixed and cannot be reordered") } // CreateCard creates a new card in a column func (s *kanbanService) CreateCard(ctx context.Context, req CreateCardRequest, 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, }) if _, _, err := s.getColumnForOwner(ctx, req.ColumnID, ownerID); err != nil { return nil, 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, ownerID string) (*CardResponse, error) { s.logger.Info("Retrieving card by ID", map[string]interface{}{ "card_id": id, "owner_id": ownerID, }) card, _, _, err := s.getCardForOwner(ctx, id, ownerID) if err != nil { return nil, err } return card.ToResponse(), nil } // UpdateCard updates an existing card 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, "owner_id": ownerID, }) card, _, _, err := s.getCardForOwner(ctx, req.ID, ownerID) 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, ownerID string) error { s.logger.Info("Deleting card", map[string]interface{}{ "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, ownerID string) error { s.logger.Info("Moving card", map[string]interface{}{ "card_id": req.CardID, "column_id": req.ColumnID, "new_order": req.NewOrder, "owner_id": ownerID, }) card, currentColumn, board, err := s.getCardForOwner(ctx, req.CardID, ownerID) if err != nil { 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, 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) } // 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.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] }