kanban dashboard

This commit is contained in:
Mazyar
2026-01-05 15:36:20 +03:30
parent ec39dae734
commit e6005ee0ee
8 changed files with 1961 additions and 5 deletions
+11 -4
View File
@@ -102,6 +102,7 @@ import (
"tm/internal/customer"
"tm/internal/feedback"
"tm/internal/inquiry"
"tm/internal/kanban"
"tm/internal/notification"
"tm/internal/scraper"
"tm/internal/tender"
@@ -167,13 +168,17 @@ func main() {
inquiryRepository := inquiry.NewRepository(mongoManager, logger)
contactRepository := contact.NewContactRepository(mongoManager, logger)
cmsRepository := cms.NewRepository(mongoManager, logger)
boardRepository := kanban.NewBoardRepository(mongoManager, logger)
columnRepository := kanban.NewColumnRepository(mongoManager, logger)
cardRepository := kanban.NewCardRepository(mongoManager, logger)
logger.Info("Repositories initialized successfully", map[string]interface{}{
"repositories": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "contact", "cms"},
"repositories": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "contact", "cms", "kanban_board", "kanban_column", "kanban_card"},
})
// Initialize validation services
userValidator := user.NewValidationService()
customerValidator := customer.NewValidationService()
_ = kanban.NewValidationService() // Register hexcolor validator
// Initialize services with repositories
userService := user.NewService(userRepository, logger, userAuthService, userValidator, notificationSDK)
@@ -189,8 +194,9 @@ func main() {
contactService := contact.NewService(contactRepository, logger, notificationSDK)
cmsService := cms.NewService(cmsRepository, logger)
scraperService := scraper.NewService(conf.Scraper.BaseURL, conf.Scraper.Timeout, logger)
kanbanService := kanban.NewService(boardRepository, columnRepository, cardRepository, logger)
logger.Info("Services initialized successfully", map[string]interface{}{
"services": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper"},
"services": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper", "kanban"},
})
// Initialize handlers with services
@@ -207,15 +213,16 @@ func main() {
contactHandler := contact.NewHandler(contactService, hcaptchaVerifier)
cmsHandler := cms.NewHandler(cmsService)
scraperHandler := scraper.NewHandler(scraperService)
kanbanHandler := kanban.NewHandler(kanbanService)
logger.Info("Handlers initialized successfully", map[string]interface{}{
"handlers": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper"},
"handlers": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper", "kanban"},
})
// Initialize HTTP server
e := bootstrap.InitHTTPServer(conf, logger)
// Register routes
router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, scraperHandler, xssPolicy)
router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, scraperHandler, kanbanHandler, xssPolicy)
router.RegisterPublicRoutes(e, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, companyHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, xssPolicy)
// Start server
+9 -1
View File
@@ -9,6 +9,7 @@ import (
"tm/internal/customer"
"tm/internal/feedback"
"tm/internal/inquiry"
"tm/internal/kanban"
"tm/internal/notification"
"tm/internal/scraper"
"tm/internal/tender"
@@ -19,7 +20,7 @@ import (
"github.com/labstack/echo/v4"
)
func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler *company.Handler, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, scraperHandler *scraper.Handler, xssPolicy *security.XSSPolicy) {
func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler *company.Handler, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, scraperHandler *scraper.Handler, kanbanHandler *kanban.Handler, xssPolicy *security.XSSPolicy) {
adminV1 := e.Group("/admin/v1")
// Add CSP middleware to admin routes (stricter policy)
@@ -183,6 +184,13 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler
scraperGP.Use(userHandler.AuthMiddleware())
scraperGP.POST("/download", scraperHandler.ScrapeDocuments)
}
// Kanban Routes
kanbanGP := adminV1.Group("/kanban")
{
kanbanGP.Use(userHandler.AuthMiddleware())
kanbanHandler.RegisterRoutes(kanbanGP)
}
}
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, xssPolicy *security.XSSPolicy) {
+125
View File
@@ -0,0 +1,125 @@
package kanban
import (
"time"
"tm/pkg/mongo"
)
// Board represents a kanban board
type Board struct {
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
}
// 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)
}
// 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
}
// CardPriority represents the priority level of a card
type CardPriority string
const (
CardPriorityLow CardPriority = "low"
CardPriorityMedium CardPriority = "medium"
CardPriorityHigh CardPriority = "high"
CardPriorityUrgent CardPriority = "urgent"
)
// GetID returns the board ID
func (b *Board) GetID() string {
return b.ID.Hex()
}
// GetID returns the column ID
func (c *Column) GetID() string {
return c.ID.Hex()
}
// GetID returns the card ID
func (c *Card) GetID() string {
return c.ID.Hex()
}
// IsOverLimit returns true if the column has a WIP limit and is over it
func (c *Column) IsOverLimit(cardCount int) bool {
if c.Limit == nil {
return false
}
return cardCount > *c.Limit
}
// UpdateOrder updates the column's display order
func (c *Column) UpdateOrder(order int) {
c.Order = order
c.UpdatedAt = time.Now().Unix()
}
// UpdateOrder updates the card's position within the column
func (c *Card) UpdateOrder(order int) {
c.Order = order
c.UpdatedAt = time.Now().Unix()
}
// MoveToColumn moves the card to a different column and resets order
func (c *Card) MoveToColumn(columnID string, order int) {
c.ColumnID = columnID
c.Order = order
c.UpdatedAt = time.Now().Unix()
}
// UpdatePriority updates the card's priority
func (c *Card) UpdatePriority(priority CardPriority) {
c.Priority = priority
c.UpdatedAt = time.Now().Unix()
}
// AddLabel adds a label to the card
func (c *Card) AddLabel(label string) {
if c.Labels == nil {
c.Labels = []string{}
}
// Check if label already exists
for _, l := range c.Labels {
if l == label {
return
}
}
c.Labels = append(c.Labels, label)
c.UpdatedAt = time.Now().Unix()
}
// RemoveLabel removes a label from the card
func (c *Card) RemoveLabel(label string) {
if c.Labels == nil {
return
}
for i, l := range c.Labels {
if l == label {
c.Labels = append(c.Labels[:i], c.Labels[i+1:]...)
c.UpdatedAt = time.Now().Unix()
return
}
}
}
+176
View File
@@ -0,0 +1,176 @@
package kanban
import "tm/pkg/response"
// CreateBoardRequest represents a request to create a new kanban board
type CreateBoardRequest struct {
Name string `json:"name" valid:"required~Board name is required,stringlength(1|100)~Board name must be between 1 and 100 characters"`
Description string `json:"description" valid:"stringlength(0|500)~Description must not exceed 500 characters"`
IsDefault bool `json:"is_default"`
UserID string `json:"-"` // Set from context/auth middleware
}
// UpdateBoardRequest represents a request to update an existing board
type UpdateBoardRequest struct {
ID string `json:"id" valid:"required~Board ID is required"`
Name *string `json:"name,omitempty" valid:"stringlength(1|100)~Board name must be between 1 and 100 characters"`
Description *string `json:"description,omitempty" valid:"stringlength(0|500)~Description must not exceed 500 characters"`
IsDefault *bool `json:"is_default,omitempty"`
}
// CreateColumnRequest represents a request to create a new column
type CreateColumnRequest struct {
BoardID string `json:"board_id" valid:"required~Board ID is required"`
Name string `json:"name" valid:"required~Column name is required,stringlength(1|50)~Column name must be between 1 and 50 characters"`
Color string `json:"color" valid:"hexcolor~Color must be a valid hex color"`
Order int `json:"order"`
Limit *int `json:"limit,omitempty" valid:"range(1|100)~WIP limit must be between 1 and 100"`
}
// 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"`
}
// 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
}
// UpdateCardRequest represents a request to update an existing card
type UpdateCardRequest struct {
ID string `json:"id" valid:"required~Card ID is required"`
Title *string `json:"title,omitempty" valid:"stringlength(1|200)~Title must be between 1 and 200 characters"`
Priority *CardPriority `json:"priority,omitempty"`
Labels []string `json:"labels,omitempty"`
DueDate *int64 `json:"due_date,omitempty"` // Unix timestamp
}
// 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"`
}
// 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"`
}
// ReorderColumnsRequest represents a request to reorder columns within a board
type ReorderColumnsRequest struct {
BoardID string `json:"board_id" valid:"required~Board ID is required"`
ColumnOrders map[string]int `json:"column_orders" valid:"required~Column orders are required"`
}
// 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"`
}
// 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"`
}
// CardResponse represents a card in API responses
type CardResponse struct {
ID string `json:"id"`
ColumnID string `json:"column_id"`
TenderID string `json:"tender_id"`
Title string `json:"title"`
Order int `json:"order"`
Priority CardPriority `json:"priority"`
Labels []string `json:"labels,omitempty"`
DueDate *int64 `json:"due_date,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// BoardsListResponse represents the response for listing boards
type BoardsListResponse struct {
Boards []BoardResponse `json:"boards"`
Meta *response.Meta `json:"-"`
}
// BoardDetailResponse represents detailed board response with full column and card data
type BoardDetailResponse struct {
Board BoardResponse `json:"board"`
Meta *response.Meta `json:"-"`
}
// ToBoardResponse converts a Board entity to BoardResponse
func (b *Board) ToResponse() *BoardResponse {
return &BoardResponse{
ID: b.ID.Hex(),
Name: b.Name,
Description: b.Description,
Columns: make([]ColumnResponse, 0), // Will be populated by service
IsDefault: b.IsDefault,
UserID: b.UserID,
CreatedAt: b.CreatedAt,
UpdatedAt: b.UpdatedAt,
}
}
// 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,
}
}
// ToCardResponse converts a Card entity to CardResponse
func (c *Card) ToResponse() *CardResponse {
return &CardResponse{
ID: c.ID.Hex(),
ColumnID: c.ColumnID,
TenderID: c.TenderID,
Title: c.Title,
Order: c.Order,
Priority: c.Priority,
Labels: c.Labels,
DueDate: c.DueDate,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
}
+471
View File
@@ -0,0 +1,471 @@
package kanban
import (
"net/http"
"strconv"
"tm/pkg/response"
"github.com/labstack/echo/v4"
)
// Handler handles kanban HTTP requests
type Handler struct {
service Service
}
// NewHandler creates a new kanban handler
func NewHandler(service Service) *Handler {
return &Handler{
service: service,
}
}
// RegisterRoutes registers kanban routes
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)
// 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)
// 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)
}
}
// CreateBoard creates a new kanban board
// @Summary Create a new kanban board
// @Description Create a new kanban board with the provided information
// @Tags kanban
// @Accept json
// @Produce json
// @Param board body CreateBoardRequest true "Board information"
// @Success 201 {object} response.Response{data=BoardResponse}
// @Failure 400 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /kanban/boards [post]
func (h *Handler) CreateBoard(c echo.Context) error {
var req CreateBoardRequest
if err := c.Bind(&req); err != nil {
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)
if !exists {
return response.Unauthorized(c, "User not authenticated")
}
req.UserID = userID
board, err := h.service.CreateBoard(c.Request().Context(), req)
if err != nil {
return response.InternalServerError(c, "Failed to create board")
}
return response.Created(c, board, "Board created successfully")
}
// GetBoardByID retrieves a board by ID
// @Summary Get a kanban board by ID
// @Description Retrieve a kanban board with all its columns and cards
// @Tags kanban
// @Accept json
// @Produce json
// @Param id path string true "Board ID"
// @Success 200 {object} response.Response{data=BoardDetailResponse}
// @Failure 404 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /kanban/boards/{id} [get]
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")
}
return response.Success(c, board, "Board retrieved successfully")
}
// GetBoardsByUser retrieves all boards for the authenticated user
// @Summary Get all kanban boards for user
// @Description Retrieve all kanban boards belonging to the authenticated user
// @Tags kanban
// @Accept json
// @Produce json
// @Success 200 {object} response.Response{data=BoardsListResponse}
// @Failure 500 {object} response.Response
// @Router /kanban/boards [get]
func (h *Handler) GetBoardsByUser(c echo.Context) error {
userID, exists := c.Get("user_id").(string)
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")
}
return response.Success(c, boards, "Boards retrieved successfully")
}
// GetDefaultBoard retrieves the default board for the authenticated user
// @Summary Get default kanban board
// @Description Retrieve the default kanban board for the authenticated user
// @Tags kanban
// @Accept json
// @Produce json
// @Success 200 {object} response.Response{data=BoardDetailResponse}
// @Failure 404 {object} response.Response
// @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)
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")
}
return response.Success(c, board, "Default board retrieved successfully")
}
// UpdateBoard updates an existing board
// @Summary Update a kanban board
// @Description Update an existing kanban board with the provided information
// @Tags kanban
// @Accept json
// @Produce json
// @Param id path string true "Board ID"
// @Param board body UpdateBoardRequest true "Board update information"
// @Success 200 {object} response.Response{data=BoardResponse}
// @Failure 400 {object} response.Response
// @Failure 404 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /kanban/boards/{id} [put]
func (h *Handler) UpdateBoard(c echo.Context) error {
var req UpdateBoardRequest
if err := c.Bind(&req); err != nil {
return response.ValidationError(c, "Invalid request data", err.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")
}
return response.Success(c, board, "Board updated successfully")
}
// DeleteBoard deletes a board
// @Summary Delete a kanban board
// @Description Delete a kanban board and all its columns and cards
// @Tags kanban
// @Accept json
// @Produce json
// @Param id path string true "Board ID"
// @Success 204 {object} response.Response
// @Failure 404 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /kanban/boards/{id} [delete]
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")
}
return c.NoContent(http.StatusNoContent)
}
// CreateColumn creates a new column
// @Summary Create a new column
// @Description Create a new column in a kanban board
// @Tags kanban
// @Accept json
// @Produce json
// @Param column body CreateColumnRequest true "Column information"
// @Success 201 {object} response.Response{data=ColumnResponse}
// @Failure 400 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /kanban/columns [post]
func (h *Handler) CreateColumn(c echo.Context) error {
var req CreateColumnRequest
if err := c.Bind(&req); err != nil {
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")
}
return response.Created(c, column, "Column created successfully")
}
// UpdateColumn updates an existing column
// @Summary Update a column
// @Description Update an existing column in a kanban board
// @Tags kanban
// @Accept json
// @Produce json
// @Param id path string true "Column ID"
// @Param column body UpdateColumnRequest true "Column update information"
// @Success 200 {object} response.Response{data=ColumnResponse}
// @Failure 400 {object} response.Response
// @Failure 404 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /kanban/columns/{id} [put]
func (h *Handler) UpdateColumn(c echo.Context) error {
var req UpdateColumnRequest
if err := c.Bind(&req); err != nil {
return response.ValidationError(c, "Invalid request data", err.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")
}
return response.Success(c, column, "Column updated successfully")
}
// DeleteColumn deletes a column
// @Summary Delete a column
// @Description Delete a column and move its cards to the next column
// @Tags kanban
// @Accept json
// @Produce json
// @Param id path string true "Column ID"
// @Success 204 {object} response.Response
// @Failure 404 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /kanban/columns/{id} [delete]
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")
}
return c.NoContent(http.StatusNoContent)
}
// ReorderColumns reorders columns within a board
// @Summary Reorder columns
// @Description Reorder columns within a kanban board
// @Tags kanban
// @Accept json
// @Produce json
// @Param reorder body ReorderColumnsRequest true "Column reorder information"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /kanban/columns/reorder [put]
func (h *Handler) ReorderColumns(c echo.Context) error {
var req ReorderColumnsRequest
if err := c.Bind(&req); err != nil {
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")
}
return response.Success(c, map[string]string{"message": "Columns reordered successfully"}, "Columns reordered successfully")
}
// CreateCard creates a new card
// @Summary Create a new card
// @Description Create a new card in a kanban column
// @Tags kanban
// @Accept json
// @Produce json
// @Param card body CreateCardRequest true "Card information"
// @Success 201 {object} response.Response{data=CardResponse}
// @Failure 400 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /kanban/cards [post]
func (h *Handler) CreateCard(c echo.Context) error {
var req CreateCardRequest
if err := c.Bind(&req); err != nil {
return response.ValidationError(c, "Invalid request data", err.Error())
}
// Set default priority if not provided
if req.Priority == "" {
req.Priority = CardPriorityMedium
}
// Parse order from query param if provided
if orderStr := c.QueryParam("order"); orderStr != "" {
if order, err := strconv.Atoi(orderStr); err == nil {
req.Order = order
}
}
// Parse due_date from query param if provided
if dueDateStr := c.QueryParam("due_date"); dueDateStr != "" {
if dueDate, err := strconv.ParseInt(dueDateStr, 10, 64); err == nil {
req.DueDate = &dueDate
}
}
card, err := h.service.CreateCard(c.Request().Context(), req)
if err != nil {
return response.InternalServerError(c, "Failed to create card")
}
return response.Created(c, card, "Card created successfully")
}
// GetCardByID retrieves a card by ID
// @Summary Get a card by ID
// @Description Retrieve a kanban card by its ID
// @Tags kanban
// @Accept json
// @Produce json
// @Param id path string true "Card ID"
// @Success 200 {object} response.Response{data=CardResponse}
// @Failure 404 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /kanban/cards/{id} [get]
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")
}
return response.Success(c, card, "Card retrieved successfully")
}
// UpdateCard updates an existing card
// @Summary Update a card
// @Description Update an existing kanban card
// @Tags kanban
// @Accept json
// @Produce json
// @Param id path string true "Card ID"
// @Param card body UpdateCardRequest true "Card update information"
// @Success 200 {object} response.Response{data=CardResponse}
// @Failure 400 {object} response.Response
// @Failure 404 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /kanban/cards/{id} [put]
func (h *Handler) UpdateCard(c echo.Context) error {
var req UpdateCardRequest
if err := c.Bind(&req); err != nil {
return response.ValidationError(c, "Invalid request data", err.Error())
}
req.ID = c.Param("id")
// Parse due_date from query param if provided
if dueDateStr := c.QueryParam("due_date"); dueDateStr != "" {
if dueDate, err := strconv.ParseInt(dueDateStr, 10, 64); err == nil {
req.DueDate = &dueDate
}
}
card, err := h.service.UpdateCard(c.Request().Context(), req)
if err != nil {
return response.InternalServerError(c, "Failed to update card")
}
return response.Success(c, card, "Card updated successfully")
}
// DeleteCard deletes a card
// @Summary Delete a card
// @Description Delete a kanban card
// @Tags kanban
// @Accept json
// @Produce json
// @Param id path string true "Card ID"
// @Success 204 {object} response.Response
// @Failure 404 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /kanban/cards/{id} [delete]
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")
}
return c.NoContent(http.StatusNoContent)
}
// MoveCard moves a card to a different column
// @Summary Move a card
// @Description Move a kanban card to a different column
// @Tags kanban
// @Accept json
// @Produce json
// @Param move body MoveCardRequest true "Card move information"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 404 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /kanban/cards/move [put]
func (h *Handler) MoveCard(c echo.Context) error {
var req MoveCardRequest
if err := c.Bind(&req); err != nil {
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")
}
return response.Success(c, map[string]string{"message": "Card moved successfully"}, "Card moved successfully")
}
// ReorderCards reorders cards within a column
// @Summary Reorder cards
// @Description Reorder cards within a kanban column
// @Tags kanban
// @Accept json
// @Produce json
// @Param reorder body ReorderCardsRequest true "Card reorder information"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /kanban/cards/reorder [put]
func (h *Handler) ReorderCards(c echo.Context) error {
var req ReorderCardsRequest
if err := c.Bind(&req); err != nil {
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")
}
return response.Success(c, map[string]string{"message": "Cards reordered successfully"}, "Cards reordered successfully")
}
+604
View File
@@ -0,0 +1,604 @@
package kanban
import (
"context"
"time"
"tm/pkg/logger"
orm "tm/pkg/mongo"
"go.mongodb.org/mongo-driver/v2/bson"
)
// BoardRepository interface defines board data access methods
type BoardRepository interface {
// Board CRUD operations
CreateBoard(ctx context.Context, board *Board) error
GetBoardByID(ctx context.Context, id string) (*Board, error)
GetBoardsByUserID(ctx context.Context, userID string) ([]Board, error)
GetDefaultBoard(ctx context.Context, userID string) (*Board, error)
UpdateBoard(ctx context.Context, board *Board) error
DeleteBoard(ctx context.Context, id string) error
}
// ColumnRepository interface defines column data access methods
type ColumnRepository interface {
// Column CRUD operations
CreateColumn(ctx context.Context, column *Column) error
GetColumnByID(ctx context.Context, id string) (*Column, error)
GetColumnsByBoardID(ctx context.Context, boardID string) ([]Column, error)
UpdateColumn(ctx context.Context, column *Column) error
DeleteColumn(ctx context.Context, id string) error
UpdateColumnOrder(ctx context.Context, boardID string, columnOrders map[string]int) error
}
// CardRepository interface defines card data access methods
type CardRepository interface {
// Card CRUD operations
CreateCard(ctx context.Context, card *Card) error
GetCardByID(ctx context.Context, id string) (*Card, error)
GetCardsByColumnID(ctx context.Context, columnID string) ([]Card, error)
GetCardsByTenderID(ctx context.Context, tenderID string) ([]Card, error)
UpdateCard(ctx context.Context, card *Card) error
DeleteCard(ctx context.Context, id string) error
MoveCard(ctx context.Context, cardID, targetColumnID string, newOrder int) error
UpdateCardOrder(ctx context.Context, columnID string, cardOrders map[string]int) error
}
func boardCollectionName() string {
return "kanban_boards"
}
func columnCollectionName() string {
return "kanban_columns"
}
func cardCollectionName() string {
return "kanban_cards"
}
// boardRepository implements BoardRepository interface using MongoDB ORM
type boardRepository struct {
ormRepo orm.Repository[Board]
mongoManager *orm.ConnectionManager
logger logger.Logger
}
// columnRepository implements ColumnRepository interface using MongoDB ORM
type columnRepository struct {
ormRepo orm.Repository[Column]
mongoManager *orm.ConnectionManager
logger logger.Logger
}
// cardRepository implements CardRepository interface using MongoDB ORM
type cardRepository struct {
ormRepo orm.Repository[Card]
mongoManager *orm.ConnectionManager
logger logger.Logger
}
// NewBoardRepository creates a new board repository
func NewBoardRepository(mongoManager *orm.ConnectionManager, logger logger.Logger) BoardRepository {
// Create indexes for boards collection
boardIndexes := []orm.Index{
*orm.NewIndex("user_id_idx", bson.D{{Key: "user_id", Value: 1}}),
*orm.NewIndex("is_default_idx", bson.D{{Key: "is_default", Value: 1}}),
*orm.NewIndex("created_at_idx", bson.D{{Key: "created_at", Value: -1}}),
*orm.NewIndex("user_default_idx", bson.D{
{Key: "user_id", Value: 1},
{Key: "is_default", Value: 1},
}),
}
// Create indexes
if err := mongoManager.CreateIndexes(boardCollectionName(), boardIndexes); err != nil {
logger.Warn("Failed to create board indexes", map[string]interface{}{
"error": err.Error(),
})
}
boardOrmRepo := orm.NewRepository[Board](mongoManager.GetCollection(boardCollectionName()), logger)
return &boardRepository{
ormRepo: boardOrmRepo,
mongoManager: mongoManager,
logger: logger,
}
}
// NewColumnRepository creates a new column repository
func NewColumnRepository(mongoManager *orm.ConnectionManager, logger logger.Logger) ColumnRepository {
// Create indexes for columns collection
columnIndexes := []orm.Index{
*orm.NewIndex("board_id_idx", bson.D{{Key: "board_id", Value: 1}}),
*orm.NewIndex("order_idx", bson.D{{Key: "order", Value: 1}}),
*orm.NewIndex("created_at_idx", bson.D{{Key: "created_at", Value: -1}}),
*orm.NewIndex("board_order_idx", bson.D{
{Key: "board_id", Value: 1},
{Key: "order", Value: 1},
}),
}
// Create indexes
if err := mongoManager.CreateIndexes(columnCollectionName(), columnIndexes); err != nil {
logger.Warn("Failed to create column indexes", map[string]interface{}{
"error": err.Error(),
})
}
columnOrmRepo := orm.NewRepository[Column](mongoManager.GetCollection(columnCollectionName()), logger)
return &columnRepository{
ormRepo: columnOrmRepo,
mongoManager: mongoManager,
logger: logger,
}
}
// NewCardRepository creates a new card repository
func NewCardRepository(mongoManager *orm.ConnectionManager, logger logger.Logger) CardRepository {
// Create indexes for cards collection
cardIndexes := []orm.Index{
*orm.NewIndex("column_id_idx", bson.D{{Key: "column_id", Value: 1}}),
*orm.NewIndex("tender_id_idx", bson.D{{Key: "tender_id", Value: 1}}),
*orm.NewIndex("order_idx", bson.D{{Key: "order", Value: 1}}),
*orm.NewIndex("priority_idx", bson.D{{Key: "priority", Value: 1}}),
*orm.NewIndex("due_date_idx", bson.D{{Key: "due_date", Value: 1}}),
*orm.NewIndex("created_at_idx", bson.D{{Key: "created_at", Value: -1}}),
*orm.NewIndex("column_order_idx", bson.D{
{Key: "column_id", Value: 1},
{Key: "order", Value: 1},
}),
}
// Create indexes
if err := mongoManager.CreateIndexes(cardCollectionName(), cardIndexes); err != nil {
logger.Warn("Failed to create card indexes", map[string]interface{}{
"error": err.Error(),
})
}
cardOrmRepo := orm.NewRepository[Card](mongoManager.GetCollection(cardCollectionName()), logger)
return &cardRepository{
ormRepo: cardOrmRepo,
mongoManager: mongoManager,
logger: logger,
}
}
// BoardRepository implementation
func (r *boardRepository) CreateBoard(ctx context.Context, board *Board) error {
now := time.Now().Unix()
board.SetCreatedAt(now)
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,
})
return err
}
r.logger.Info("Board created successfully", map[string]interface{}{
"board_id": board.ID,
"name": board.Name,
"user_id": board.UserID,
})
return nil
}
func (r *boardRepository) GetBoardByID(ctx context.Context, id string) (*Board, error) {
board, err := r.ormRepo.FindByID(ctx, id)
if err != nil {
r.logger.Error("Failed to get board by ID", map[string]interface{}{
"board_id": id,
"error": err.Error(),
})
return nil, err
}
return board, nil
}
func (r *boardRepository) GetBoardsByUserID(ctx context.Context, userID string) ([]Board, error) {
filter := bson.M{"user_id": userID}
pagination := orm.Pagination{
SortField: "created_at",
SortOrder: -1, // Newest first
}
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to get boards by user ID", map[string]interface{}{
"user_id": userID,
"error": err.Error(),
})
return nil, err
}
return result.Items, nil
}
func (r *boardRepository) GetDefaultBoard(ctx context.Context, userID string) (*Board, error) {
filter := bson.M{
"user_id": userID,
"is_default": true,
}
pagination := orm.Pagination{Limit: 1}
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to get default board", map[string]interface{}{
"user_id": userID,
"error": err.Error(),
})
return nil, err
}
if len(result.Items) == 0 {
return nil, orm.ErrDocumentNotFound
}
return &result.Items[0], nil
}
func (r *boardRepository) UpdateBoard(ctx context.Context, board *Board) error {
board.UpdatedAt = time.Now().Unix()
err := r.ormRepo.Update(ctx, board)
if err != nil {
r.logger.Error("Failed to update board", map[string]interface{}{
"board_id": board.ID,
"error": err.Error(),
})
return err
}
r.logger.Info("Board updated successfully", map[string]interface{}{
"board_id": board.ID,
"name": board.Name,
})
return nil
}
func (r *boardRepository) DeleteBoard(ctx context.Context, id string) error {
err := r.ormRepo.Delete(ctx, id)
if err != nil {
r.logger.Error("Failed to delete board", map[string]interface{}{
"board_id": id,
"error": err.Error(),
})
return err
}
r.logger.Info("Board deleted successfully", map[string]interface{}{
"board_id": id,
})
return nil
}
// ColumnRepository implementation
func (r *columnRepository) CreateColumn(ctx context.Context, column *Column) error {
now := time.Now().Unix()
column.SetCreatedAt(now)
err := r.ormRepo.Create(ctx, column)
if err != nil {
r.logger.Error("Failed to create column", map[string]interface{}{
"error": err.Error(),
"name": column.Name,
"board_id": column.BoardID,
})
return err
}
r.logger.Info("Column created successfully", map[string]interface{}{
"column_id": column.ID,
"name": column.Name,
"board_id": column.BoardID,
})
return nil
}
func (r *columnRepository) GetColumnByID(ctx context.Context, id string) (*Column, error) {
column, err := r.ormRepo.FindByID(ctx, id)
if err != nil {
r.logger.Error("Failed to get column by ID", map[string]interface{}{
"column_id": id,
"error": err.Error(),
})
return nil, err
}
return column, nil
}
func (r *columnRepository) GetColumnsByBoardID(ctx context.Context, boardID string) ([]Column, error) {
filter := bson.M{"board_id": boardID}
pagination := orm.Pagination{
SortField: "order",
SortOrder: 1, // Ascending order
}
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to get columns by board ID", map[string]interface{}{
"board_id": boardID,
"error": err.Error(),
})
return nil, err
}
return result.Items, nil
}
func (r *columnRepository) UpdateColumn(ctx context.Context, column *Column) error {
column.UpdatedAt = time.Now().Unix()
err := r.ormRepo.Update(ctx, column)
if err != nil {
r.logger.Error("Failed to update column", map[string]interface{}{
"column_id": column.ID,
"error": err.Error(),
})
return err
}
r.logger.Info("Column updated successfully", map[string]interface{}{
"column_id": column.ID,
"name": column.Name,
})
return nil
}
func (r *columnRepository) DeleteColumn(ctx context.Context, id string) error {
err := r.ormRepo.Delete(ctx, id)
if err != nil {
r.logger.Error("Failed to delete column", map[string]interface{}{
"column_id": id,
"error": err.Error(),
})
return err
}
r.logger.Info("Column deleted successfully", map[string]interface{}{
"column_id": id,
})
return nil
}
func (r *columnRepository) UpdateColumnOrder(ctx context.Context, boardID string, columnOrders map[string]int) error {
// Update each column's order
for columnID, order := range columnOrders {
column, err := r.ormRepo.FindByID(ctx, columnID)
if err != nil {
r.logger.Error("Failed to find column for order update", map[string]interface{}{
"column_id": columnID,
"error": err.Error(),
})
return err
}
column.Order = order
column.UpdatedAt = time.Now().Unix()
err = r.ormRepo.Update(ctx, column)
if err != nil {
r.logger.Error("Failed to update column order", map[string]interface{}{
"column_id": columnID,
"order": order,
"error": err.Error(),
})
return err
}
}
r.logger.Info("Column orders updated successfully", map[string]interface{}{
"board_id": boardID,
"count": len(columnOrders),
})
return nil
}
// CardRepository implementation
func (r *cardRepository) CreateCard(ctx context.Context, card *Card) error {
now := time.Now().Unix()
card.SetCreatedAt(now)
err := r.ormRepo.Create(ctx, card)
if err != nil {
r.logger.Error("Failed to create card", map[string]interface{}{
"error": err.Error(),
"title": card.Title,
"tender_id": card.TenderID,
"column_id": card.ColumnID,
})
return err
}
r.logger.Info("Card created successfully", map[string]interface{}{
"card_id": card.ID,
"title": card.Title,
"tender_id": card.TenderID,
"column_id": card.ColumnID,
})
return nil
}
func (r *cardRepository) GetCardByID(ctx context.Context, id string) (*Card, error) {
card, err := r.ormRepo.FindByID(ctx, id)
if err != nil {
r.logger.Error("Failed to get card by ID", map[string]interface{}{
"card_id": id,
"error": err.Error(),
})
return nil, err
}
return card, nil
}
func (r *cardRepository) GetCardsByColumnID(ctx context.Context, columnID string) ([]Card, error) {
filter := bson.M{"column_id": columnID}
pagination := orm.Pagination{
SortField: "order",
SortOrder: 1, // Ascending order
}
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to get cards by column ID", map[string]interface{}{
"column_id": columnID,
"error": err.Error(),
})
return nil, err
}
return result.Items, nil
}
func (r *cardRepository) GetCardsByTenderID(ctx context.Context, tenderID string) ([]Card, error) {
filter := bson.M{"tender_id": tenderID}
pagination := orm.Pagination{
SortField: "created_at",
SortOrder: -1, // Newest first
}
result, err := r.ormRepo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to get cards by tender ID", map[string]interface{}{
"tender_id": tenderID,
"error": err.Error(),
})
return nil, err
}
return result.Items, nil
}
func (r *cardRepository) UpdateCard(ctx context.Context, card *Card) error {
card.UpdatedAt = time.Now().Unix()
err := r.ormRepo.Update(ctx, card)
if err != nil {
r.logger.Error("Failed to update card", map[string]interface{}{
"card_id": card.ID,
"error": err.Error(),
})
return err
}
r.logger.Info("Card updated successfully", map[string]interface{}{
"card_id": card.ID,
"title": card.Title,
})
return nil
}
func (r *cardRepository) DeleteCard(ctx context.Context, id string) error {
err := r.ormRepo.Delete(ctx, id)
if err != nil {
r.logger.Error("Failed to delete card", map[string]interface{}{
"card_id": id,
"error": err.Error(),
})
return err
}
r.logger.Info("Card deleted successfully", map[string]interface{}{
"card_id": id,
})
return nil
}
func (r *cardRepository) MoveCard(ctx context.Context, cardID, targetColumnID string, newOrder int) error {
card, err := r.ormRepo.FindByID(ctx, cardID)
if err != nil {
r.logger.Error("Failed to find card for move", map[string]interface{}{
"card_id": cardID,
"error": err.Error(),
})
return err
}
oldColumnID := card.ColumnID
oldOrder := card.Order
// Update card position
card.MoveToColumn(targetColumnID, newOrder)
err = r.ormRepo.Update(ctx, card)
if err != nil {
r.logger.Error("Failed to move card", map[string]interface{}{
"card_id": cardID,
"target_column_id": targetColumnID,
"new_order": newOrder,
"error": err.Error(),
})
return err
}
r.logger.Info("Card moved successfully", map[string]interface{}{
"card_id": cardID,
"old_column_id": oldColumnID,
"target_column_id": targetColumnID,
"old_order": oldOrder,
"new_order": newOrder,
})
return nil
}
func (r *cardRepository) UpdateCardOrder(ctx context.Context, columnID string, cardOrders map[string]int) error {
// Update each card's order
for cardID, order := range cardOrders {
// Find the card
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(),
})
return err
}
// Update the order
card.Order = order
card.UpdatedAt = time.Now().Unix()
// Save the card
err = r.ormRepo.Update(ctx, card)
if err != nil {
r.logger.Error("Failed to update card order", map[string]interface{}{
"card_id": cardID,
"order": order,
"column_id": columnID,
"error": err.Error(),
})
return err
}
}
r.logger.Info("Card orders updated successfully", map[string]interface{}{
"column_id": columnID,
"count": len(cardOrders),
})
return nil
}
+524
View File
@@ -0,0 +1,524 @@
package kanban
import (
"context"
"fmt"
"sort"
"tm/pkg/logger"
)
// KanbanService interface defines kanban business logic operations
type Service interface {
// Board operations
CreateBoard(ctx context.Context, req CreateBoardRequest) (*BoardResponse, error)
GetBoardByID(ctx context.Context, id 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
// 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
// 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
}
// kanbanService implements KanbanService interface
type kanbanService struct {
boardRepository BoardRepository
columnRepository ColumnRepository
cardRepository CardRepository
logger logger.Logger
}
// NewService creates a new kanban service
func NewService(boardRepo BoardRepository, columnRepo ColumnRepository, cardRepo CardRepository, logger logger.Logger) Service {
return &kanbanService{
boardRepository: boardRepo,
columnRepository: columnRepo,
cardRepository: cardRepo,
logger: logger,
}
}
// CreateBoard creates a new kanban board
func (s *kanbanService) CreateBoard(ctx context.Context, req CreateBoardRequest) (*BoardResponse, error) {
s.logger.Info("Creating new board", map[string]interface{}{
"name": req.Name,
"user_id": req.UserID,
"is_default": req.IsDefault,
})
// If this is set as default, unset any existing default board for this user
if req.IsDefault {
if err := s.unsetDefaultBoard(ctx, req.UserID); err != nil {
s.logger.Warn("Failed to unset existing default board", map[string]interface{}{
"user_id": req.UserID,
"error": err.Error(),
})
}
}
board := &Board{
Name: req.Name,
Description: req.Description,
IsDefault: req.IsDefault,
UserID: req.UserID,
Columns: []Column{}, // Start with empty columns
}
if err := s.boardRepository.CreateBoard(ctx, board); err != nil {
return nil, err
}
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) {
s.logger.Info("Retrieving board by ID", map[string]interface{}{
"board_id": id,
})
board, err := s.boardRepository.GetBoardByID(ctx, id)
if err != nil {
return nil, err
}
boardResponse := board.ToResponse()
// Load columns with cards
columns, err := s.columnRepository.GetColumnsByBoardID(ctx, id)
if err != nil {
return nil, err
}
for _, column := range columns {
columnResponse := column.ToResponse()
// Load cards for this column
cards, err := s.cardRepository.GetCardsByColumnID(ctx, column.ID.Hex())
if err != nil {
s.logger.Warn("Failed to load cards for column", map[string]interface{}{
"column_id": column.ID.Hex(),
"error": err.Error(),
})
continue
}
columnResponse.CardCount = len(cards)
if column.Limit != nil && len(cards) > *column.Limit {
columnResponse.IsOverLimit = true
}
for _, card := range cards {
columnResponse.Cards = append(columnResponse.Cards, *card.ToResponse())
}
boardResponse.Columns = append(boardResponse.Columns, *columnResponse)
}
return &BoardDetailResponse{
Board: *boardResponse,
}, nil
}
// GetBoardsByUserID retrieves all boards for a user
func (s *kanbanService) GetBoardsByUserID(ctx context.Context, userID string) (*BoardsListResponse, error) {
s.logger.Info("Retrieving boards by user ID", map[string]interface{}{
"user_id": userID,
})
boards, err := s.boardRepository.GetBoardsByUserID(ctx, userID)
if err != nil {
return nil, err
}
var boardResponses []BoardResponse
for _, board := range boards {
boardResponses = append(boardResponses, *board.ToResponse())
}
return &BoardsListResponse{
Boards: boardResponses,
}, nil
}
// GetDefaultBoard retrieves the default board for a user
func (s *kanbanService) GetDefaultBoard(ctx context.Context, userID string) (*BoardDetailResponse, error) {
s.logger.Info("Retrieving default board", map[string]interface{}{
"user_id": userID,
})
board, err := s.boardRepository.GetDefaultBoard(ctx, userID)
if err != nil {
return nil, err
}
return s.GetBoardByID(ctx, board.ID.Hex())
}
// UpdateBoard updates an existing board
func (s *kanbanService) UpdateBoard(ctx context.Context, req UpdateBoardRequest) (*BoardResponse, error) {
s.logger.Info("Updating board", map[string]interface{}{
"board_id": req.ID,
})
board, err := s.boardRepository.GetBoardByID(ctx, req.ID)
if err != nil {
return nil, err
}
// Update fields
if req.Name != nil {
board.Name = *req.Name
}
if req.Description != nil {
board.Description = *req.Description
}
if req.IsDefault != nil {
// If setting as default, unset other default boards
if *req.IsDefault && !board.IsDefault {
if err := s.unsetDefaultBoard(ctx, board.UserID); err != nil {
s.logger.Warn("Failed to unset existing default board", map[string]interface{}{
"user_id": board.UserID,
"error": err.Error(),
})
}
}
board.IsDefault = *req.IsDefault
}
if err := s.boardRepository.UpdateBoard(ctx, board); err != nil {
return nil, err
}
return board.ToResponse(), nil
}
// DeleteBoard deletes a board and all its columns and cards
func (s *kanbanService) DeleteBoard(ctx context.Context, id string) error {
s.logger.Info("Deleting board", map[string]interface{}{
"board_id": id,
})
// Get all columns for this board
columns, err := s.columnRepository.GetColumnsByBoardID(ctx, id)
if err != nil {
return err
}
// Delete all cards in each column
for _, column := range columns {
cards, err := s.cardRepository.GetCardsByColumnID(ctx, column.ID.Hex())
if err != nil {
s.logger.Warn("Failed to get cards for column during board deletion", map[string]interface{}{
"column_id": column.ID.Hex(),
"error": err.Error(),
})
continue
}
for _, card := range cards {
if err := s.cardRepository.DeleteCard(ctx, card.ID.Hex()); err != nil {
s.logger.Warn("Failed to delete card during board deletion", map[string]interface{}{
"card_id": card.ID.Hex(),
"error": err.Error(),
})
}
}
// Delete the column
if err := s.columnRepository.DeleteColumn(ctx, column.ID.Hex()); err != nil {
s.logger.Warn("Failed to delete column during board deletion", map[string]interface{}{
"column_id": column.ID.Hex(),
"error": err.Error(),
})
}
}
// Finally delete the board
return s.boardRepository.DeleteBoard(ctx, id)
}
// CreateColumn creates a new column in a board
func (s *kanbanService) CreateColumn(ctx context.Context, req CreateColumnRequest) (*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 {
return nil, err
}
return column.ToResponse(), nil
}
// UpdateColumn updates an existing column
func (s *kanbanService) UpdateColumn(ctx context.Context, req UpdateColumnRequest) (*ColumnResponse, error) {
s.logger.Info("Updating column", map[string]interface{}{
"column_id": req.ID,
})
column, err := s.columnRepository.GetColumnByID(ctx, req.ID)
if err != nil {
return nil, err
}
// Update fields
if req.Name != nil {
column.Name = *req.Name
}
if req.Color != nil {
column.Color = *req.Color
}
if req.Order != nil {
column.Order = *req.Order
}
if req.Limit != nil {
column.Limit = req.Limit
}
if err := s.columnRepository.UpdateColumn(ctx, column); err != nil {
return nil, err
}
return column.ToResponse(), nil
}
// DeleteColumn deletes a column and moves all its cards to the next column
func (s *kanbanService) DeleteColumn(ctx context.Context, id 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)
}
// ReorderColumns reorders columns within a board
func (s *kanbanService) ReorderColumns(ctx context.Context, req ReorderColumnsRequest) 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)
}
// CreateCard creates a new card in a column
func (s *kanbanService) CreateCard(ctx context.Context, req CreateCardRequest) (*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,
})
// Validate that the column exists
_, err := s.columnRepository.GetColumnByID(ctx, req.ColumnID)
if err != nil {
return nil, fmt.Errorf("column not found: %w", err)
}
// Check for duplicate card (same tender in same column)
existingCards, err := s.cardRepository.GetCardsByTenderID(ctx, req.TenderID)
if err != nil {
return nil, err
}
for _, card := range existingCards {
if card.ColumnID == req.ColumnID {
return nil, fmt.Errorf("card for this tender already exists in the column")
}
}
card := &Card{
ColumnID: req.ColumnID,
TenderID: req.TenderID,
Title: req.Title,
Order: req.Order,
Priority: req.Priority,
Labels: req.Labels,
DueDate: req.DueDate,
}
if err := s.cardRepository.CreateCard(ctx, card); err != nil {
return nil, err
}
return card.ToResponse(), nil
}
// GetCardByID retrieves a card by ID
func (s *kanbanService) GetCardByID(ctx context.Context, id string) (*CardResponse, error) {
s.logger.Info("Retrieving card by ID", map[string]interface{}{
"card_id": id,
})
card, err := s.cardRepository.GetCardByID(ctx, id)
if err != nil {
return nil, err
}
return card.ToResponse(), nil
}
// UpdateCard updates an existing card
func (s *kanbanService) UpdateCard(ctx context.Context, req UpdateCardRequest) (*CardResponse, error) {
s.logger.Info("Updating card", map[string]interface{}{
"card_id": req.ID,
})
card, err := s.cardRepository.GetCardByID(ctx, req.ID)
if err != nil {
return nil, err
}
// Update fields
if req.Title != nil {
card.Title = *req.Title
}
if req.Priority != nil {
card.Priority = *req.Priority
}
if req.Labels != nil {
card.Labels = req.Labels
}
if req.DueDate != nil {
card.DueDate = req.DueDate
}
if err := s.cardRepository.UpdateCard(ctx, card); err != nil {
return nil, err
}
return card.ToResponse(), nil
}
// DeleteCard deletes a card
func (s *kanbanService) DeleteCard(ctx context.Context, id string) error {
s.logger.Info("Deleting card", map[string]interface{}{
"card_id": id,
})
return s.cardRepository.DeleteCard(ctx, id)
}
// MoveCard moves a card to a different column
func (s *kanbanService) MoveCard(ctx context.Context, req MoveCardRequest) error {
s.logger.Info("Moving card", map[string]interface{}{
"card_id": req.CardID,
"column_id": req.ColumnID,
"new_order": req.NewOrder,
})
// Validate that the target column exists
_, err := s.columnRepository.GetColumnByID(ctx, req.ColumnID)
if err != nil {
return fmt.Errorf("target column not found: %w", err)
}
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 {
s.logger.Info("Reordering cards", map[string]interface{}{
"column_id": req.ColumnID,
"count": len(req.CardOrders),
})
return s.cardRepository.UpdateCardOrder(ctx, req.ColumnID, req.CardOrders)
}
// unsetDefaultBoard removes the default flag from all boards for a user
func (s *kanbanService) unsetDefaultBoard(ctx context.Context, userID string) error {
boards, err := s.boardRepository.GetBoardsByUserID(ctx, userID)
if err != nil {
return err
}
for _, board := range boards {
if board.IsDefault {
board.IsDefault = false
if err := s.boardRepository.UpdateBoard(ctx, &board); err != nil {
return err
}
}
}
return nil
}
+41
View File
@@ -0,0 +1,41 @@
package kanban
import (
"regexp"
"github.com/asaskevich/govalidator"
)
// ValidationService defines validation methods for kanban operations
type ValidationService interface {
IsValidHexColor(hexColor string) bool
}
// validationService implements the ValidationService interface
type validationService struct {
hexColorRegex *regexp.Regexp
}
// NewValidationService creates a new validation service
func NewValidationService() ValidationService {
// Compile the hex color regex pattern (supports #RGB, #RRGGBB, #RRGGBBAA)
hexColorRegex := regexp.MustCompile(`^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$`)
// Register custom validator with govalidator
govalidator.CustomTypeTagMap.Set("hexcolor", govalidator.CustomTypeValidator(func(i interface{}, o interface{}) bool {
hexColor, ok := i.(string)
if !ok {
return false
}
return hexColorRegex.MatchString(hexColor)
}))
return &validationService{
hexColorRegex: hexColorRegex,
}
}
// IsValidHexColor checks if a string is a valid hex color code
func (v *validationService) IsValidHexColor(hexColor string) bool {
return v.hexColorRegex.MatchString(hexColor)
}