diff --git a/cmd/web/bootstrap/bootstrap.go b/cmd/web/bootstrap/bootstrap.go index a67ee5f..234eda6 100644 --- a/cmd/web/bootstrap/bootstrap.go +++ b/cmd/web/bootstrap/bootstrap.go @@ -7,6 +7,7 @@ import ( "tm/pkg/authorization" "tm/pkg/config" "tm/pkg/filestore" + "tm/pkg/gorules" "tm/pkg/hcaptcha" "tm/pkg/logger" "tm/pkg/mongo" @@ -359,3 +360,36 @@ func InitAISummarizerStorage(conf AISummarizerConfig, log logger.Logger) *ai_sum 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 +} diff --git a/cmd/web/bootstrap/config.go b/cmd/web/bootstrap/config.go index 06bd2c7..0002db2 100644 --- a/cmd/web/bootstrap/config.go +++ b/cmd/web/bootstrap/config.go @@ -22,6 +22,7 @@ type Config struct { CustomerAuth AuthConfig DocumentScraper DocumentScraperConfig AISummarizer AISummarizerConfig + GoRules GoRulesConfig } type ScraperConfig struct { @@ -77,3 +78,11 @@ type AISummarizerConfig struct { MinioRegion string `env:"AI_SUMMARIZER_MINIO_REGION" envDefault:"us-east-1"` 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"` +} diff --git a/cmd/web/main.go b/cmd/web/main.go index 1ff778a..48fc7ed 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -178,6 +178,7 @@ func main() { // Initialize authorization service userAuthService := bootstrap.InitAuthorizationService(conf.UserAuth.JWT, redisClient, logger) customerAuthService := bootstrap.InitAuthorizationService(conf.CustomerAuth.JWT, redisClient, logger) + goRulesClient := bootstrap.InitGoRulesClient(conf.GoRules, logger) // Initialize repositories with MongoDB connection manager userRepository := user.NewRepository(mongoManager, logger) @@ -218,7 +219,7 @@ func main() { notificationService := notification.NewService(notificationSDK, notificationRepository, userService, customerService, logger) contactService := contact.NewService(contactRepository, logger, notificationSDK) 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) dashboardRepository := dashboard.NewRepository(mongoManager, logger) dashboardService := dashboard.NewService(dashboardRepository, logger) @@ -254,7 +255,7 @@ func main() { // Register routes 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) // Start server diff --git a/cmd/web/router/routes.go b/cmd/web/router/routes.go index 4d83f5f..b087183 100644 --- a/cmd/web/router/routes.go +++ b/cmd/web/router/routes.go @@ -192,7 +192,7 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler cmsGP.DELETE("/:id", cmsHandler.Delete) } - // Kanban Routes + // Kanban Routes — paths are /admin/v1/kanban/* (RegisterRoutes expects this /kanban group) kanbanGP := adminV1.Group("/kanban") { 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") customerGP := v1.Group("/profile") @@ -320,6 +320,13 @@ func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tende 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 publicFilesGP := v1.Group("/files") { diff --git a/go.mod b/go.mod index 65bcfc1..3bf8898 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module tm -go 1.23.0 +go 1.24 toolchain go1.24.4 @@ -18,6 +18,7 @@ require ( go.uber.org/zap v1.26.0 golang.org/x/crypto v0.40.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 + repo.ravanertebat.com/k.khodayari/gorulessdk v1.0.10 ) require ( diff --git a/go.sum b/go.sum index 26f27f3..8247541 100644 --- a/go.sum +++ b/go.sum @@ -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.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 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= diff --git a/internal/kanban/context.go b/internal/kanban/context.go new file mode 100644 index 0000000..0f8b7c3 --- /dev/null +++ b/internal/kanban/context.go @@ -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 +} diff --git a/internal/kanban/entity.go b/internal/kanban/entity.go index 695105b..0bf5fc0 100644 --- a/internal/kanban/entity.go +++ b/internal/kanban/entity.go @@ -7,9 +7,9 @@ import ( // Board represents a kanban board type Board struct { - mongo.Model `bson:",inline"` - Name string `bson:"name" json:"name"` - Description string `bson:"description" json:"description"` + mongo.Model `bson:",inline"` + Name string `bson:"name" json:"name"` + Description string `bson:"description" json:"description"` Columns []Column `bson:"columns" json:"columns"` 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 @@ -17,24 +17,26 @@ type Board struct { // Column represents a column on a kanban board type Column struct { - mongo.Model `bson:",inline"` - BoardID string `bson:"board_id" json:"board_id"` - Name string `bson:"name" json:"name"` - 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) + mongo.Model `bson:",inline"` + BoardID string `bson:"board_id" json:"board_id"` + 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 + 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) type Card struct { - mongo.Model `bson:",inline"` - ColumnID string `bson:"column_id" json:"column_id"` - TenderID string `bson:"tender_id" json:"tender_id"` // Reference to the tender - Title string `bson:"title" json:"title"` // Cached title from tender - Order int `bson:"order" json:"order"` // Position within the column - Priority CardPriority `bson:"priority" json:"priority"` - Labels []string `bson:"labels,omitempty" json:"labels,omitempty"` - DueDate *int64 `bson:"due_date,omitempty" json:"due_date,omitempty"` // Unix timestamp + mongo.Model `bson:",inline"` + ColumnID string `bson:"column_id" json:"column_id"` + TenderID string `bson:"tender_id" json:"tender_id"` // Reference to the tender + Title string `bson:"title" json:"title"` // Cached title from tender + Order int `bson:"order" json:"order"` // Position within the column + Priority CardPriority `bson:"priority" json:"priority"` + Labels []string `bson:"labels,omitempty" json:"labels,omitempty"` + DueDate *int64 `bson:"due_date,omitempty" json:"due_date,omitempty"` // Unix timestamp } // CardPriority represents the priority level of a card diff --git a/internal/kanban/errors.go b/internal/kanban/errors.go new file mode 100644 index 0000000..6596bbe --- /dev/null +++ b/internal/kanban/errors.go @@ -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") +) diff --git a/internal/kanban/form.go b/internal/kanban/form.go index dbae879..58eef42 100644 --- a/internal/kanban/form.go +++ b/internal/kanban/form.go @@ -29,22 +29,22 @@ type CreateColumnRequest struct { // UpdateColumnRequest represents a request to update an existing column type UpdateColumnRequest struct { - 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"` - Color *string `json:"color,omitempty" valid:"hexcolor~Color must be a valid hex color"` - Order *int `json:"order,omitempty"` - Limit *int `json:"limit,omitempty" valid:"range(1|100)~WIP limit must be between 1 and 100"` + 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"` + Color *string `json:"color,omitempty" valid:"hexcolor~Color must be a valid hex color"` + Order *int `json:"order,omitempty"` + 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 type CreateCardRequest struct { - ColumnID string `json:"column_id" valid:"required~Column 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"` - Order int `json:"order"` - Priority CardPriority `json:"priority"` - Labels []string `json:"labels,omitempty"` - DueDate *int64 `json:"due_date,omitempty"` // Unix timestamp + ColumnID string `json:"column_id" valid:"required~Column 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"` + Order int `json:"order"` + Priority CardPriority `json:"priority"` + Labels []string `json:"labels,omitempty"` + DueDate *int64 `json:"due_date,omitempty"` // Unix timestamp } // 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 type MoveCardRequest struct { - CardID string `json:"card_id" valid:"required~Card ID is required"` - ColumnID string `json:"column_id" valid:"required~Column ID is required"` - NewOrder int `json:"new_order"` + CardID string `json:"card_id" valid:"required~Card ID is required"` + ColumnID string `json:"column_id" valid:"required~Column ID is required"` + NewOrder int `json:"new_order"` } // ReorderCardsRequest represents a request to reorder cards within a column type ReorderCardsRequest struct { - ColumnID string `json:"column_id" valid:"required~Column ID is required"` - CardOrders map[string]int `json:"card_orders" valid:"required~Card orders are 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"` } // ReorderColumnsRequest represents a request to reorder columns within a board @@ -77,29 +77,31 @@ type ReorderColumnsRequest struct { // BoardResponse represents a board in API responses type BoardResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Columns []ColumnResponse `json:"columns"` - IsDefault bool `json:"is_default"` - UserID string `json:"user_id"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Columns []ColumnResponse `json:"columns"` + IsDefault bool `json:"is_default"` + UserID string `json:"user_id"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` } // ColumnResponse represents a column in API responses type ColumnResponse struct { - ID string `json:"id"` - BoardID string `json:"board_id"` - Name string `json:"name"` - Order int `json:"order"` - Color string `json:"color"` - Limit *int `json:"limit,omitempty"` - Cards []CardResponse `json:"cards"` - CardCount int `json:"card_count"` - IsOverLimit bool `json:"is_over_limit"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` + ID string `json:"id"` + BoardID string `json:"board_id"` + Name string `json:"name"` + Status string `json:"status"` + StatusCategory string `json:"status_category"` + Order int `json:"order"` + Color string `json:"color"` + Limit *int `json:"limit,omitempty"` + Cards []CardResponse `json:"cards"` + CardCount int `json:"card_count"` + IsOverLimit bool `json:"is_over_limit"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` } // 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 type BoardDetailResponse struct { - Board BoardResponse `json:"board"` + Board BoardResponse `json:"board"` Meta *response.Meta `json:"-"` } @@ -145,17 +147,19 @@ func (b *Board) ToResponse() *BoardResponse { // ToColumnResponse converts a Column entity to ColumnResponse func (c *Column) ToResponse() *ColumnResponse { return &ColumnResponse{ - ID: c.ID.Hex(), - BoardID: c.BoardID, - Name: c.Name, - Order: c.Order, - Color: c.Color, - Limit: c.Limit, - Cards: make([]CardResponse, 0), // Will be populated by service - CardCount: 0, // Will be populated by service - IsOverLimit: false, // Will be calculated by service - CreatedAt: c.CreatedAt, - UpdatedAt: c.UpdatedAt, + ID: c.ID.Hex(), + BoardID: c.BoardID, + Name: c.Name, + Status: c.Status, + StatusCategory: c.StatusCategory, + Order: c.Order, + Color: c.Color, + Limit: c.Limit, + Cards: make([]CardResponse, 0), // Will be populated by service + CardCount: 0, // Will be populated by service + IsOverLimit: false, // Will be calculated by service + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, } } diff --git a/internal/kanban/handler.go b/internal/kanban/handler.go index 71d36d3..deaa54d 100644 --- a/internal/kanban/handler.go +++ b/internal/kanban/handler.go @@ -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) { - kanban := router.Group("/kanban") - { - // Board routes - kanban.POST("/boards", h.CreateBoard) - kanban.GET("/boards", h.GetBoardsByUser) - kanban.GET("/boards/default", h.GetDefaultBoard) - kanban.GET("/boards/:id", h.GetBoardByID) - kanban.PUT("/boards/:id", h.UpdateBoard) - kanban.DELETE("/boards/:id", h.DeleteBoard) + router.POST("/boards", h.CreateBoard) + router.GET("/boards", h.GetBoardsByUser) + router.GET("/boards/default", h.GetDefaultBoard) + router.GET("/boards/:id", h.GetBoardByID) + router.PUT("/boards/:id", h.UpdateBoard) + router.DELETE("/boards/:id", h.DeleteBoard) - // Column routes - kanban.POST("/columns", h.CreateColumn) - kanban.PUT("/columns/:id", h.UpdateColumn) - kanban.DELETE("/columns/:id", h.DeleteColumn) - kanban.PUT("/columns/reorder", h.ReorderColumns) + router.POST("/columns", h.CreateColumn) + router.PUT("/columns/:id", h.UpdateColumn) + router.DELETE("/columns/:id", h.DeleteColumn) + router.PUT("/columns/reorder", h.ReorderColumns) - // Card routes - kanban.POST("/cards", h.CreateCard) - kanban.GET("/cards/:id", h.GetCardByID) - kanban.PUT("/cards/:id", h.UpdateCard) - kanban.DELETE("/cards/:id", h.DeleteCard) - kanban.PUT("/cards/move", h.MoveCard) - kanban.PUT("/cards/reorder", h.ReorderCards) - } + router.POST("/cards", h.CreateCard) + router.GET("/cards/:id", h.GetCardByID) + router.PUT("/cards/:id", h.UpdateCard) + router.DELETE("/cards/:id", h.DeleteCard) + router.PUT("/cards/move", h.MoveCard) + router.PUT("/cards/reorder", h.ReorderCards) } // 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()) } - // Get user ID from context (assuming auth middleware sets this) - userID, exists := c.Get("user_id").(string) + ownerID, exists := ownerIDFromContext(c) if !exists { return response.Unauthorized(c, "User not authenticated") } - req.UserID = userID + req.UserID = ownerID board, err := h.service.CreateBoard(c.Request().Context(), req) - if err != nil { - return response.InternalServerError(c, "Failed to create board") + if err := handleServiceError(c, err, "Failed to create board"); err != nil { + return err } 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 { id := c.Param("id") - board, err := h.service.GetBoardByID(c.Request().Context(), id) - if err != nil { - return response.InternalServerError(c, "Failed to get board") + ownerID, exists := ownerIDFromContext(c) + if !exists { + 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") @@ -112,14 +112,14 @@ func (h *Handler) GetBoardByID(c echo.Context) error { // @Failure 500 {object} response.Response // @Router /kanban/boards [get] func (h *Handler) GetBoardsByUser(c echo.Context) error { - userID, exists := c.Get("user_id").(string) + ownerID, exists := ownerIDFromContext(c) if !exists { return response.Unauthorized(c, "User not authenticated") } - boards, err := h.service.GetBoardsByUserID(c.Request().Context(), userID) - if err != nil { - return response.InternalServerError(c, "Failed to get boards") + boards, err := h.service.GetBoardsByUserID(c.Request().Context(), ownerID) + if err := handleServiceError(c, err, "Failed to get boards"); err != nil { + return err } 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 // @Router /kanban/boards/default [get] func (h *Handler) GetDefaultBoard(c echo.Context) error { - userID, exists := c.Get("user_id").(string) + ownerID, exists := ownerIDFromContext(c) if !exists { return response.Unauthorized(c, "User not authenticated") } - board, err := h.service.GetDefaultBoard(c.Request().Context(), userID) - if err != nil { - return response.InternalServerError(c, "Failed to get default board") + board, err := h.service.GetDefaultBoard(c.Request().Context(), ownerID) + if err := handleServiceError(c, err, "Failed to get default board"); err != nil { + return err } 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") - board, err := h.service.UpdateBoard(c.Request().Context(), req) - if err != nil { - return response.InternalServerError(c, "Failed to update board") + ownerID, exists := ownerIDFromContext(c) + if !exists { + 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") @@ -192,8 +197,13 @@ func (h *Handler) UpdateBoard(c echo.Context) error { func (h *Handler) DeleteBoard(c echo.Context) error { id := c.Param("id") - if err := h.service.DeleteBoard(c.Request().Context(), id); err != nil { - return response.InternalServerError(c, "Failed to delete board") + ownerID, exists := ownerIDFromContext(c) + 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) @@ -216,9 +226,14 @@ func (h *Handler) CreateColumn(c echo.Context) error { return response.ValidationError(c, "Invalid request data", err.Error()) } - column, err := h.service.CreateColumn(c.Request().Context(), req) - if err != nil { - return response.InternalServerError(c, "Failed to create column") + ownerID, exists := ownerIDFromContext(c) + if !exists { + 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") @@ -245,9 +260,14 @@ func (h *Handler) UpdateColumn(c echo.Context) error { req.ID = c.Param("id") - column, err := h.service.UpdateColumn(c.Request().Context(), req) - if err != nil { - return response.InternalServerError(c, "Failed to update column") + ownerID, exists := ownerIDFromContext(c) + if !exists { + 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") @@ -267,8 +287,13 @@ func (h *Handler) UpdateColumn(c echo.Context) error { func (h *Handler) DeleteColumn(c echo.Context) error { id := c.Param("id") - if err := h.service.DeleteColumn(c.Request().Context(), id); err != nil { - return response.InternalServerError(c, "Failed to delete column") + ownerID, exists := ownerIDFromContext(c) + 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) @@ -291,8 +316,13 @@ func (h *Handler) ReorderColumns(c echo.Context) error { return response.ValidationError(c, "Invalid request data", err.Error()) } - if err := h.service.ReorderColumns(c.Request().Context(), req); err != nil { - return response.InternalServerError(c, "Failed to reorder columns") + ownerID, exists := ownerIDFromContext(c) + 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") @@ -334,9 +364,14 @@ func (h *Handler) CreateCard(c echo.Context) error { } } - card, err := h.service.CreateCard(c.Request().Context(), req) - if err != nil { - return response.InternalServerError(c, "Failed to create card") + ownerID, exists := ownerIDFromContext(c) + if !exists { + 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") @@ -356,9 +391,14 @@ func (h *Handler) CreateCard(c echo.Context) error { func (h *Handler) GetCardByID(c echo.Context) error { id := c.Param("id") - card, err := h.service.GetCardByID(c.Request().Context(), id) - if err != nil { - return response.InternalServerError(c, "Failed to get card") + ownerID, exists := ownerIDFromContext(c) + if !exists { + 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") @@ -392,9 +432,14 @@ func (h *Handler) UpdateCard(c echo.Context) error { } } - card, err := h.service.UpdateCard(c.Request().Context(), req) - if err != nil { - return response.InternalServerError(c, "Failed to update card") + ownerID, exists := ownerIDFromContext(c) + if !exists { + 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") @@ -414,8 +459,13 @@ func (h *Handler) UpdateCard(c echo.Context) error { func (h *Handler) DeleteCard(c echo.Context) error { id := c.Param("id") - if err := h.service.DeleteCard(c.Request().Context(), id); err != nil { - return response.InternalServerError(c, "Failed to delete card") + ownerID, exists := ownerIDFromContext(c) + 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) @@ -439,8 +489,13 @@ func (h *Handler) MoveCard(c echo.Context) error { return response.ValidationError(c, "Invalid request data", err.Error()) } - if err := h.service.MoveCard(c.Request().Context(), req); err != nil { - return response.InternalServerError(c, "Failed to move card") + ownerID, exists := ownerIDFromContext(c) + 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") @@ -463,8 +518,13 @@ func (h *Handler) ReorderCards(c echo.Context) error { return response.ValidationError(c, "Invalid request data", err.Error()) } - if err := h.service.ReorderCards(c.Request().Context(), req); err != nil { - return response.InternalServerError(c, "Failed to reorder cards") + ownerID, exists := ownerIDFromContext(c) + 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") diff --git a/internal/kanban/handler_errors.go b/internal/kanban/handler_errors.go new file mode 100644 index 0000000..010628b --- /dev/null +++ b/internal/kanban/handler_errors.go @@ -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) +} diff --git a/internal/kanban/repository.go b/internal/kanban/repository.go index 67b9f3d..afbe11b 100644 --- a/internal/kanban/repository.go +++ b/internal/kanban/repository.go @@ -176,9 +176,9 @@ func (r *boardRepository) CreateBoard(ctx context.Context, board *Board) error { err := r.ormRepo.Create(ctx, board) if err != nil { r.logger.Error("Failed to create board", map[string]interface{}{ - "error": err.Error(), - "name": board.Name, - "user_id": board.UserID, + "error": err.Error(), + "name": board.Name, + "user_id": board.UserID, }) return err } @@ -572,8 +572,8 @@ func (r *cardRepository) UpdateCardOrder(ctx context.Context, columnID string, c card, err := r.ormRepo.FindByID(ctx, cardID) if err != nil { r.logger.Error("Failed to find card for order update", map[string]interface{}{ - "card_id": cardID, - "error": err.Error(), + "card_id": cardID, + "error": err.Error(), }) return err } diff --git a/internal/kanban/service.go b/internal/kanban/service.go index bf7c132..a04239c 100644 --- a/internal/kanban/service.go +++ b/internal/kanban/service.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "sort" + "strings" + "tm/pkg/gorules" "tm/pkg/logger" ) @@ -11,25 +13,25 @@ import ( type Service interface { // Board operations 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) GetDefaultBoard(ctx context.Context, userID string) (*BoardDetailResponse, error) - UpdateBoard(ctx context.Context, req UpdateBoardRequest) (*BoardResponse, error) - DeleteBoard(ctx context.Context, id string) 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) (*ColumnResponse, error) - UpdateColumn(ctx context.Context, req UpdateColumnRequest) (*ColumnResponse, error) - DeleteColumn(ctx context.Context, id string) error - ReorderColumns(ctx context.Context, req ReorderColumnsRequest) error + 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) (*CardResponse, error) - GetCardByID(ctx context.Context, id string) (*CardResponse, error) - UpdateCard(ctx context.Context, req UpdateCardRequest) (*CardResponse, error) - DeleteCard(ctx context.Context, id string) error - MoveCard(ctx context.Context, req MoveCardRequest) error - ReorderCards(ctx context.Context, req ReorderCardsRequest) error + 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 @@ -37,24 +39,30 @@ 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, logger logger.Logger) 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, + "name": req.Name, + "user_id": req.UserID, "is_default": req.IsDefault, }) @@ -80,20 +88,29 @@ func (s *kanbanService) CreateBoard(ctx context.Context, req CreateBoardRequest) return nil, err } + if err := s.ensureDefaultColumns(ctx, board.ID.Hex()); err != nil { + return nil, err + } + return board.ToResponse(), nil } // GetBoardByID retrieves a board with full column and card details -func (s *kanbanService) GetBoardByID(ctx context.Context, id string) (*BoardDetailResponse, error) { +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.boardRepository.GetBoardByID(ctx, id) + 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 @@ -164,16 +181,17 @@ func (s *kanbanService) GetDefaultBoard(ctx context.Context, userID string) (*Bo return nil, err } - return s.GetBoardByID(ctx, board.ID.Hex()) + return s.GetBoardByID(ctx, board.ID.Hex(), userID) } // 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{}{ "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 { 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 -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{}{ "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 { @@ -251,52 +274,46 @@ func (s *kanbanService) DeleteBoard(ctx context.Context, id string) error { } // 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{}{ "name": req.Name, "board_id": req.BoardID, "order": req.Order, }) - column := &Column{ - BoardID: req.BoardID, - Name: req.Name, - Color: req.Color, - Order: req.Order, - Limit: req.Limit, - } - - if err := s.columnRepository.CreateColumn(ctx, column); err != nil { + if _, err := s.getBoardForOwner(ctx, req.BoardID, ownerID); err != nil { 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 -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{}{ "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 { return nil, err } - // Update fields if req.Name != nil { - column.Name = *req.Name - } - if req.Color != nil { - column.Color = *req.Color + return nil, fmt.Errorf("column name cannot be changed on a fixed workflow board") } 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 { - 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 { 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 -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{}{ "column_id": id, }) - column, err := s.columnRepository.GetColumnByID(ctx, id) - 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) + return fmt.Errorf("columns are fixed and cannot be deleted") } // ReorderColumns reorders columns within a board -func (s *kanbanService) ReorderColumns(ctx context.Context, req ReorderColumnsRequest) error { +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), }) - 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 -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{}{ "title": req.Title, "tender_id": req.TenderID, "column_id": req.ColumnID, "order": req.Order, + "owner_id": ownerID, }) - // Validate that the column exists - _, err := s.columnRepository.GetColumnByID(ctx, req.ColumnID) - if err != nil { - return nil, fmt.Errorf("column not found: %w", err) + if _, _, err := s.getColumnForOwner(ctx, req.ColumnID, ownerID); err != nil { + return nil, err } // 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 -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{}{ - "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 { return nil, err } @@ -437,12 +404,13 @@ func (s *kanbanService) GetCardByID(ctx context.Context, id string) (*CardRespon } // 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{}{ - "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 { return nil, err } @@ -469,38 +437,68 @@ func (s *kanbanService) UpdateCard(ctx context.Context, req UpdateCardRequest) ( } // 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{}{ - "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) } // 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{}{ - "card_id": req.CardID, - "column_id": req.ColumnID, - "new_order": req.NewOrder, + "card_id": req.CardID, + "column_id": req.ColumnID, + "new_order": req.NewOrder, + "owner_id": ownerID, }) - // Validate that the target column exists - _, err := s.columnRepository.GetColumnByID(ctx, req.ColumnID) + card, currentColumn, board, err := s.getCardForOwner(ctx, req.CardID, ownerID) 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) } // 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{}{ "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) } @@ -522,3 +520,234 @@ func (s *kanbanService) unsetDefaultBoard(ctx context.Context, userID string) er 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] +} diff --git a/pkg/gorules/client.go b/pkg/gorules/client.go new file mode 100644 index 0000000..f99526c --- /dev/null +++ b/pkg/gorules/client.go @@ -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 +}