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:
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package kanban
|
||||||
|
|
||||||
|
import "github.com/labstack/echo/v4"
|
||||||
|
|
||||||
|
// ownerIDFromContext returns the board owner ID from admin (user_id) or mobile (customer_id) auth.
|
||||||
|
func ownerIDFromContext(c echo.Context) (string, bool) {
|
||||||
|
if id, ok := c.Get("customer_id").(string); ok && id != "" {
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
if id, ok := c.Get("user_id").(string); ok && id != "" {
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
+19
-17
@@ -7,9 +7,9 @@ import (
|
|||||||
|
|
||||||
// Board represents a kanban board
|
// Board represents a kanban board
|
||||||
type Board struct {
|
type Board struct {
|
||||||
mongo.Model `bson:",inline"`
|
mongo.Model `bson:",inline"`
|
||||||
Name string `bson:"name" json:"name"`
|
Name string `bson:"name" json:"name"`
|
||||||
Description string `bson:"description" json:"description"`
|
Description string `bson:"description" json:"description"`
|
||||||
Columns []Column `bson:"columns" json:"columns"`
|
Columns []Column `bson:"columns" json:"columns"`
|
||||||
IsDefault bool `bson:"is_default" json:"is_default"` // Whether this is the default board
|
IsDefault bool `bson:"is_default" json:"is_default"` // Whether this is the default board
|
||||||
UserID string `bson:"user_id" json:"user_id"` // User who owns this board
|
UserID string `bson:"user_id" json:"user_id"` // User who owns this board
|
||||||
@@ -17,24 +17,26 @@ type Board struct {
|
|||||||
|
|
||||||
// Column represents a column on a kanban board
|
// Column represents a column on a kanban board
|
||||||
type Column struct {
|
type Column struct {
|
||||||
mongo.Model `bson:",inline"`
|
mongo.Model `bson:",inline"`
|
||||||
BoardID string `bson:"board_id" json:"board_id"`
|
BoardID string `bson:"board_id" json:"board_id"`
|
||||||
Name string `bson:"name" json:"name"`
|
Name string `bson:"name" json:"name"`
|
||||||
Order int `bson:"order" json:"order"` // Display order in the board
|
Status string `bson:"status" json:"status"`
|
||||||
Color string `bson:"color" json:"color"` // Hex color code for the column
|
StatusCategory string `bson:"status_category" json:"status_category"`
|
||||||
Limit *int `bson:"limit,omitempty" json:"limit,omitempty"` // WIP limit (optional)
|
Order int `bson:"order" json:"order"` // Display order in the board
|
||||||
|
Color string `bson:"color" json:"color"` // Hex color code for the column
|
||||||
|
Limit *int `bson:"limit,omitempty" json:"limit,omitempty"` // WIP limit (optional)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Card represents a card on a kanban board (references a tender)
|
// Card represents a card on a kanban board (references a tender)
|
||||||
type Card struct {
|
type Card struct {
|
||||||
mongo.Model `bson:",inline"`
|
mongo.Model `bson:",inline"`
|
||||||
ColumnID string `bson:"column_id" json:"column_id"`
|
ColumnID string `bson:"column_id" json:"column_id"`
|
||||||
TenderID string `bson:"tender_id" json:"tender_id"` // Reference to the tender
|
TenderID string `bson:"tender_id" json:"tender_id"` // Reference to the tender
|
||||||
Title string `bson:"title" json:"title"` // Cached title from tender
|
Title string `bson:"title" json:"title"` // Cached title from tender
|
||||||
Order int `bson:"order" json:"order"` // Position within the column
|
Order int `bson:"order" json:"order"` // Position within the column
|
||||||
Priority CardPriority `bson:"priority" json:"priority"`
|
Priority CardPriority `bson:"priority" json:"priority"`
|
||||||
Labels []string `bson:"labels,omitempty" json:"labels,omitempty"`
|
Labels []string `bson:"labels,omitempty" json:"labels,omitempty"`
|
||||||
DueDate *int64 `bson:"due_date,omitempty" json:"due_date,omitempty"` // Unix timestamp
|
DueDate *int64 `bson:"due_date,omitempty" json:"due_date,omitempty"` // Unix timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
// CardPriority represents the priority level of a card
|
// CardPriority represents the priority level of a card
|
||||||
|
|||||||
@@ -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")
|
||||||
|
)
|
||||||
+52
-48
@@ -29,22 +29,22 @@ type CreateColumnRequest struct {
|
|||||||
|
|
||||||
// UpdateColumnRequest represents a request to update an existing column
|
// UpdateColumnRequest represents a request to update an existing column
|
||||||
type UpdateColumnRequest struct {
|
type UpdateColumnRequest struct {
|
||||||
ID string `json:"id" valid:"required~Column ID is required"`
|
ID string `json:"id" valid:"required~Column ID is required"`
|
||||||
Name *string `json:"name,omitempty" valid:"stringlength(1|50)~Column name must be between 1 and 50 characters"`
|
Name *string `json:"name,omitempty" valid:"stringlength(1|50)~Column name must be between 1 and 50 characters"`
|
||||||
Color *string `json:"color,omitempty" valid:"hexcolor~Color must be a valid hex color"`
|
Color *string `json:"color,omitempty" valid:"hexcolor~Color must be a valid hex color"`
|
||||||
Order *int `json:"order,omitempty"`
|
Order *int `json:"order,omitempty"`
|
||||||
Limit *int `json:"limit,omitempty" valid:"range(1|100)~WIP limit must be between 1 and 100"`
|
Limit *int `json:"limit,omitempty" valid:"range(1|100)~WIP limit must be between 1 and 100"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateCardRequest represents a request to create a new card
|
// CreateCardRequest represents a request to create a new card
|
||||||
type CreateCardRequest struct {
|
type CreateCardRequest struct {
|
||||||
ColumnID string `json:"column_id" valid:"required~Column ID is required"`
|
ColumnID string `json:"column_id" valid:"required~Column ID is required"`
|
||||||
TenderID string `json:"tender_id" valid:"required~Tender ID is required"`
|
TenderID string `json:"tender_id" valid:"required~Tender ID is required"`
|
||||||
Title string `json:"title" valid:"required~Title is required,stringlength(1|200)~Title must be between 1 and 200 characters"`
|
Title string `json:"title" valid:"required~Title is required,stringlength(1|200)~Title must be between 1 and 200 characters"`
|
||||||
Order int `json:"order"`
|
Order int `json:"order"`
|
||||||
Priority CardPriority `json:"priority"`
|
Priority CardPriority `json:"priority"`
|
||||||
Labels []string `json:"labels,omitempty"`
|
Labels []string `json:"labels,omitempty"`
|
||||||
DueDate *int64 `json:"due_date,omitempty"` // Unix timestamp
|
DueDate *int64 `json:"due_date,omitempty"` // Unix timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateCardRequest represents a request to update an existing card
|
// UpdateCardRequest represents a request to update an existing card
|
||||||
@@ -58,15 +58,15 @@ type UpdateCardRequest struct {
|
|||||||
|
|
||||||
// MoveCardRequest represents a request to move a card to a different column
|
// MoveCardRequest represents a request to move a card to a different column
|
||||||
type MoveCardRequest struct {
|
type MoveCardRequest struct {
|
||||||
CardID string `json:"card_id" valid:"required~Card ID is required"`
|
CardID string `json:"card_id" valid:"required~Card ID is required"`
|
||||||
ColumnID string `json:"column_id" valid:"required~Column ID is required"`
|
ColumnID string `json:"column_id" valid:"required~Column ID is required"`
|
||||||
NewOrder int `json:"new_order"`
|
NewOrder int `json:"new_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReorderCardsRequest represents a request to reorder cards within a column
|
// ReorderCardsRequest represents a request to reorder cards within a column
|
||||||
type ReorderCardsRequest struct {
|
type ReorderCardsRequest struct {
|
||||||
ColumnID string `json:"column_id" valid:"required~Column ID is required"`
|
ColumnID string `json:"column_id" valid:"required~Column ID is required"`
|
||||||
CardOrders map[string]int `json:"card_orders" valid:"required~Card orders are required"`
|
CardOrders map[string]int `json:"card_orders" valid:"required~Card orders are required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReorderColumnsRequest represents a request to reorder columns within a board
|
// ReorderColumnsRequest represents a request to reorder columns within a board
|
||||||
@@ -77,29 +77,31 @@ type ReorderColumnsRequest struct {
|
|||||||
|
|
||||||
// BoardResponse represents a board in API responses
|
// BoardResponse represents a board in API responses
|
||||||
type BoardResponse struct {
|
type BoardResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Columns []ColumnResponse `json:"columns"`
|
Columns []ColumnResponse `json:"columns"`
|
||||||
IsDefault bool `json:"is_default"`
|
IsDefault bool `json:"is_default"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ColumnResponse represents a column in API responses
|
// ColumnResponse represents a column in API responses
|
||||||
type ColumnResponse struct {
|
type ColumnResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
BoardID string `json:"board_id"`
|
BoardID string `json:"board_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Order int `json:"order"`
|
Status string `json:"status"`
|
||||||
Color string `json:"color"`
|
StatusCategory string `json:"status_category"`
|
||||||
Limit *int `json:"limit,omitempty"`
|
Order int `json:"order"`
|
||||||
Cards []CardResponse `json:"cards"`
|
Color string `json:"color"`
|
||||||
CardCount int `json:"card_count"`
|
Limit *int `json:"limit,omitempty"`
|
||||||
IsOverLimit bool `json:"is_over_limit"`
|
Cards []CardResponse `json:"cards"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CardCount int `json:"card_count"`
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
IsOverLimit bool `json:"is_over_limit"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CardResponse represents a card in API responses
|
// CardResponse represents a card in API responses
|
||||||
@@ -124,7 +126,7 @@ type BoardsListResponse struct {
|
|||||||
|
|
||||||
// BoardDetailResponse represents detailed board response with full column and card data
|
// BoardDetailResponse represents detailed board response with full column and card data
|
||||||
type BoardDetailResponse struct {
|
type BoardDetailResponse struct {
|
||||||
Board BoardResponse `json:"board"`
|
Board BoardResponse `json:"board"`
|
||||||
Meta *response.Meta `json:"-"`
|
Meta *response.Meta `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,17 +147,19 @@ func (b *Board) ToResponse() *BoardResponse {
|
|||||||
// ToColumnResponse converts a Column entity to ColumnResponse
|
// ToColumnResponse converts a Column entity to ColumnResponse
|
||||||
func (c *Column) ToResponse() *ColumnResponse {
|
func (c *Column) ToResponse() *ColumnResponse {
|
||||||
return &ColumnResponse{
|
return &ColumnResponse{
|
||||||
ID: c.ID.Hex(),
|
ID: c.ID.Hex(),
|
||||||
BoardID: c.BoardID,
|
BoardID: c.BoardID,
|
||||||
Name: c.Name,
|
Name: c.Name,
|
||||||
Order: c.Order,
|
Status: c.Status,
|
||||||
Color: c.Color,
|
StatusCategory: c.StatusCategory,
|
||||||
Limit: c.Limit,
|
Order: c.Order,
|
||||||
Cards: make([]CardResponse, 0), // Will be populated by service
|
Color: c.Color,
|
||||||
CardCount: 0, // Will be populated by service
|
Limit: c.Limit,
|
||||||
IsOverLimit: false, // Will be calculated by service
|
Cards: make([]CardResponse, 0), // Will be populated by service
|
||||||
CreatedAt: c.CreatedAt,
|
CardCount: 0, // Will be populated by service
|
||||||
UpdatedAt: c.UpdatedAt,
|
IsOverLimit: false, // Will be calculated by service
|
||||||
|
CreatedAt: c.CreatedAt,
|
||||||
|
UpdatedAt: c.UpdatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+129
-69
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -176,9 +176,9 @@ func (r *boardRepository) CreateBoard(ctx context.Context, board *Board) error {
|
|||||||
err := r.ormRepo.Create(ctx, board)
|
err := r.ormRepo.Create(ctx, board)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.logger.Error("Failed to create board", map[string]interface{}{
|
r.logger.Error("Failed to create board", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
"name": board.Name,
|
"name": board.Name,
|
||||||
"user_id": board.UserID,
|
"user_id": board.UserID,
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -572,8 +572,8 @@ func (r *cardRepository) UpdateCardOrder(ctx context.Context, columnID string, c
|
|||||||
card, err := r.ormRepo.FindByID(ctx, cardID)
|
card, err := r.ormRepo.FindByID(ctx, cardID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.logger.Error("Failed to find card for order update", map[string]interface{}{
|
r.logger.Error("Failed to find card for order update", map[string]interface{}{
|
||||||
"card_id": cardID,
|
"card_id": cardID,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+350
-121
@@ -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,24 +39,30 @@ type kanbanService struct {
|
|||||||
boardRepository BoardRepository
|
boardRepository BoardRepository
|
||||||
columnRepository ColumnRepository
|
columnRepository ColumnRepository
|
||||||
cardRepository CardRepository
|
cardRepository CardRepository
|
||||||
|
goRulesClient gorules.Client
|
||||||
|
goRulesRuleID string
|
||||||
logger logger.Logger
|
logger logger.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new kanban service
|
// NewService creates a new kanban service
|
||||||
func NewService(boardRepo BoardRepository, columnRepo ColumnRepository, cardRepo CardRepository, logger logger.Logger) Service {
|
func NewService(boardRepo BoardRepository, columnRepo ColumnRepository, cardRepo CardRepository, goRulesClient gorules.Client, goRulesRuleID string, logger logger.Logger) Service {
|
||||||
return &kanbanService{
|
return &kanbanService{
|
||||||
boardRepository: boardRepo,
|
boardRepository: boardRepo,
|
||||||
columnRepository: columnRepo,
|
columnRepository: columnRepo,
|
||||||
cardRepository: cardRepo,
|
cardRepository: cardRepo,
|
||||||
|
goRulesClient: goRulesClient,
|
||||||
|
goRulesRuleID: goRulesRuleID,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var defaultColumnColors = []string{"#64748B", "#0EA5E9", "#22C55E", "#A855F7", "#F59E0B"}
|
||||||
|
|
||||||
// CreateBoard creates a new kanban board
|
// CreateBoard creates a new kanban board
|
||||||
func (s *kanbanService) CreateBoard(ctx context.Context, req CreateBoardRequest) (*BoardResponse, error) {
|
func (s *kanbanService) CreateBoard(ctx context.Context, req CreateBoardRequest) (*BoardResponse, error) {
|
||||||
s.logger.Info("Creating new board", map[string]interface{}{
|
s.logger.Info("Creating new board", map[string]interface{}{
|
||||||
"name": req.Name,
|
"name": req.Name,
|
||||||
"user_id": req.UserID,
|
"user_id": req.UserID,
|
||||||
"is_default": req.IsDefault,
|
"is_default": req.IsDefault,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -80,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]
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user