diff --git a/cmd/web/main.go b/cmd/web/main.go index 14eaeff..483b652 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -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 diff --git a/cmd/web/router/routes.go b/cmd/web/router/routes.go index e6be0dd..53cfbbe 100644 --- a/cmd/web/router/routes.go +++ b/cmd/web/router/routes.go @@ -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) { diff --git a/internal/kanban/entity.go b/internal/kanban/entity.go new file mode 100644 index 0000000..695105b --- /dev/null +++ b/internal/kanban/entity.go @@ -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 + } + } +} diff --git a/internal/kanban/form.go b/internal/kanban/form.go new file mode 100644 index 0000000..dbae879 --- /dev/null +++ b/internal/kanban/form.go @@ -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, + } +} diff --git a/internal/kanban/handler.go b/internal/kanban/handler.go new file mode 100644 index 0000000..71d36d3 --- /dev/null +++ b/internal/kanban/handler.go @@ -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") +} diff --git a/internal/kanban/repository.go b/internal/kanban/repository.go new file mode 100644 index 0000000..67b9f3d --- /dev/null +++ b/internal/kanban/repository.go @@ -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 +} diff --git a/internal/kanban/service.go b/internal/kanban/service.go new file mode 100644 index 0000000..bf7c132 --- /dev/null +++ b/internal/kanban/service.go @@ -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 +} diff --git a/internal/kanban/validation.go b/internal/kanban/validation.go new file mode 100644 index 0000000..e6ed792 --- /dev/null +++ b/internal/kanban/validation.go @@ -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) +}