754 lines
22 KiB
Go
754 lines
22 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, 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]
|
|
}
|