Files
tm_back/internal/kanban/service.go
T
2026-05-27 22:06:23 +03:30

654 lines
18 KiB
Go

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]
}