kanban dashboard refactor

This commit is contained in:
Mazyar
2026-05-27 22:06:23 +03:30
parent 42cd0452ce
commit ce2dc7a61b
12 changed files with 457 additions and 176 deletions
+34
View File
@@ -7,6 +7,7 @@ import (
"tm/pkg/authorization" "tm/pkg/authorization"
"tm/pkg/config" "tm/pkg/config"
"tm/pkg/filestore" "tm/pkg/filestore"
"tm/pkg/gorules"
"tm/pkg/hcaptcha" "tm/pkg/hcaptcha"
"tm/pkg/logger" "tm/pkg/logger"
"tm/pkg/mongo" "tm/pkg/mongo"
@@ -359,3 +360,36 @@ func InitAISummarizerStorage(conf AISummarizerConfig, log logger.Logger) *ai_sum
return storage return storage
} }
// InitGoRulesClient initializes the GoRules SDK client.
func InitGoRulesClient(conf GoRulesConfig, log logger.Logger) gorules.Client {
if conf.BaseURL == "" || conf.Token == "" || conf.RuleID == "" {
log.Warn("GoRules client not configured, falling back to static Kanban statuses", map[string]interface{}{})
return nil
}
timeout := conf.Timeout
if timeout <= 0 {
timeout = 10 * time.Second
}
client, err := gorules.New(gorules.Config{
BaseURL: conf.BaseURL,
Token: conf.Token,
Timeout: timeout,
}, log)
if err != nil {
log.Error("Failed to initialize GoRules client", map[string]interface{}{
"error": err.Error(),
})
return nil
}
log.Info("GoRules client initialized successfully", map[string]interface{}{
"base_url": conf.BaseURL,
"timeout": timeout,
"rule_id": conf.RuleID,
})
return client
}
+9
View File
@@ -22,6 +22,7 @@ type Config struct {
CustomerAuth AuthConfig CustomerAuth AuthConfig
DocumentScraper DocumentScraperConfig DocumentScraper DocumentScraperConfig
AISummarizer AISummarizerConfig AISummarizer AISummarizerConfig
GoRules GoRulesConfig
} }
type ScraperConfig struct { type ScraperConfig struct {
@@ -77,3 +78,11 @@ type AISummarizerConfig struct {
MinioRegion string `env:"AI_SUMMARIZER_MINIO_REGION" envDefault:"us-east-1"` MinioRegion string `env:"AI_SUMMARIZER_MINIO_REGION" envDefault:"us-east-1"`
MinioBucket string `env:"AI_SUMMARIZER_MINIO_BUCKET" envDefault:"opplens-documents"` MinioBucket string `env:"AI_SUMMARIZER_MINIO_BUCKET" envDefault:"opplens-documents"`
} }
// GoRulesConfig holds configuration for the GoRules status engine.
type GoRulesConfig struct {
BaseURL string `env:"GORULES_BASE_URL"`
Token string `env:"GORULES_TOKEN"`
RuleID string `env:"GORULES_RULE_ID"`
Timeout time.Duration `env:"GORULES_TIMEOUT"`
}
+3 -2
View File
@@ -177,6 +177,7 @@ func main() {
// Initialize authorization service // Initialize authorization service
userAuthService := bootstrap.InitAuthorizationService(conf.UserAuth.JWT, redisClient, logger) userAuthService := bootstrap.InitAuthorizationService(conf.UserAuth.JWT, redisClient, logger)
customerAuthService := bootstrap.InitAuthorizationService(conf.CustomerAuth.JWT, redisClient, logger) customerAuthService := bootstrap.InitAuthorizationService(conf.CustomerAuth.JWT, redisClient, logger)
goRulesClient := bootstrap.InitGoRulesClient(conf.GoRules, logger)
// Initialize repositories with MongoDB connection manager // Initialize repositories with MongoDB connection manager
userRepository := user.NewRepository(mongoManager, logger) userRepository := user.NewRepository(mongoManager, logger)
@@ -216,7 +217,7 @@ func main() {
notificationService := notification.NewService(notificationSDK, notificationRepository, userService, customerService, logger) notificationService := notification.NewService(notificationSDK, notificationRepository, userService, customerService, logger)
contactService := contact.NewService(contactRepository, logger, notificationSDK) contactService := contact.NewService(contactRepository, logger, notificationSDK)
cmsService := cms.NewService(cmsRepository, logger) cmsService := cms.NewService(cmsRepository, logger)
kanbanService := kanban.NewService(boardRepository, columnRepository, cardRepository, logger) kanbanService := kanban.NewService(boardRepository, columnRepository, cardRepository, goRulesClient, conf.GoRules.RuleID, logger)
documentScraperService := document_scraper.NewService(tenderRepository, logger) documentScraperService := document_scraper.NewService(tenderRepository, logger)
dashboardRepository := dashboard.NewRepository(mongoManager, logger) dashboardRepository := dashboard.NewRepository(mongoManager, logger)
dashboardService := dashboard.NewService(dashboardRepository, logger) dashboardService := dashboard.NewService(dashboardRepository, logger)
@@ -252,7 +253,7 @@ func main() {
// Register routes // Register routes
router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, kanbanHandler, fileStoreHandler, dashboardHandler, xssPolicy) router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, kanbanHandler, fileStoreHandler, dashboardHandler, xssPolicy)
router.RegisterPublicRoutes(e, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, companyHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, fileStoreHandler, xssPolicy) router.RegisterPublicRoutes(e, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, companyHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, kanbanHandler, fileStoreHandler, xssPolicy)
router.RegisterDocumentScraperRoutes(e, documentScraperHandler, conf.DocumentScraper.APIKey) router.RegisterDocumentScraperRoutes(e, documentScraperHandler, conf.DocumentScraper.APIKey)
// Start server // Start server
+8 -1
View File
@@ -221,7 +221,7 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler
} }
} }
func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, companyHandler *company.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, fileStoreHandler *filestore.Handler, xssPolicy *security.XSSPolicy) { func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, companyHandler *company.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, kanbanHandler *kanban.Handler, fileStoreHandler *filestore.Handler, xssPolicy *security.XSSPolicy) {
v1 := e.Group("/api/v1") v1 := e.Group("/api/v1")
customerGP := v1.Group("/profile") customerGP := v1.Group("/profile")
@@ -318,6 +318,13 @@ func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tende
cmsGP.GET("/:key", cmsHandler.GetByKey) cmsGP.GET("/:key", cmsHandler.GetByKey)
} }
// Kanban (mobile bid workflow board)
kanbanGP := v1.Group("/kanban")
{
kanbanGP.Use(customerHandler.AuthMiddleware())
kanbanHandler.RegisterRoutes(kanbanGP)
}
// File Store Routes // File Store Routes
publicFilesGP := v1.Group("/files") publicFilesGP := v1.Group("/files")
{ {
+2 -1
View File
@@ -1,6 +1,6 @@
module tm module tm
go 1.23.0 go 1.24
toolchain go1.24.4 toolchain go1.24.4
@@ -18,6 +18,7 @@ require (
go.uber.org/zap v1.26.0 go.uber.org/zap v1.26.0
golang.org/x/crypto v0.40.0 golang.org/x/crypto v0.40.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
repo.ravanertebat.com/k.khodayari/gorulessdk v1.0.10
) )
require ( require (
+2
View File
@@ -163,3 +163,5 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
repo.ravanertebat.com/k.khodayari/gorulessdk v1.0.10 h1:xxNnEyXr6v9wWb3nv2YpRDJ+2kEX7s588RdD6mGqwWI=
repo.ravanertebat.com/k.khodayari/gorulessdk v1.0.10/go.mod h1:SqAyTO7MZgI20+E1/xedUGHGWU5uyphZ2izHx62Rsr0=
+14
View File
@@ -0,0 +1,14 @@
package kanban
import "github.com/labstack/echo/v4"
// ownerIDFromContext returns the board owner ID from admin (user_id) or mobile (customer_id) auth.
func ownerIDFromContext(c echo.Context) (string, bool) {
if id, ok := c.Get("customer_id").(string); ok && id != "" {
return id, true
}
if id, ok := c.Get("user_id").(string); ok && id != "" {
return id, true
}
return "", false
}
+19 -17
View File
@@ -7,9 +7,9 @@ import (
// Board represents a kanban board // Board represents a kanban board
type Board struct { type Board struct {
mongo.Model `bson:",inline"` mongo.Model `bson:",inline"`
Name string `bson:"name" json:"name"` Name string `bson:"name" json:"name"`
Description string `bson:"description" json:"description"` Description string `bson:"description" json:"description"`
Columns []Column `bson:"columns" json:"columns"` Columns []Column `bson:"columns" json:"columns"`
IsDefault bool `bson:"is_default" json:"is_default"` // Whether this is the default board IsDefault bool `bson:"is_default" json:"is_default"` // Whether this is the default board
UserID string `bson:"user_id" json:"user_id"` // User who owns this board UserID string `bson:"user_id" json:"user_id"` // User who owns this board
@@ -17,24 +17,26 @@ type Board struct {
// Column represents a column on a kanban board // Column represents a column on a kanban board
type Column struct { type Column struct {
mongo.Model `bson:",inline"` mongo.Model `bson:",inline"`
BoardID string `bson:"board_id" json:"board_id"` BoardID string `bson:"board_id" json:"board_id"`
Name string `bson:"name" json:"name"` Name string `bson:"name" json:"name"`
Order int `bson:"order" json:"order"` // Display order in the board Status string `bson:"status" json:"status"`
Color string `bson:"color" json:"color"` // Hex color code for the column StatusCategory string `bson:"status_category" json:"status_category"`
Limit *int `bson:"limit,omitempty" json:"limit,omitempty"` // WIP limit (optional) Order int `bson:"order" json:"order"` // Display order in the board
Color string `bson:"color" json:"color"` // Hex color code for the column
Limit *int `bson:"limit,omitempty" json:"limit,omitempty"` // WIP limit (optional)
} }
// Card represents a card on a kanban board (references a tender) // Card represents a card on a kanban board (references a tender)
type Card struct { type Card struct {
mongo.Model `bson:",inline"` mongo.Model `bson:",inline"`
ColumnID string `bson:"column_id" json:"column_id"` ColumnID string `bson:"column_id" json:"column_id"`
TenderID string `bson:"tender_id" json:"tender_id"` // Reference to the tender TenderID string `bson:"tender_id" json:"tender_id"` // Reference to the tender
Title string `bson:"title" json:"title"` // Cached title from tender Title string `bson:"title" json:"title"` // Cached title from tender
Order int `bson:"order" json:"order"` // Position within the column Order int `bson:"order" json:"order"` // Position within the column
Priority CardPriority `bson:"priority" json:"priority"` Priority CardPriority `bson:"priority" json:"priority"`
Labels []string `bson:"labels,omitempty" json:"labels,omitempty"` Labels []string `bson:"labels,omitempty" json:"labels,omitempty"`
DueDate *int64 `bson:"due_date,omitempty" json:"due_date,omitempty"` // Unix timestamp DueDate *int64 `bson:"due_date,omitempty" json:"due_date,omitempty"` // Unix timestamp
} }
// CardPriority represents the priority level of a card // CardPriority represents the priority level of a card
+52 -48
View File
@@ -29,22 +29,22 @@ type CreateColumnRequest struct {
// UpdateColumnRequest represents a request to update an existing column // UpdateColumnRequest represents a request to update an existing column
type UpdateColumnRequest struct { type UpdateColumnRequest struct {
ID string `json:"id" valid:"required~Column ID is required"` ID string `json:"id" valid:"required~Column ID is required"`
Name *string `json:"name,omitempty" valid:"stringlength(1|50)~Column name must be between 1 and 50 characters"` Name *string `json:"name,omitempty" valid:"stringlength(1|50)~Column name must be between 1 and 50 characters"`
Color *string `json:"color,omitempty" valid:"hexcolor~Color must be a valid hex color"` Color *string `json:"color,omitempty" valid:"hexcolor~Color must be a valid hex color"`
Order *int `json:"order,omitempty"` Order *int `json:"order,omitempty"`
Limit *int `json:"limit,omitempty" valid:"range(1|100)~WIP limit must be between 1 and 100"` Limit *int `json:"limit,omitempty" valid:"range(1|100)~WIP limit must be between 1 and 100"`
} }
// CreateCardRequest represents a request to create a new card // CreateCardRequest represents a request to create a new card
type CreateCardRequest struct { type CreateCardRequest struct {
ColumnID string `json:"column_id" valid:"required~Column ID is required"` ColumnID string `json:"column_id" valid:"required~Column ID is required"`
TenderID string `json:"tender_id" valid:"required~Tender ID is required"` TenderID string `json:"tender_id" valid:"required~Tender ID is required"`
Title string `json:"title" valid:"required~Title is required,stringlength(1|200)~Title must be between 1 and 200 characters"` Title string `json:"title" valid:"required~Title is required,stringlength(1|200)~Title must be between 1 and 200 characters"`
Order int `json:"order"` Order int `json:"order"`
Priority CardPriority `json:"priority"` Priority CardPriority `json:"priority"`
Labels []string `json:"labels,omitempty"` Labels []string `json:"labels,omitempty"`
DueDate *int64 `json:"due_date,omitempty"` // Unix timestamp DueDate *int64 `json:"due_date,omitempty"` // Unix timestamp
} }
// UpdateCardRequest represents a request to update an existing card // UpdateCardRequest represents a request to update an existing card
@@ -58,15 +58,15 @@ type UpdateCardRequest struct {
// MoveCardRequest represents a request to move a card to a different column // MoveCardRequest represents a request to move a card to a different column
type MoveCardRequest struct { type MoveCardRequest struct {
CardID string `json:"card_id" valid:"required~Card ID is required"` CardID string `json:"card_id" valid:"required~Card ID is required"`
ColumnID string `json:"column_id" valid:"required~Column ID is required"` ColumnID string `json:"column_id" valid:"required~Column ID is required"`
NewOrder int `json:"new_order"` NewOrder int `json:"new_order"`
} }
// ReorderCardsRequest represents a request to reorder cards within a column // ReorderCardsRequest represents a request to reorder cards within a column
type ReorderCardsRequest struct { type ReorderCardsRequest struct {
ColumnID string `json:"column_id" valid:"required~Column ID is required"` ColumnID string `json:"column_id" valid:"required~Column ID is required"`
CardOrders map[string]int `json:"card_orders" valid:"required~Card orders are required"` CardOrders map[string]int `json:"card_orders" valid:"required~Card orders are required"`
} }
// ReorderColumnsRequest represents a request to reorder columns within a board // ReorderColumnsRequest represents a request to reorder columns within a board
@@ -77,29 +77,31 @@ type ReorderColumnsRequest struct {
// BoardResponse represents a board in API responses // BoardResponse represents a board in API responses
type BoardResponse struct { type BoardResponse struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Columns []ColumnResponse `json:"columns"` Columns []ColumnResponse `json:"columns"`
IsDefault bool `json:"is_default"` IsDefault bool `json:"is_default"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
} }
// ColumnResponse represents a column in API responses // ColumnResponse represents a column in API responses
type ColumnResponse struct { type ColumnResponse struct {
ID string `json:"id"` ID string `json:"id"`
BoardID string `json:"board_id"` BoardID string `json:"board_id"`
Name string `json:"name"` Name string `json:"name"`
Order int `json:"order"` Status string `json:"status"`
Color string `json:"color"` StatusCategory string `json:"status_category"`
Limit *int `json:"limit,omitempty"` Order int `json:"order"`
Cards []CardResponse `json:"cards"` Color string `json:"color"`
CardCount int `json:"card_count"` Limit *int `json:"limit,omitempty"`
IsOverLimit bool `json:"is_over_limit"` Cards []CardResponse `json:"cards"`
CreatedAt int64 `json:"created_at"` CardCount int `json:"card_count"`
UpdatedAt int64 `json:"updated_at"` IsOverLimit bool `json:"is_over_limit"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
} }
// CardResponse represents a card in API responses // CardResponse represents a card in API responses
@@ -124,7 +126,7 @@ type BoardsListResponse struct {
// BoardDetailResponse represents detailed board response with full column and card data // BoardDetailResponse represents detailed board response with full column and card data
type BoardDetailResponse struct { type BoardDetailResponse struct {
Board BoardResponse `json:"board"` Board BoardResponse `json:"board"`
Meta *response.Meta `json:"-"` Meta *response.Meta `json:"-"`
} }
@@ -145,17 +147,19 @@ func (b *Board) ToResponse() *BoardResponse {
// ToColumnResponse converts a Column entity to ColumnResponse // ToColumnResponse converts a Column entity to ColumnResponse
func (c *Column) ToResponse() *ColumnResponse { func (c *Column) ToResponse() *ColumnResponse {
return &ColumnResponse{ return &ColumnResponse{
ID: c.ID.Hex(), ID: c.ID.Hex(),
BoardID: c.BoardID, BoardID: c.BoardID,
Name: c.Name, Name: c.Name,
Order: c.Order, Status: c.Status,
Color: c.Color, StatusCategory: c.StatusCategory,
Limit: c.Limit, Order: c.Order,
Cards: make([]CardResponse, 0), // Will be populated by service Color: c.Color,
CardCount: 0, // Will be populated by service Limit: c.Limit,
IsOverLimit: false, // Will be calculated by service Cards: make([]CardResponse, 0), // Will be populated by service
CreatedAt: c.CreatedAt, CardCount: 0, // Will be populated by service
UpdatedAt: c.UpdatedAt, IsOverLimit: false, // Will be calculated by service
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
} }
} }
+23 -30
View File
@@ -20,32 +20,26 @@ func NewHandler(service Service) *Handler {
} }
} }
// RegisterRoutes registers kanban routes // RegisterRoutes registers kanban routes on the provided group (e.g. /admin/v1/kanban).
func (h *Handler) RegisterRoutes(router *echo.Group) { func (h *Handler) RegisterRoutes(router *echo.Group) {
kanban := router.Group("/kanban") router.POST("/boards", h.CreateBoard)
{ router.GET("/boards", h.GetBoardsByUser)
// Board routes router.GET("/boards/default", h.GetDefaultBoard)
kanban.POST("/boards", h.CreateBoard) router.GET("/boards/:id", h.GetBoardByID)
kanban.GET("/boards", h.GetBoardsByUser) router.PUT("/boards/:id", h.UpdateBoard)
kanban.GET("/boards/default", h.GetDefaultBoard) router.DELETE("/boards/:id", h.DeleteBoard)
kanban.GET("/boards/:id", h.GetBoardByID)
kanban.PUT("/boards/:id", h.UpdateBoard)
kanban.DELETE("/boards/:id", h.DeleteBoard)
// Column routes router.POST("/columns", h.CreateColumn)
kanban.POST("/columns", h.CreateColumn) router.PUT("/columns/:id", h.UpdateColumn)
kanban.PUT("/columns/:id", h.UpdateColumn) router.DELETE("/columns/:id", h.DeleteColumn)
kanban.DELETE("/columns/:id", h.DeleteColumn) router.PUT("/columns/reorder", h.ReorderColumns)
kanban.PUT("/columns/reorder", h.ReorderColumns)
// Card routes router.POST("/cards", h.CreateCard)
kanban.POST("/cards", h.CreateCard) router.GET("/cards/:id", h.GetCardByID)
kanban.GET("/cards/:id", h.GetCardByID) router.PUT("/cards/:id", h.UpdateCard)
kanban.PUT("/cards/:id", h.UpdateCard) router.DELETE("/cards/:id", h.DeleteCard)
kanban.DELETE("/cards/:id", h.DeleteCard) router.PUT("/cards/move", h.MoveCard)
kanban.PUT("/cards/move", h.MoveCard) router.PUT("/cards/reorder", h.ReorderCards)
kanban.PUT("/cards/reorder", h.ReorderCards)
}
} }
// CreateBoard creates a new kanban board // CreateBoard creates a new kanban board
@@ -65,12 +59,11 @@ func (h *Handler) CreateBoard(c echo.Context) error {
return response.ValidationError(c, "Invalid request data", err.Error()) return response.ValidationError(c, "Invalid request data", err.Error())
} }
// Get user ID from context (assuming auth middleware sets this) ownerID, exists := ownerIDFromContext(c)
userID, exists := c.Get("user_id").(string)
if !exists { if !exists {
return response.Unauthorized(c, "User not authenticated") return response.Unauthorized(c, "User not authenticated")
} }
req.UserID = userID req.UserID = ownerID
board, err := h.service.CreateBoard(c.Request().Context(), req) board, err := h.service.CreateBoard(c.Request().Context(), req)
if err != nil { if err != nil {
@@ -112,12 +105,12 @@ func (h *Handler) GetBoardByID(c echo.Context) error {
// @Failure 500 {object} response.Response // @Failure 500 {object} response.Response
// @Router /kanban/boards [get] // @Router /kanban/boards [get]
func (h *Handler) GetBoardsByUser(c echo.Context) error { func (h *Handler) GetBoardsByUser(c echo.Context) error {
userID, exists := c.Get("user_id").(string) ownerID, exists := ownerIDFromContext(c)
if !exists { if !exists {
return response.Unauthorized(c, "User not authenticated") return response.Unauthorized(c, "User not authenticated")
} }
boards, err := h.service.GetBoardsByUserID(c.Request().Context(), userID) boards, err := h.service.GetBoardsByUserID(c.Request().Context(), ownerID)
if err != nil { if err != nil {
return response.InternalServerError(c, "Failed to get boards") return response.InternalServerError(c, "Failed to get boards")
} }
@@ -136,12 +129,12 @@ func (h *Handler) GetBoardsByUser(c echo.Context) error {
// @Failure 500 {object} response.Response // @Failure 500 {object} response.Response
// @Router /kanban/boards/default [get] // @Router /kanban/boards/default [get]
func (h *Handler) GetDefaultBoard(c echo.Context) error { func (h *Handler) GetDefaultBoard(c echo.Context) error {
userID, exists := c.Get("user_id").(string) ownerID, exists := ownerIDFromContext(c)
if !exists { if !exists {
return response.Unauthorized(c, "User not authenticated") return response.Unauthorized(c, "User not authenticated")
} }
board, err := h.service.GetDefaultBoard(c.Request().Context(), userID) board, err := h.service.GetDefaultBoard(c.Request().Context(), ownerID)
if err != nil { if err != nil {
return response.InternalServerError(c, "Failed to get default board") return response.InternalServerError(c, "Failed to get default board")
} }
+206 -77
View File
@@ -4,6 +4,8 @@ import (
"context" "context"
"fmt" "fmt"
"sort" "sort"
"strings"
"tm/pkg/gorules"
"tm/pkg/logger" "tm/pkg/logger"
) )
@@ -37,24 +39,30 @@ type kanbanService struct {
boardRepository BoardRepository boardRepository BoardRepository
columnRepository ColumnRepository columnRepository ColumnRepository
cardRepository CardRepository cardRepository CardRepository
goRulesClient gorules.Client
goRulesRuleID string
logger logger.Logger logger logger.Logger
} }
// NewService creates a new kanban service // 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{ return &kanbanService{
boardRepository: boardRepo, boardRepository: boardRepo,
columnRepository: columnRepo, columnRepository: columnRepo,
cardRepository: cardRepo, cardRepository: cardRepo,
goRulesClient: goRulesClient,
goRulesRuleID: goRulesRuleID,
logger: logger, logger: logger,
} }
} }
var defaultColumnColors = []string{"#64748B", "#0EA5E9", "#22C55E", "#A855F7", "#F59E0B"}
// CreateBoard creates a new kanban board // CreateBoard creates a new kanban board
func (s *kanbanService) CreateBoard(ctx context.Context, req CreateBoardRequest) (*BoardResponse, error) { func (s *kanbanService) CreateBoard(ctx context.Context, req CreateBoardRequest) (*BoardResponse, error) {
s.logger.Info("Creating new board", map[string]interface{}{ s.logger.Info("Creating new board", map[string]interface{}{
"name": req.Name, "name": req.Name,
"user_id": req.UserID, "user_id": req.UserID,
"is_default": req.IsDefault, "is_default": req.IsDefault,
}) })
@@ -80,6 +88,10 @@ func (s *kanbanService) CreateBoard(ctx context.Context, req CreateBoardRequest)
return nil, err return nil, err
} }
if err := s.ensureDefaultColumns(ctx, board.ID.Hex()); err != nil {
return nil, err
}
return board.ToResponse(), nil return board.ToResponse(), nil
} }
@@ -94,6 +106,10 @@ func (s *kanbanService) GetBoardByID(ctx context.Context, id string) (*BoardDeta
return nil, err return nil, err
} }
if err := s.ensureDefaultColumns(ctx, id); err != nil {
return nil, err
}
boardResponse := board.ToResponse() boardResponse := board.ToResponse()
// Load columns with cards // Load columns with cards
@@ -258,19 +274,7 @@ func (s *kanbanService) CreateColumn(ctx context.Context, req CreateColumnReques
"order": req.Order, "order": req.Order,
}) })
column := &Column{ return nil, fmt.Errorf("kanban board uses a fixed bid workflow; columns are created automatically from GoRules")
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
} }
// UpdateColumn updates an existing column // UpdateColumn updates an existing column
@@ -286,7 +290,7 @@ func (s *kanbanService) UpdateColumn(ctx context.Context, req UpdateColumnReques
// Update fields // Update fields
if req.Name != nil { 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 { if req.Color != nil {
column.Color = *req.Color column.Color = *req.Color
@@ -311,61 +315,7 @@ func (s *kanbanService) DeleteColumn(ctx context.Context, id string) error {
"column_id": id, "column_id": id,
}) })
column, err := s.columnRepository.GetColumnByID(ctx, id) return fmt.Errorf("columns are fixed and cannot be deleted")
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)
} }
// ReorderColumns reorders columns within a board // ReorderColumns reorders columns within a board
@@ -375,7 +325,7 @@ func (s *kanbanService) ReorderColumns(ctx context.Context, req ReorderColumnsRe
"count": len(req.ColumnOrders), "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 // 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 // MoveCard moves a card to a different column
func (s *kanbanService) MoveCard(ctx context.Context, req MoveCardRequest) error { func (s *kanbanService) MoveCard(ctx context.Context, req MoveCardRequest) error {
s.logger.Info("Moving card", map[string]interface{}{ s.logger.Info("Moving card", map[string]interface{}{
"card_id": req.CardID, "card_id": req.CardID,
"column_id": req.ColumnID, "column_id": req.ColumnID,
"new_order": req.NewOrder, "new_order": req.NewOrder,
}) })
// Validate that the target column exists // Validate that the target column exists
_, err := s.columnRepository.GetColumnByID(ctx, req.ColumnID) targetColumn, err := s.columnRepository.GetColumnByID(ctx, req.ColumnID)
if err != nil { if err != nil {
return fmt.Errorf("target column not found: %w", err) 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) 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 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]
}
+85
View File
@@ -0,0 +1,85 @@
package gorules
import (
"context"
"errors"
"strings"
"time"
"tm/pkg/logger"
"repo.ravanertebat.com/k.khodayari/gorulessdk"
)
const (
showAvailableStatusAction = gorulessdk.ShowAvailableStatus
)
// Config contains GoRules client configuration.
type Config struct {
BaseURL string
Token string
Timeout time.Duration
}
// StatusWithCategory contains status and category values returned by GoRules.
type StatusWithCategory struct {
Status string `json:"status"`
StatusCategory string `json:"status_category"`
}
// Client evaluates available statuses against GoRules.
type Client interface {
EvaluateAvailableStatuses(ctx context.Context, currentStatus string, ruleID string) ([]StatusWithCategory, error)
}
type sdkClient struct {
client *gorulessdk.Client
logger logger.Logger
}
// New creates a new GoRules client.
func New(cfg Config, log logger.Logger) (Client, error) {
if strings.TrimSpace(cfg.BaseURL) == "" {
return nil, errors.New("gorules base url is required")
}
if strings.TrimSpace(cfg.Token) == "" {
return nil, errors.New("gorules token is required")
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 10 * time.Second
}
client, err := gorulessdk.NewClient(cfg.BaseURL, cfg.Token, gorulessdk.WithTimeout(timeout))
if err != nil {
return nil, err
}
return &sdkClient{
client: client,
logger: log,
}, nil
}
func (s *sdkClient) EvaluateAvailableStatuses(ctx context.Context, currentStatus string, ruleID string) ([]StatusWithCategory, error) {
input := gorulessdk.Input{
Status: currentStatus,
Action: showAvailableStatusAction,
}
resp, err := s.client.Evaluate(ctx, ruleID, &input)
if err != nil {
return nil, err
}
available := resp.AvailableStatuses()
statuses := make([]StatusWithCategory, 0, len(available))
for _, item := range available {
statuses = append(statuses, StatusWithCategory{
Status: item.Status,
StatusCategory: item.Category,
})
}
return statuses, nil
}