Merge pull request 'kanban dashboard refactor' (#21) from kanban into develop

Reviewed-on: https://repo.ravanertebat.com/TM/tm_back/pulls/21
Reviewed-by: Hadi Barzegar <barzagarhadi@gmail.com>
This commit is contained in:
m.nazemi
2026-05-30 14:19:24 +03:30
15 changed files with 764 additions and 265 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
@@ -178,6 +178,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)
@@ -218,7 +219,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)
@@ -254,7 +255,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
+9 -2
View File
@@ -192,7 +192,7 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler
cmsGP.DELETE("/:id", cmsHandler.Delete) cmsGP.DELETE("/:id", cmsHandler.Delete)
} }
// Kanban Routes // Kanban Routes — paths are /admin/v1/kanban/* (RegisterRoutes expects this /kanban group)
kanbanGP := adminV1.Group("/kanban") kanbanGP := adminV1.Group("/kanban")
{ {
kanbanGP.Use(userHandler.AuthMiddleware()) kanbanGP.Use(userHandler.AuthMiddleware())
@@ -223,7 +223,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")
@@ -320,6 +320,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) — paths are /api/v1/kanban/*
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
}
+2
View File
@@ -20,6 +20,8 @@ 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"`
Status string `bson:"status" json:"status"`
StatusCategory string `bson:"status_category" json:"status_category"`
Order int `bson:"order" json:"order"` // Display order in the board Order int `bson:"order" json:"order"` // Display order in the board
Color string `bson:"color" json:"color"` // Hex color code for the column Color string `bson:"color" json:"color"` // Hex color code for the column
Limit *int `bson:"limit,omitempty" json:"limit,omitempty"` // WIP limit (optional) Limit *int `bson:"limit,omitempty" json:"limit,omitempty"` // WIP limit (optional)
+11
View File
@@ -0,0 +1,11 @@
package kanban
import "errors"
var (
// ErrAccessDenied is returned when the resource exists but does not belong to the caller.
ErrAccessDenied = errors.New("kanban: access denied")
// ErrWorkflowUnavailable is returned when GoRules is configured but cannot supply workflow data.
ErrWorkflowUnavailable = errors.New("kanban: workflow rules unavailable")
)
+4
View File
@@ -92,6 +92,8 @@ 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"`
Status string `json:"status"`
StatusCategory string `json:"status_category"`
Order int `json:"order"` Order int `json:"order"`
Color string `json:"color"` Color string `json:"color"`
Limit *int `json:"limit,omitempty"` Limit *int `json:"limit,omitempty"`
@@ -148,6 +150,8 @@ func (c *Column) ToResponse() *ColumnResponse {
ID: c.ID.Hex(), ID: c.ID.Hex(),
BoardID: c.BoardID, BoardID: c.BoardID,
Name: c.Name, Name: c.Name,
Status: c.Status,
StatusCategory: c.StatusCategory,
Order: c.Order, Order: c.Order,
Color: c.Color, Color: c.Color,
Limit: c.Limit, Limit: c.Limit,
+129 -69
View File
@@ -20,32 +20,28 @@ func NewHandler(service Service) *Handler {
} }
} }
// RegisterRoutes registers kanban routes // RegisterRoutes registers kanban routes on the Echo group passed by the caller.
// The caller MUST mount a /kanban subgroup first (e.g. adminV1.Group("/kanban") or v1.Group("/kanban"))
// so paths resolve to .../kanban/boards, not .../boards.
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,16 +61,15 @@ 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 := handleServiceError(c, err, "Failed to create board"); err != nil {
return response.InternalServerError(c, "Failed to create board") return err
} }
return response.Created(c, board, "Board created successfully") return response.Created(c, board, "Board created successfully")
@@ -94,9 +89,14 @@ func (h *Handler) CreateBoard(c echo.Context) error {
func (h *Handler) GetBoardByID(c echo.Context) error { func (h *Handler) GetBoardByID(c echo.Context) error {
id := c.Param("id") id := c.Param("id")
board, err := h.service.GetBoardByID(c.Request().Context(), id) ownerID, exists := ownerIDFromContext(c)
if err != nil { if !exists {
return response.InternalServerError(c, "Failed to get board") return response.Unauthorized(c, "User not authenticated")
}
board, err := h.service.GetBoardByID(c.Request().Context(), id, ownerID)
if err := handleServiceError(c, err, "Failed to get board"); err != nil {
return err
} }
return response.Success(c, board, "Board retrieved successfully") return response.Success(c, board, "Board retrieved successfully")
@@ -112,14 +112,14 @@ 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 := handleServiceError(c, err, "Failed to get boards"); err != nil {
return response.InternalServerError(c, "Failed to get boards") return err
} }
return response.Success(c, boards, "Boards retrieved successfully") return response.Success(c, boards, "Boards retrieved successfully")
@@ -136,14 +136,14 @@ 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 := handleServiceError(c, err, "Failed to get default board"); err != nil {
return response.InternalServerError(c, "Failed to get default board") return err
} }
return response.Success(c, board, "Default board retrieved successfully") return response.Success(c, board, "Default board retrieved successfully")
@@ -170,9 +170,14 @@ func (h *Handler) UpdateBoard(c echo.Context) error {
req.ID = c.Param("id") req.ID = c.Param("id")
board, err := h.service.UpdateBoard(c.Request().Context(), req) ownerID, exists := ownerIDFromContext(c)
if err != nil { if !exists {
return response.InternalServerError(c, "Failed to update board") return response.Unauthorized(c, "User not authenticated")
}
board, err := h.service.UpdateBoard(c.Request().Context(), req, ownerID)
if err := handleServiceError(c, err, "Failed to update board"); err != nil {
return err
} }
return response.Success(c, board, "Board updated successfully") return response.Success(c, board, "Board updated successfully")
@@ -192,8 +197,13 @@ func (h *Handler) UpdateBoard(c echo.Context) error {
func (h *Handler) DeleteBoard(c echo.Context) error { func (h *Handler) DeleteBoard(c echo.Context) error {
id := c.Param("id") id := c.Param("id")
if err := h.service.DeleteBoard(c.Request().Context(), id); err != nil { ownerID, exists := ownerIDFromContext(c)
return response.InternalServerError(c, "Failed to delete board") if !exists {
return response.Unauthorized(c, "User not authenticated")
}
if err := h.service.DeleteBoard(c.Request().Context(), id, ownerID); err != nil {
return handleServiceError(c, err, "Failed to delete board")
} }
return c.NoContent(http.StatusNoContent) return c.NoContent(http.StatusNoContent)
@@ -216,9 +226,14 @@ func (h *Handler) CreateColumn(c echo.Context) error {
return response.ValidationError(c, "Invalid request data", err.Error()) return response.ValidationError(c, "Invalid request data", err.Error())
} }
column, err := h.service.CreateColumn(c.Request().Context(), req) ownerID, exists := ownerIDFromContext(c)
if err != nil { if !exists {
return response.InternalServerError(c, "Failed to create column") return response.Unauthorized(c, "User not authenticated")
}
column, err := h.service.CreateColumn(c.Request().Context(), req, ownerID)
if err := handleServiceError(c, err, "Failed to create column"); err != nil {
return err
} }
return response.Created(c, column, "Column created successfully") return response.Created(c, column, "Column created successfully")
@@ -245,9 +260,14 @@ func (h *Handler) UpdateColumn(c echo.Context) error {
req.ID = c.Param("id") req.ID = c.Param("id")
column, err := h.service.UpdateColumn(c.Request().Context(), req) ownerID, exists := ownerIDFromContext(c)
if err != nil { if !exists {
return response.InternalServerError(c, "Failed to update column") return response.Unauthorized(c, "User not authenticated")
}
column, err := h.service.UpdateColumn(c.Request().Context(), req, ownerID)
if err := handleServiceError(c, err, "Failed to update column"); err != nil {
return err
} }
return response.Success(c, column, "Column updated successfully") return response.Success(c, column, "Column updated successfully")
@@ -267,8 +287,13 @@ func (h *Handler) UpdateColumn(c echo.Context) error {
func (h *Handler) DeleteColumn(c echo.Context) error { func (h *Handler) DeleteColumn(c echo.Context) error {
id := c.Param("id") id := c.Param("id")
if err := h.service.DeleteColumn(c.Request().Context(), id); err != nil { ownerID, exists := ownerIDFromContext(c)
return response.InternalServerError(c, "Failed to delete column") if !exists {
return response.Unauthorized(c, "User not authenticated")
}
if err := h.service.DeleteColumn(c.Request().Context(), id, ownerID); err != nil {
return handleServiceError(c, err, "Failed to delete column")
} }
return c.NoContent(http.StatusNoContent) return c.NoContent(http.StatusNoContent)
@@ -291,8 +316,13 @@ func (h *Handler) ReorderColumns(c echo.Context) error {
return response.ValidationError(c, "Invalid request data", err.Error()) return response.ValidationError(c, "Invalid request data", err.Error())
} }
if err := h.service.ReorderColumns(c.Request().Context(), req); err != nil { ownerID, exists := ownerIDFromContext(c)
return response.InternalServerError(c, "Failed to reorder columns") if !exists {
return response.Unauthorized(c, "User not authenticated")
}
if err := h.service.ReorderColumns(c.Request().Context(), req, ownerID); err != nil {
return handleServiceError(c, err, "Failed to reorder columns")
} }
return response.Success(c, map[string]string{"message": "Columns reordered successfully"}, "Columns reordered successfully") return response.Success(c, map[string]string{"message": "Columns reordered successfully"}, "Columns reordered successfully")
@@ -334,9 +364,14 @@ func (h *Handler) CreateCard(c echo.Context) error {
} }
} }
card, err := h.service.CreateCard(c.Request().Context(), req) ownerID, exists := ownerIDFromContext(c)
if err != nil { if !exists {
return response.InternalServerError(c, "Failed to create card") return response.Unauthorized(c, "User not authenticated")
}
card, err := h.service.CreateCard(c.Request().Context(), req, ownerID)
if err := handleServiceError(c, err, "Failed to create card"); err != nil {
return err
} }
return response.Created(c, card, "Card created successfully") return response.Created(c, card, "Card created successfully")
@@ -356,9 +391,14 @@ func (h *Handler) CreateCard(c echo.Context) error {
func (h *Handler) GetCardByID(c echo.Context) error { func (h *Handler) GetCardByID(c echo.Context) error {
id := c.Param("id") id := c.Param("id")
card, err := h.service.GetCardByID(c.Request().Context(), id) ownerID, exists := ownerIDFromContext(c)
if err != nil { if !exists {
return response.InternalServerError(c, "Failed to get card") return response.Unauthorized(c, "User not authenticated")
}
card, err := h.service.GetCardByID(c.Request().Context(), id, ownerID)
if err := handleServiceError(c, err, "Failed to get card"); err != nil {
return err
} }
return response.Success(c, card, "Card retrieved successfully") return response.Success(c, card, "Card retrieved successfully")
@@ -392,9 +432,14 @@ func (h *Handler) UpdateCard(c echo.Context) error {
} }
} }
card, err := h.service.UpdateCard(c.Request().Context(), req) ownerID, exists := ownerIDFromContext(c)
if err != nil { if !exists {
return response.InternalServerError(c, "Failed to update card") return response.Unauthorized(c, "User not authenticated")
}
card, err := h.service.UpdateCard(c.Request().Context(), req, ownerID)
if err := handleServiceError(c, err, "Failed to update card"); err != nil {
return err
} }
return response.Success(c, card, "Card updated successfully") return response.Success(c, card, "Card updated successfully")
@@ -414,8 +459,13 @@ func (h *Handler) UpdateCard(c echo.Context) error {
func (h *Handler) DeleteCard(c echo.Context) error { func (h *Handler) DeleteCard(c echo.Context) error {
id := c.Param("id") id := c.Param("id")
if err := h.service.DeleteCard(c.Request().Context(), id); err != nil { ownerID, exists := ownerIDFromContext(c)
return response.InternalServerError(c, "Failed to delete card") if !exists {
return response.Unauthorized(c, "User not authenticated")
}
if err := h.service.DeleteCard(c.Request().Context(), id, ownerID); err != nil {
return handleServiceError(c, err, "Failed to delete card")
} }
return c.NoContent(http.StatusNoContent) return c.NoContent(http.StatusNoContent)
@@ -439,8 +489,13 @@ func (h *Handler) MoveCard(c echo.Context) error {
return response.ValidationError(c, "Invalid request data", err.Error()) return response.ValidationError(c, "Invalid request data", err.Error())
} }
if err := h.service.MoveCard(c.Request().Context(), req); err != nil { ownerID, exists := ownerIDFromContext(c)
return response.InternalServerError(c, "Failed to move card") if !exists {
return response.Unauthorized(c, "User not authenticated")
}
if err := h.service.MoveCard(c.Request().Context(), req, ownerID); err != nil {
return handleServiceError(c, err, "Failed to move card")
} }
return response.Success(c, map[string]string{"message": "Card moved successfully"}, "Card moved successfully") return response.Success(c, map[string]string{"message": "Card moved successfully"}, "Card moved successfully")
@@ -463,8 +518,13 @@ func (h *Handler) ReorderCards(c echo.Context) error {
return response.ValidationError(c, "Invalid request data", err.Error()) return response.ValidationError(c, "Invalid request data", err.Error())
} }
if err := h.service.ReorderCards(c.Request().Context(), req); err != nil { ownerID, exists := ownerIDFromContext(c)
return response.InternalServerError(c, "Failed to reorder cards") if !exists {
return response.Unauthorized(c, "User not authenticated")
}
if err := h.service.ReorderCards(c.Request().Context(), req, ownerID); err != nil {
return handleServiceError(c, err, "Failed to reorder cards")
} }
return response.Success(c, map[string]string{"message": "Cards reordered successfully"}, "Cards reordered successfully") return response.Success(c, map[string]string{"message": "Cards reordered successfully"}, "Cards reordered successfully")
+40
View File
@@ -0,0 +1,40 @@
package kanban
import (
"errors"
"strings"
"tm/pkg/response"
"github.com/labstack/echo/v4"
)
func handleServiceError(c echo.Context, err error, fallback string) error {
if err == nil {
return nil
}
if errors.Is(err, ErrAccessDenied) {
return response.NotFound(c, "Resource not found")
}
if errors.Is(err, ErrWorkflowUnavailable) {
return response.BadRequest(c, "Workflow rules are temporarily unavailable", err.Error())
}
msg := err.Error()
if strings.Contains(msg, "not allowed") ||
strings.Contains(msg, "cannot be changed") ||
strings.Contains(msg, "cannot be deleted") ||
strings.Contains(msg, "cannot be reordered") ||
strings.Contains(msg, "fixed") ||
strings.Contains(msg, "only column color") {
return response.BadRequest(c, msg, "")
}
if strings.Contains(msg, "not found") {
return response.NotFound(c, "Resource not found")
}
return response.InternalServerError(c, fallback)
}
+342 -113
View File
@@ -4,6 +4,8 @@ import (
"context" "context"
"fmt" "fmt"
"sort" "sort"
"strings"
"tm/pkg/gorules"
"tm/pkg/logger" "tm/pkg/logger"
) )
@@ -11,25 +13,25 @@ import (
type Service interface { type Service interface {
// Board operations // Board operations
CreateBoard(ctx context.Context, req CreateBoardRequest) (*BoardResponse, error) CreateBoard(ctx context.Context, req CreateBoardRequest) (*BoardResponse, error)
GetBoardByID(ctx context.Context, id string) (*BoardDetailResponse, error) GetBoardByID(ctx context.Context, id, ownerID string) (*BoardDetailResponse, error)
GetBoardsByUserID(ctx context.Context, userID string) (*BoardsListResponse, error) GetBoardsByUserID(ctx context.Context, userID string) (*BoardsListResponse, error)
GetDefaultBoard(ctx context.Context, userID string) (*BoardDetailResponse, error) GetDefaultBoard(ctx context.Context, userID string) (*BoardDetailResponse, error)
UpdateBoard(ctx context.Context, req UpdateBoardRequest) (*BoardResponse, error) UpdateBoard(ctx context.Context, req UpdateBoardRequest, ownerID string) (*BoardResponse, error)
DeleteBoard(ctx context.Context, id string) error DeleteBoard(ctx context.Context, id, ownerID string) error
// Column operations // Column operations
CreateColumn(ctx context.Context, req CreateColumnRequest) (*ColumnResponse, error) CreateColumn(ctx context.Context, req CreateColumnRequest, ownerID string) (*ColumnResponse, error)
UpdateColumn(ctx context.Context, req UpdateColumnRequest) (*ColumnResponse, error) UpdateColumn(ctx context.Context, req UpdateColumnRequest, ownerID string) (*ColumnResponse, error)
DeleteColumn(ctx context.Context, id string) error DeleteColumn(ctx context.Context, id, ownerID string) error
ReorderColumns(ctx context.Context, req ReorderColumnsRequest) error ReorderColumns(ctx context.Context, req ReorderColumnsRequest, ownerID string) error
// Card operations // Card operations
CreateCard(ctx context.Context, req CreateCardRequest) (*CardResponse, error) CreateCard(ctx context.Context, req CreateCardRequest, ownerID string) (*CardResponse, error)
GetCardByID(ctx context.Context, id string) (*CardResponse, error) GetCardByID(ctx context.Context, id, ownerID string) (*CardResponse, error)
UpdateCard(ctx context.Context, req UpdateCardRequest) (*CardResponse, error) UpdateCard(ctx context.Context, req UpdateCardRequest, ownerID string) (*CardResponse, error)
DeleteCard(ctx context.Context, id string) error DeleteCard(ctx context.Context, id, ownerID string) error
MoveCard(ctx context.Context, req MoveCardRequest) error MoveCard(ctx context.Context, req MoveCardRequest, ownerID string) error
ReorderCards(ctx context.Context, req ReorderCardsRequest) error ReorderCards(ctx context.Context, req ReorderCardsRequest, ownerID string) error
} }
// kanbanService implements KanbanService interface // kanbanService implements KanbanService interface
@@ -37,19 +39,25 @@ 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{}{
@@ -80,20 +88,29 @@ 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
} }
// GetBoardByID retrieves a board with full column and card details // GetBoardByID retrieves a board with full column and card details
func (s *kanbanService) GetBoardByID(ctx context.Context, id string) (*BoardDetailResponse, error) { func (s *kanbanService) GetBoardByID(ctx context.Context, id, ownerID string) (*BoardDetailResponse, error) {
s.logger.Info("Retrieving board by ID", map[string]interface{}{ s.logger.Info("Retrieving board by ID", map[string]interface{}{
"board_id": id, "board_id": id,
"owner_id": ownerID,
}) })
board, err := s.boardRepository.GetBoardByID(ctx, id) board, err := s.getBoardForOwner(ctx, id, ownerID)
if err != nil { if err != nil {
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
@@ -164,16 +181,17 @@ func (s *kanbanService) GetDefaultBoard(ctx context.Context, userID string) (*Bo
return nil, err return nil, err
} }
return s.GetBoardByID(ctx, board.ID.Hex()) return s.GetBoardByID(ctx, board.ID.Hex(), userID)
} }
// UpdateBoard updates an existing board // UpdateBoard updates an existing board
func (s *kanbanService) UpdateBoard(ctx context.Context, req UpdateBoardRequest) (*BoardResponse, error) { func (s *kanbanService) UpdateBoard(ctx context.Context, req UpdateBoardRequest, ownerID string) (*BoardResponse, error) {
s.logger.Info("Updating board", map[string]interface{}{ s.logger.Info("Updating board", map[string]interface{}{
"board_id": req.ID, "board_id": req.ID,
"owner_id": ownerID,
}) })
board, err := s.boardRepository.GetBoardByID(ctx, req.ID) board, err := s.getBoardForOwner(ctx, req.ID, ownerID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -206,11 +224,16 @@ func (s *kanbanService) UpdateBoard(ctx context.Context, req UpdateBoardRequest)
} }
// DeleteBoard deletes a board and all its columns and cards // DeleteBoard deletes a board and all its columns and cards
func (s *kanbanService) DeleteBoard(ctx context.Context, id string) error { func (s *kanbanService) DeleteBoard(ctx context.Context, id, ownerID string) error {
s.logger.Info("Deleting board", map[string]interface{}{ s.logger.Info("Deleting board", map[string]interface{}{
"board_id": id, "board_id": id,
"owner_id": ownerID,
}) })
if _, err := s.getBoardForOwner(ctx, id, ownerID); err != nil {
return err
}
// Get all columns for this board // Get all columns for this board
columns, err := s.columnRepository.GetColumnsByBoardID(ctx, id) columns, err := s.columnRepository.GetColumnsByBoardID(ctx, id)
if err != nil { if err != nil {
@@ -251,52 +274,46 @@ func (s *kanbanService) DeleteBoard(ctx context.Context, id string) error {
} }
// CreateColumn creates a new column in a board // CreateColumn creates a new column in a board
func (s *kanbanService) CreateColumn(ctx context.Context, req CreateColumnRequest) (*ColumnResponse, error) { func (s *kanbanService) CreateColumn(ctx context.Context, req CreateColumnRequest, ownerID string) (*ColumnResponse, error) {
s.logger.Info("Creating new column", map[string]interface{}{ s.logger.Info("Creating new column", map[string]interface{}{
"name": req.Name, "name": req.Name,
"board_id": req.BoardID, "board_id": req.BoardID,
"order": req.Order, "order": req.Order,
}) })
column := &Column{ if _, err := s.getBoardForOwner(ctx, req.BoardID, ownerID); err != nil {
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 nil, err
} }
return column.ToResponse(), nil return nil, fmt.Errorf("kanban board uses a fixed bid workflow; columns are created automatically from GoRules")
} }
// UpdateColumn updates an existing column // UpdateColumn updates an existing column
func (s *kanbanService) UpdateColumn(ctx context.Context, req UpdateColumnRequest) (*ColumnResponse, error) { func (s *kanbanService) UpdateColumn(ctx context.Context, req UpdateColumnRequest, ownerID string) (*ColumnResponse, error) {
s.logger.Info("Updating column", map[string]interface{}{ s.logger.Info("Updating column", map[string]interface{}{
"column_id": req.ID, "column_id": req.ID,
"owner_id": ownerID,
}) })
column, err := s.columnRepository.GetColumnByID(ctx, req.ID) column, _, err := s.getColumnForOwner(ctx, req.ID, ownerID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Update fields
if req.Name != nil { if req.Name != nil {
column.Name = *req.Name return nil, fmt.Errorf("column name cannot be changed on a fixed workflow board")
}
if req.Color != nil {
column.Color = *req.Color
} }
if req.Order != nil { if req.Order != nil {
column.Order = *req.Order return nil, fmt.Errorf("column order cannot be changed on a fixed workflow board")
} }
if req.Limit != nil { if req.Limit != nil {
column.Limit = req.Limit 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 { if err := s.columnRepository.UpdateColumn(ctx, column); err != nil {
return nil, err return nil, err
@@ -306,91 +323,40 @@ func (s *kanbanService) UpdateColumn(ctx context.Context, req UpdateColumnReques
} }
// DeleteColumn deletes a column and moves all its cards to the next column // DeleteColumn deletes a column and moves all its cards to the next column
func (s *kanbanService) DeleteColumn(ctx context.Context, id string) error { func (s *kanbanService) DeleteColumn(ctx context.Context, id, ownerID string) error {
s.logger.Info("Deleting column", map[string]interface{}{ s.logger.Info("Deleting column", map[string]interface{}{
"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
func (s *kanbanService) ReorderColumns(ctx context.Context, req ReorderColumnsRequest) error { func (s *kanbanService) ReorderColumns(ctx context.Context, req ReorderColumnsRequest, ownerID string) error {
s.logger.Info("Reordering columns", map[string]interface{}{ s.logger.Info("Reordering columns", map[string]interface{}{
"board_id": req.BoardID, "board_id": req.BoardID,
"count": len(req.ColumnOrders), "count": len(req.ColumnOrders),
}) })
return s.columnRepository.UpdateColumnOrder(ctx, req.BoardID, 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 // CreateCard creates a new card in a column
func (s *kanbanService) CreateCard(ctx context.Context, req CreateCardRequest) (*CardResponse, error) { func (s *kanbanService) CreateCard(ctx context.Context, req CreateCardRequest, ownerID string) (*CardResponse, error) {
s.logger.Info("Creating new card", map[string]interface{}{ s.logger.Info("Creating new card", map[string]interface{}{
"title": req.Title, "title": req.Title,
"tender_id": req.TenderID, "tender_id": req.TenderID,
"column_id": req.ColumnID, "column_id": req.ColumnID,
"order": req.Order, "order": req.Order,
"owner_id": ownerID,
}) })
// Validate that the column exists if _, _, err := s.getColumnForOwner(ctx, req.ColumnID, ownerID); err != nil {
_, err := s.columnRepository.GetColumnByID(ctx, req.ColumnID) return nil, err
if err != nil {
return nil, fmt.Errorf("column not found: %w", err)
} }
// Check for duplicate card (same tender in same column) // Check for duplicate card (same tender in same column)
@@ -423,12 +389,13 @@ func (s *kanbanService) CreateCard(ctx context.Context, req CreateCardRequest) (
} }
// GetCardByID retrieves a card by ID // GetCardByID retrieves a card by ID
func (s *kanbanService) GetCardByID(ctx context.Context, id string) (*CardResponse, error) { func (s *kanbanService) GetCardByID(ctx context.Context, id, ownerID string) (*CardResponse, error) {
s.logger.Info("Retrieving card by ID", map[string]interface{}{ s.logger.Info("Retrieving card by ID", map[string]interface{}{
"card_id": id, "card_id": id,
"owner_id": ownerID,
}) })
card, err := s.cardRepository.GetCardByID(ctx, id) card, _, _, err := s.getCardForOwner(ctx, id, ownerID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -437,12 +404,13 @@ func (s *kanbanService) GetCardByID(ctx context.Context, id string) (*CardRespon
} }
// UpdateCard updates an existing card // UpdateCard updates an existing card
func (s *kanbanService) UpdateCard(ctx context.Context, req UpdateCardRequest) (*CardResponse, error) { func (s *kanbanService) UpdateCard(ctx context.Context, req UpdateCardRequest, ownerID string) (*CardResponse, error) {
s.logger.Info("Updating card", map[string]interface{}{ s.logger.Info("Updating card", map[string]interface{}{
"card_id": req.ID, "card_id": req.ID,
"owner_id": ownerID,
}) })
card, err := s.cardRepository.GetCardByID(ctx, req.ID) card, _, _, err := s.getCardForOwner(ctx, req.ID, ownerID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -469,38 +437,68 @@ func (s *kanbanService) UpdateCard(ctx context.Context, req UpdateCardRequest) (
} }
// DeleteCard deletes a card // DeleteCard deletes a card
func (s *kanbanService) DeleteCard(ctx context.Context, id string) error { func (s *kanbanService) DeleteCard(ctx context.Context, id, ownerID string) error {
s.logger.Info("Deleting card", map[string]interface{}{ s.logger.Info("Deleting card", map[string]interface{}{
"card_id": id, "card_id": id,
"owner_id": ownerID,
}) })
if _, _, _, err := s.getCardForOwner(ctx, id, ownerID); err != nil {
return err
}
return s.cardRepository.DeleteCard(ctx, id) return s.cardRepository.DeleteCard(ctx, id)
} }
// 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, ownerID string) 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,
"owner_id": ownerID,
}) })
// Validate that the target column exists card, currentColumn, board, err := s.getCardForOwner(ctx, req.CardID, ownerID)
_, err := s.columnRepository.GetColumnByID(ctx, req.ColumnID)
if err != nil { if err != nil {
return fmt.Errorf("target column not found: %w", err) 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) return s.cardRepository.MoveCard(ctx, req.CardID, req.ColumnID, req.NewOrder)
} }
// ReorderCards reorders cards within a column // ReorderCards reorders cards within a column
func (s *kanbanService) ReorderCards(ctx context.Context, req ReorderCardsRequest) error { func (s *kanbanService) ReorderCards(ctx context.Context, req ReorderCardsRequest, ownerID string) error {
s.logger.Info("Reordering cards", map[string]interface{}{ s.logger.Info("Reordering cards", map[string]interface{}{
"column_id": req.ColumnID, "column_id": req.ColumnID,
"count": len(req.CardOrders), "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) return s.cardRepository.UpdateCardOrder(ctx, req.ColumnID, req.CardOrders)
} }
@@ -522,3 +520,234 @@ 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.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]
}
+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
}