feat: add http handlers to backend

This commit is contained in:
Carlos Florêncio
2026-01-11 12:33:10 +00:00
parent a7f8c767d7
commit 6753bfa397
14 changed files with 1174 additions and 713 deletions
+16 -10
View File
@@ -1,6 +1,6 @@
// Package main is the unified entry point for Kandev.
// This single binary runs all services together with shared infrastructure.
// All communication happens over WebSocket - no REST API.
// The server exposes WebSocket and HTTP endpoints.
package main
import (
@@ -38,9 +38,10 @@ import (
orchestratorwshandlers "github.com/kandev/kandev/internal/orchestrator/wshandlers"
// Task Service packages
taskcontroller "github.com/kandev/kandev/internal/task/controller"
taskhandlers "github.com/kandev/kandev/internal/task/handlers"
"github.com/kandev/kandev/internal/task/repository"
taskservice "github.com/kandev/kandev/internal/task/service"
taskwshandlers "github.com/kandev/kandev/internal/task/wshandlers"
// ACP
"github.com/kandev/kandev/internal/orchestrator/acp"
@@ -200,10 +201,9 @@ func main() {
// Create the unified WebSocket gateway
gateway := gateways.NewGateway(log)
// Register WebSocket message handlers for each service
taskWSHandlers := taskwshandlers.NewHandlers(taskSvc, log)
taskWSHandlers.RegisterHandlers(gateway.Dispatcher)
log.Info("Registered Task Service WebSocket handlers")
// Prepare Task Service controllers for HTTP + WebSocket handlers
boardController := taskcontroller.NewBoardController(taskSvc)
taskController := taskcontroller.NewTaskController(taskSvc)
orchestratorWSHandlers := orchestratorwshandlers.NewHandlers(orchestratorSvc, log)
orchestratorWSHandlers.SetACPHandler(acpHandler)
@@ -301,7 +301,7 @@ func main() {
log.Info("Orchestrator initialized")
// ============================================
// HTTP SERVER (WebSocket endpoint only)
// HTTP SERVER (WebSocket + HTTP endpoints)
// ============================================
if cfg.Logging.Level != "debug" {
gin.SetMode(gin.ReleaseMode)
@@ -310,15 +310,20 @@ func main() {
router.Use(gin.Recovery())
router.Use(corsMiddleware())
// WebSocket endpoint - the only way to communicate with the backend
// WebSocket endpoint - primary realtime transport
gateway.SetupRoutes(router)
// Task Service handlers (HTTP + WebSocket)
taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log)
taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, log)
log.Info("Registered Task Service handlers (HTTP + WebSocket)")
// Health check (simple HTTP for load balancers/monitoring)
router.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"service": "kandev",
"mode": "websocket-only",
"mode": "websocket+http",
})
})
@@ -343,9 +348,10 @@ func main() {
}()
// Print routes summary
log.Info("WebSocket-only API configured",
log.Info("API configured",
zap.String("websocket", "/ws"),
zap.String("health", "/health"),
zap.String("http", "/api/v1"),
)
// ============================================
@@ -21,9 +21,10 @@ import (
"github.com/kandev/kandev/internal/events/bus"
gateways "github.com/kandev/kandev/internal/gateway/websocket"
"github.com/kandev/kandev/internal/orchestrator/acp"
taskcontroller "github.com/kandev/kandev/internal/task/controller"
taskhandlers "github.com/kandev/kandev/internal/task/handlers"
"github.com/kandev/kandev/internal/task/repository"
taskservice "github.com/kandev/kandev/internal/task/service"
taskwshandlers "github.com/kandev/kandev/internal/task/wshandlers"
"github.com/kandev/kandev/pkg/acp/protocol"
ws "github.com/kandev/kandev/pkg/websocket"
)
@@ -65,10 +66,6 @@ func NewTestServer(t *testing.T) *TestServer {
// Create WebSocket gateway
gateway := gateways.NewGateway(log)
// Register handlers
taskWSHandlers := taskwshandlers.NewHandlers(taskSvc, log)
taskWSHandlers.RegisterHandlers(gateway.Dispatcher)
// Start hub
go gateway.Hub.Run(ctx)
@@ -77,6 +74,12 @@ func NewTestServer(t *testing.T) *TestServer {
router := gin.New()
gateway.SetupRoutes(router)
// Register handlers (HTTP + WS)
boardController := taskcontroller.NewBoardController(taskSvc)
taskController := taskcontroller.NewTaskController(taskSvc)
taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log)
taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, log)
// Create test server
server := httptest.NewServer(router)
@@ -291,7 +294,6 @@ func TestBoardCRUD(t *testing.T) {
})
}
// ============================================
// COLUMN TESTS
// ============================================
@@ -506,7 +508,6 @@ func TestTaskCRUD(t *testing.T) {
})
}
// ============================================
// TASK STATE TESTS
// ============================================
@@ -747,7 +748,6 @@ func TestMultipleClients(t *testing.T) {
assert.Len(t, tasks, 1)
}
// ============================================
// ERROR HANDLING TESTS
// ============================================
@@ -920,10 +920,6 @@ func NewTestServerWithACP(t *testing.T) *TestServerWithACP {
// Create WebSocket gateway
gateway := gateways.NewGateway(log)
// Register handlers
taskWSHandlers := taskwshandlers.NewHandlers(taskSvc, log)
taskWSHandlers.RegisterHandlers(gateway.Dispatcher)
// Register a mock handler for task.logs that uses the ACP handler
gateway.Dispatcher.RegisterFunc(ws.ActionTaskLogs, func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
@@ -1006,6 +1002,12 @@ func NewTestServerWithACP(t *testing.T) *TestServerWithACP {
router := gin.New()
gateway.SetupRoutes(router)
// Register handlers (HTTP + WS)
boardController := taskcontroller.NewBoardController(taskSvc)
taskController := taskcontroller.NewTaskController(taskSvc)
taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log)
taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, log)
// Create test server
server := httptest.NewServer(router)
@@ -27,9 +27,10 @@ import (
"github.com/kandev/kandev/internal/orchestrator"
"github.com/kandev/kandev/internal/orchestrator/executor"
orchestratorwshandlers "github.com/kandev/kandev/internal/orchestrator/wshandlers"
taskcontroller "github.com/kandev/kandev/internal/task/controller"
taskhandlers "github.com/kandev/kandev/internal/task/handlers"
"github.com/kandev/kandev/internal/task/repository"
taskservice "github.com/kandev/kandev/internal/task/service"
taskwshandlers "github.com/kandev/kandev/internal/task/wshandlers"
"github.com/kandev/kandev/pkg/acp/protocol"
v1 "github.com/kandev/kandev/pkg/api/v1"
ws "github.com/kandev/kandev/pkg/websocket"
@@ -42,17 +43,17 @@ import (
// SimulatedAgentManagerClient simulates agent container behavior for testing.
// It publishes realistic agent events (started, ACP messages, completion) to the event bus.
type SimulatedAgentManagerClient struct {
eventBus bus.EventBus
logger *logger.Logger
mu sync.Mutex
instances map[string]*simulatedInstance
launchDelay time.Duration
executionTime time.Duration
shouldFail bool
failAfter int // Fail after N successful launches
launchCount int32
acpMessageFn func(taskID, instanceID string) []protocol.Message // Custom ACP messages
stopCh chan struct{}
eventBus bus.EventBus
logger *logger.Logger
mu sync.Mutex
instances map[string]*simulatedInstance
launchDelay time.Duration
executionTime time.Duration
shouldFail bool
failAfter int // Fail after N successful launches
launchCount int32
acpMessageFn func(taskID, instanceID string) []protocol.Message // Custom ACP messages
stopCh chan struct{}
}
// simulatedInstance tracks a simulated agent instance
@@ -201,9 +202,9 @@ func (s *SimulatedAgentManagerClient) publishACPMessages(instance *simulatedInst
TaskID: instance.taskID,
Timestamp: time.Now(),
Data: map[string]interface{}{
"progress": 25,
"stage": "starting",
"message": "Agent started processing task",
"progress": 25,
"stage": "starting",
"message": "Agent started processing task",
},
},
{
@@ -220,9 +221,9 @@ func (s *SimulatedAgentManagerClient) publishACPMessages(instance *simulatedInst
TaskID: instance.taskID,
Timestamp: time.Now(),
Data: map[string]interface{}{
"progress": 75,
"stage": "executing",
"message": "Task execution in progress",
"progress": 75,
"stage": "executing",
"message": "Task execution in progress",
},
},
}
@@ -455,10 +456,6 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer {
// Create WebSocket gateway
gateway := gateways.NewGateway(log)
// Register task handlers
taskWSHandlers := taskwshandlers.NewHandlers(taskSvc, log)
taskWSHandlers.RegisterHandlers(gateway.Dispatcher)
// Register orchestrator handlers
orchestratorWSHandlers := orchestratorwshandlers.NewHandlers(orchestratorSvc, log)
orchestratorWSHandlers.RegisterHandlers(gateway.Dispatcher)
@@ -499,6 +496,12 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer {
router := gin.New()
gateway.SetupRoutes(router)
// Register handlers (HTTP + WS)
boardController := taskcontroller.NewBoardController(taskSvc)
taskController := taskcontroller.NewTaskController(taskSvc)
taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log)
taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, log)
// Create test server
server := httptest.NewServer(router)
@@ -1388,4 +1391,3 @@ func TestOrchestratorSessionInfo(t *testing.T) {
require.True(t, ok, "auggie_session_id should be present in task metadata")
assert.Equal(t, expectedSessionID, storedSessionID, "session ID should match what the agent sent")
}
@@ -0,0 +1,133 @@
package controller
import (
"context"
"github.com/kandev/kandev/internal/task/dto"
"github.com/kandev/kandev/internal/task/service"
)
type BoardController struct {
service *service.Service
}
func NewBoardController(svc *service.Service) *BoardController {
return &BoardController{service: svc}
}
func (c *BoardController) ListBoards(ctx context.Context, _ dto.ListBoardsRequest) (dto.ListBoardsResponse, error) {
boards, err := c.service.ListBoards(ctx)
if err != nil {
return dto.ListBoardsResponse{}, err
}
resp := dto.ListBoardsResponse{
Boards: make([]dto.BoardDTO, 0, len(boards)),
Total: len(boards),
}
for _, board := range boards {
resp.Boards = append(resp.Boards, dto.FromBoard(board))
}
return resp, nil
}
func (c *BoardController) GetBoard(ctx context.Context, req dto.GetBoardRequest) (dto.BoardDTO, error) {
board, err := c.service.GetBoard(ctx, req.ID)
if err != nil {
return dto.BoardDTO{}, err
}
return dto.FromBoard(board), nil
}
func (c *BoardController) CreateBoard(ctx context.Context, req dto.CreateBoardRequest) (dto.BoardDTO, error) {
board, err := c.service.CreateBoard(ctx, &service.CreateBoardRequest{
Name: req.Name,
Description: req.Description,
OwnerID: req.OwnerID,
})
if err != nil {
return dto.BoardDTO{}, err
}
return dto.FromBoard(board), nil
}
func (c *BoardController) UpdateBoard(ctx context.Context, req dto.UpdateBoardRequest) (dto.BoardDTO, error) {
board, err := c.service.UpdateBoard(ctx, req.ID, &service.UpdateBoardRequest{
Name: req.Name,
Description: req.Description,
})
if err != nil {
return dto.BoardDTO{}, err
}
return dto.FromBoard(board), nil
}
func (c *BoardController) DeleteBoard(ctx context.Context, req dto.DeleteBoardRequest) (dto.SuccessResponse, error) {
if err := c.service.DeleteBoard(ctx, req.ID); err != nil {
return dto.SuccessResponse{}, err
}
return dto.SuccessResponse{Success: true}, nil
}
func (c *BoardController) ListColumns(ctx context.Context, req dto.ListColumnsRequest) (dto.ListColumnsResponse, error) {
columns, err := c.service.ListColumns(ctx, req.BoardID)
if err != nil {
return dto.ListColumnsResponse{}, err
}
resp := dto.ListColumnsResponse{
Columns: make([]dto.ColumnDTO, 0, len(columns)),
Total: len(columns),
}
for _, column := range columns {
resp.Columns = append(resp.Columns, dto.FromColumn(column))
}
return resp, nil
}
func (c *BoardController) GetColumn(ctx context.Context, req dto.GetColumnRequest) (dto.ColumnDTO, error) {
column, err := c.service.GetColumn(ctx, req.ID)
if err != nil {
return dto.ColumnDTO{}, err
}
return dto.FromColumn(column), nil
}
func (c *BoardController) CreateColumn(ctx context.Context, req dto.CreateColumnRequest) (dto.ColumnDTO, error) {
column, err := c.service.CreateColumn(ctx, &service.CreateColumnRequest{
BoardID: req.BoardID,
Name: req.Name,
Position: req.Position,
State: req.State,
})
if err != nil {
return dto.ColumnDTO{}, err
}
return dto.FromColumn(column), nil
}
func (c *BoardController) GetSnapshot(ctx context.Context, req dto.GetBoardSnapshotRequest) (dto.BoardSnapshotDTO, error) {
board, err := c.service.GetBoard(ctx, req.BoardID)
if err != nil {
return dto.BoardSnapshotDTO{}, err
}
columns, err := c.service.ListColumns(ctx, req.BoardID)
if err != nil {
return dto.BoardSnapshotDTO{}, err
}
tasks, err := c.service.ListTasks(ctx, req.BoardID)
if err != nil {
return dto.BoardSnapshotDTO{}, err
}
resp := dto.BoardSnapshotDTO{
Board: dto.FromBoard(board),
Columns: make([]dto.ColumnDTO, 0, len(columns)),
Tasks: make([]dto.TaskDTO, 0, len(tasks)),
}
for _, column := range columns {
resp.Columns = append(resp.Columns, dto.FromColumn(column))
}
for _, task := range tasks {
resp.Tasks = append(resp.Tasks, dto.FromTask(task))
}
return resp, nil
}
@@ -0,0 +1,98 @@
package controller
import (
"context"
"github.com/kandev/kandev/internal/task/dto"
"github.com/kandev/kandev/internal/task/service"
)
type TaskController struct {
service *service.Service
}
func NewTaskController(svc *service.Service) *TaskController {
return &TaskController{service: svc}
}
func (c *TaskController) ListTasks(ctx context.Context, req dto.ListTasksRequest) (dto.ListTasksResponse, error) {
tasks, err := c.service.ListTasks(ctx, req.BoardID)
if err != nil {
return dto.ListTasksResponse{}, err
}
resp := dto.ListTasksResponse{
Tasks: make([]dto.TaskDTO, 0, len(tasks)),
Total: len(tasks),
}
for _, task := range tasks {
resp.Tasks = append(resp.Tasks, dto.FromTask(task))
}
return resp, nil
}
func (c *TaskController) GetTask(ctx context.Context, req dto.GetTaskRequest) (dto.TaskDTO, error) {
task, err := c.service.GetTask(ctx, req.ID)
if err != nil {
return dto.TaskDTO{}, err
}
return dto.FromTask(task), nil
}
func (c *TaskController) CreateTask(ctx context.Context, req dto.CreateTaskRequest) (dto.TaskDTO, error) {
task, err := c.service.CreateTask(ctx, &service.CreateTaskRequest{
BoardID: req.BoardID,
ColumnID: req.ColumnID,
Title: req.Title,
Description: req.Description,
Priority: req.Priority,
AgentType: req.AgentType,
RepositoryURL: req.RepositoryURL,
Branch: req.Branch,
AssignedTo: req.AssignedTo,
Position: req.Position,
Metadata: req.Metadata,
})
if err != nil {
return dto.TaskDTO{}, err
}
return dto.FromTask(task), nil
}
func (c *TaskController) UpdateTask(ctx context.Context, req dto.UpdateTaskRequest) (dto.TaskDTO, error) {
task, err := c.service.UpdateTask(ctx, req.ID, &service.UpdateTaskRequest{
Title: req.Title,
Description: req.Description,
Priority: req.Priority,
AgentType: req.AgentType,
AssignedTo: req.AssignedTo,
Position: req.Position,
Metadata: req.Metadata,
})
if err != nil {
return dto.TaskDTO{}, err
}
return dto.FromTask(task), nil
}
func (c *TaskController) DeleteTask(ctx context.Context, req dto.DeleteTaskRequest) (dto.SuccessResponse, error) {
if err := c.service.DeleteTask(ctx, req.ID); err != nil {
return dto.SuccessResponse{}, err
}
return dto.SuccessResponse{Success: true}, nil
}
func (c *TaskController) MoveTask(ctx context.Context, req dto.MoveTaskRequest) (dto.TaskDTO, error) {
task, err := c.service.MoveTask(ctx, req.ID, req.ColumnID, req.Position)
if err != nil {
return dto.TaskDTO{}, err
}
return dto.FromTask(task), nil
}
func (c *TaskController) UpdateTaskState(ctx context.Context, req dto.UpdateTaskStateRequest) (dto.TaskDTO, error) {
task, err := c.service.UpdateTaskState(ctx, req.ID, req.State)
if err != nil {
return dto.TaskDTO{}, err
}
return dto.FromTask(task), nil
}
+135
View File
@@ -0,0 +1,135 @@
package dto
import (
"time"
"github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
type BoardDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
OwnerID string `json:"owner_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ColumnDTO struct {
ID string `json:"id"`
BoardID string `json:"board_id"`
Name string `json:"name"`
Position int `json:"position"`
State v1.TaskState `json:"state"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type TaskDTO struct {
ID string `json:"id"`
BoardID string `json:"board_id"`
ColumnID string `json:"column_id"`
Title string `json:"title"`
Description string `json:"description"`
State v1.TaskState `json:"state"`
Priority int `json:"priority"`
AgentType *string `json:"agent_type,omitempty"`
RepositoryURL *string `json:"repository_url,omitempty"`
Branch *string `json:"branch,omitempty"`
AssignedAgentID *string `json:"assigned_agent_id,omitempty"`
Position int `json:"position"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type BoardSnapshotDTO struct {
Board BoardDTO `json:"board"`
Columns []ColumnDTO `json:"columns"`
Tasks []TaskDTO `json:"tasks"`
}
type ListBoardsResponse struct {
Boards []BoardDTO `json:"boards"`
Total int `json:"total"`
}
type ListColumnsResponse struct {
Columns []ColumnDTO `json:"columns"`
Total int `json:"total"`
}
type ListTasksResponse struct {
Tasks []TaskDTO `json:"tasks"`
Total int `json:"total"`
}
type SuccessResponse struct {
Success bool `json:"success"`
}
func FromBoard(board *models.Board) BoardDTO {
var description *string
if board.Description != "" {
description = &board.Description
}
return BoardDTO{
ID: board.ID,
Name: board.Name,
Description: description,
OwnerID: board.OwnerID,
CreatedAt: board.CreatedAt,
UpdatedAt: board.UpdatedAt,
}
}
func FromColumn(column *models.Column) ColumnDTO {
return ColumnDTO{
ID: column.ID,
BoardID: column.BoardID,
Name: column.Name,
Position: column.Position,
State: column.State,
CreatedAt: column.CreatedAt,
UpdatedAt: column.UpdatedAt,
}
}
func FromTask(task *models.Task) TaskDTO {
var agentType *string
if task.AgentType != "" {
agentType = &task.AgentType
}
var repositoryURL *string
if task.RepositoryURL != "" {
repositoryURL = &task.RepositoryURL
}
var branch *string
if task.Branch != "" {
branch = &task.Branch
}
var assignedAgentID *string
if task.AssignedTo != "" {
assignedAgentID = &task.AssignedTo
}
return TaskDTO{
ID: task.ID,
BoardID: task.BoardID,
ColumnID: task.ColumnID,
Title: task.Title,
Description: task.Description,
State: task.State,
Priority: task.Priority,
AgentType: agentType,
RepositoryURL: repositoryURL,
Branch: branch,
AssignedAgentID: assignedAgentID,
Position: task.Position,
CreatedAt: task.CreatedAt,
UpdatedAt: task.UpdatedAt,
Metadata: task.Metadata,
}
}
@@ -0,0 +1,92 @@
package dto
import v1 "github.com/kandev/kandev/pkg/api/v1"
type ListBoardsRequest struct{}
type GetBoardRequest struct {
ID string
}
type CreateBoardRequest struct {
Name string
Description string
OwnerID string
}
type UpdateBoardRequest struct {
ID string
Name *string
Description *string
}
type DeleteBoardRequest struct {
ID string
}
type ListColumnsRequest struct {
BoardID string
}
type GetColumnRequest struct {
ID string
}
type CreateColumnRequest struct {
BoardID string
Name string
Position int
State v1.TaskState
}
type GetBoardSnapshotRequest struct {
BoardID string
}
type ListTasksRequest struct {
BoardID string
}
type GetTaskRequest struct {
ID string
}
type CreateTaskRequest struct {
BoardID string
ColumnID string
Title string
Description string
Priority int
AgentType string
RepositoryURL string
Branch string
AssignedTo string
Position int
Metadata map[string]interface{}
}
type UpdateTaskRequest struct {
ID string
Title *string
Description *string
Priority *int
AgentType *string
AssignedTo *string
Position *int
Metadata map[string]interface{}
}
type DeleteTaskRequest struct {
ID string
}
type MoveTaskRequest struct {
ID string
ColumnID string
Position int
}
type UpdateTaskStateRequest struct {
ID string
State v1.TaskState
}
@@ -0,0 +1,273 @@
package handlers
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/task/controller"
"github.com/kandev/kandev/internal/task/dto"
v1 "github.com/kandev/kandev/pkg/api/v1"
ws "github.com/kandev/kandev/pkg/websocket"
"go.uber.org/zap"
)
type BoardHandlers struct {
controller *controller.BoardController
logger *logger.Logger
}
func NewBoardHandlers(svc *controller.BoardController, log *logger.Logger) *BoardHandlers {
return &BoardHandlers{
controller: svc,
logger: log.WithFields(zap.String("component", "task-board-handlers")),
}
}
func RegisterBoardRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, ctrl *controller.BoardController, log *logger.Logger) {
handlers := NewBoardHandlers(ctrl, log)
handlers.registerHTTP(router)
handlers.registerWS(dispatcher)
}
func (h *BoardHandlers) registerHTTP(router *gin.Engine) {
api := router.Group("/api/v1")
api.GET("/boards", h.httpListBoards)
api.GET("/boards/:id", h.httpGetBoard)
api.GET("/boards/:id/columns", h.httpListColumns)
api.GET("/boards/:id/snapshot", h.httpGetBoardSnapshot)
}
func (h *BoardHandlers) registerWS(dispatcher *ws.Dispatcher) {
dispatcher.RegisterFunc(ws.ActionBoardList, h.wsListBoards)
dispatcher.RegisterFunc(ws.ActionBoardCreate, h.wsCreateBoard)
dispatcher.RegisterFunc(ws.ActionBoardGet, h.wsGetBoard)
dispatcher.RegisterFunc(ws.ActionBoardUpdate, h.wsUpdateBoard)
dispatcher.RegisterFunc(ws.ActionBoardDelete, h.wsDeleteBoard)
dispatcher.RegisterFunc(ws.ActionColumnList, h.wsListColumns)
dispatcher.RegisterFunc(ws.ActionColumnCreate, h.wsCreateColumn)
dispatcher.RegisterFunc(ws.ActionColumnGet, h.wsGetColumn)
}
// HTTP handlers
func (h *BoardHandlers) httpListBoards(c *gin.Context) {
resp, err := h.controller.ListBoards(c.Request.Context(), dto.ListBoardsRequest{})
if err != nil {
h.logger.Error("failed to list boards", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list boards"})
return
}
c.JSON(http.StatusOK, resp)
}
func (h *BoardHandlers) httpGetBoard(c *gin.Context) {
resp, err := h.controller.GetBoard(c.Request.Context(), dto.GetBoardRequest{ID: c.Param("id")})
if err != nil {
handleNotFound(c, h.logger, err, "board not found")
return
}
c.JSON(http.StatusOK, resp)
}
func (h *BoardHandlers) httpListColumns(c *gin.Context) {
resp, err := h.controller.ListColumns(c.Request.Context(), dto.ListColumnsRequest{BoardID: c.Param("id")})
if err != nil {
handleNotFound(c, h.logger, err, "columns not found")
return
}
c.JSON(http.StatusOK, resp)
}
func (h *BoardHandlers) httpGetBoardSnapshot(c *gin.Context) {
resp, err := h.controller.GetSnapshot(c.Request.Context(), dto.GetBoardSnapshotRequest{BoardID: c.Param("id")})
if err != nil {
handleNotFound(c, h.logger, err, "board not found")
return
}
c.JSON(http.StatusOK, resp)
}
// WS handlers
func (h *BoardHandlers) wsListBoards(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
resp, err := h.controller.ListBoards(ctx, dto.ListBoardsRequest{})
if err != nil {
h.logger.Error("failed to list boards", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list boards", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsCreateBoardRequest struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
func (h *BoardHandlers) wsCreateBoard(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsCreateBoardRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.Name == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "name is required", nil)
}
resp, err := h.controller.CreateBoard(ctx, dto.CreateBoardRequest{
Name: req.Name,
Description: req.Description,
})
if err != nil {
h.logger.Error("failed to create board", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create board", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsGetBoardRequest struct {
ID string `json:"id"`
}
func (h *BoardHandlers) wsGetBoard(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsGetBoardRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
resp, err := h.controller.GetBoard(ctx, dto.GetBoardRequest{ID: req.ID})
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Board not found", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsUpdateBoardRequest struct {
ID string `json:"id"`
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
}
func (h *BoardHandlers) wsUpdateBoard(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsUpdateBoardRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
resp, err := h.controller.UpdateBoard(ctx, dto.UpdateBoardRequest{
ID: req.ID,
Name: req.Name,
Description: req.Description,
})
if err != nil {
h.logger.Error("failed to update board", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update board", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsDeleteBoardRequest struct {
ID string `json:"id"`
}
func (h *BoardHandlers) wsDeleteBoard(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsDeleteBoardRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
resp, err := h.controller.DeleteBoard(ctx, dto.DeleteBoardRequest{ID: req.ID})
if err != nil {
h.logger.Error("failed to delete board", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete board", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsListColumnsRequest struct {
BoardID string `json:"board_id"`
}
func (h *BoardHandlers) wsListColumns(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsListColumnsRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.BoardID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil)
}
resp, err := h.controller.ListColumns(ctx, dto.ListColumnsRequest{BoardID: req.BoardID})
if err != nil {
h.logger.Error("failed to list columns", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list columns", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsCreateColumnRequest struct {
BoardID string `json:"board_id"`
Name string `json:"name"`
Position int `json:"position"`
State string `json:"state,omitempty"`
}
func (h *BoardHandlers) wsCreateColumn(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsCreateColumnRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.BoardID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil)
}
if req.Name == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "name is required", nil)
}
state := v1.TaskState(req.State)
if state == "" {
state = v1.TaskStateTODO
}
resp, err := h.controller.CreateColumn(ctx, dto.CreateColumnRequest{
BoardID: req.BoardID,
Name: req.Name,
Position: req.Position,
State: state,
})
if err != nil {
h.logger.Error("failed to create column", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create column", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsGetColumnRequest struct {
ID string `json:"id"`
}
func (h *BoardHandlers) wsGetColumn(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsGetColumnRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
resp, err := h.controller.GetColumn(ctx, dto.GetColumnRequest{ID: req.ID})
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Column not found", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
@@ -0,0 +1,26 @@
package handlers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
func handleNotFound(c *gin.Context, log *logger.Logger, err error, fallback string) {
if isNotFound(err) {
c.JSON(http.StatusNotFound, gin.H{"error": fallback})
return
}
log.Error("request failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "request failed"})
}
func isNotFound(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "not found")
}
@@ -0,0 +1,362 @@
package handlers
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/task/controller"
"github.com/kandev/kandev/internal/task/dto"
v1 "github.com/kandev/kandev/pkg/api/v1"
ws "github.com/kandev/kandev/pkg/websocket"
"go.uber.org/zap"
)
type TaskHandlers struct {
controller *controller.TaskController
logger *logger.Logger
}
func NewTaskHandlers(ctrl *controller.TaskController, log *logger.Logger) *TaskHandlers {
return &TaskHandlers{
controller: ctrl,
logger: log.WithFields(zap.String("component", "task-task-handlers")),
}
}
func RegisterTaskRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, ctrl *controller.TaskController, log *logger.Logger) {
handlers := NewTaskHandlers(ctrl, log)
handlers.registerHTTP(router)
handlers.registerWS(dispatcher)
}
func (h *TaskHandlers) registerHTTP(router *gin.Engine) {
api := router.Group("/api/v1")
api.GET("/boards/:id/tasks", h.httpListTasks)
api.GET("/tasks/:id", h.httpGetTask)
api.POST("/tasks", h.httpCreateTask)
api.PATCH("/tasks/:id", h.httpUpdateTask)
api.DELETE("/tasks/:id", h.httpDeleteTask)
}
func (h *TaskHandlers) registerWS(dispatcher *ws.Dispatcher) {
dispatcher.RegisterFunc(ws.ActionTaskList, h.wsListTasks)
dispatcher.RegisterFunc(ws.ActionTaskCreate, h.wsCreateTask)
dispatcher.RegisterFunc(ws.ActionTaskGet, h.wsGetTask)
dispatcher.RegisterFunc(ws.ActionTaskUpdate, h.wsUpdateTask)
dispatcher.RegisterFunc(ws.ActionTaskDelete, h.wsDeleteTask)
dispatcher.RegisterFunc(ws.ActionTaskMove, h.wsMoveTask)
dispatcher.RegisterFunc(ws.ActionTaskState, h.wsUpdateTaskState)
}
// HTTP handlers
func (h *TaskHandlers) httpListTasks(c *gin.Context) {
resp, err := h.controller.ListTasks(c.Request.Context(), dto.ListTasksRequest{BoardID: c.Param("id")})
if err != nil {
handleNotFound(c, h.logger, err, "tasks not found")
return
}
c.JSON(http.StatusOK, resp)
}
func (h *TaskHandlers) httpGetTask(c *gin.Context) {
resp, err := h.controller.GetTask(c.Request.Context(), dto.GetTaskRequest{ID: c.Param("id")})
if err != nil {
handleNotFound(c, h.logger, err, "task not found")
return
}
c.JSON(http.StatusOK, resp)
}
type httpCreateTaskRequest struct {
BoardID string `json:"board_id"`
ColumnID string `json:"column_id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Priority int `json:"priority,omitempty"`
AgentType string `json:"agent_type,omitempty"`
RepositoryURL string `json:"repository_url,omitempty"`
Branch string `json:"branch,omitempty"`
AssignedTo string `json:"assigned_to,omitempty"`
Position int `json:"position,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
func (h *TaskHandlers) httpCreateTask(c *gin.Context) {
var body httpCreateTaskRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
resp, err := h.controller.CreateTask(c.Request.Context(), dto.CreateTaskRequest{
BoardID: body.BoardID,
ColumnID: body.ColumnID,
Title: body.Title,
Description: body.Description,
Priority: body.Priority,
AgentType: body.AgentType,
RepositoryURL: body.RepositoryURL,
Branch: body.Branch,
AssignedTo: body.AssignedTo,
Position: body.Position,
Metadata: body.Metadata,
})
if err != nil {
handleNotFound(c, h.logger, err, "task not created")
return
}
c.JSON(http.StatusOK, resp)
}
type httpUpdateTaskRequest struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Priority *int `json:"priority,omitempty"`
AgentType *string `json:"agent_type,omitempty"`
AssignedTo *string `json:"assigned_to,omitempty"`
Position *int `json:"position,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
func (h *TaskHandlers) httpUpdateTask(c *gin.Context) {
var body httpUpdateTaskRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
resp, err := h.controller.UpdateTask(c.Request.Context(), dto.UpdateTaskRequest{
ID: c.Param("id"),
Title: body.Title,
Description: body.Description,
Priority: body.Priority,
AgentType: body.AgentType,
AssignedTo: body.AssignedTo,
Position: body.Position,
Metadata: body.Metadata,
})
if err != nil {
handleNotFound(c, h.logger, err, "task not updated")
return
}
c.JSON(http.StatusOK, resp)
}
func (h *TaskHandlers) httpDeleteTask(c *gin.Context) {
resp, err := h.controller.DeleteTask(c.Request.Context(), dto.DeleteTaskRequest{ID: c.Param("id")})
if err != nil {
handleNotFound(c, h.logger, err, "task not deleted")
return
}
c.JSON(http.StatusOK, resp)
}
// WS handlers
type wsListTasksRequest struct {
BoardID string `json:"board_id"`
}
func (h *TaskHandlers) wsListTasks(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsListTasksRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.BoardID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil)
}
resp, err := h.controller.ListTasks(ctx, dto.ListTasksRequest{BoardID: req.BoardID})
if err != nil {
h.logger.Error("failed to list tasks", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list tasks", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsCreateTaskRequest struct {
BoardID string `json:"board_id"`
ColumnID string `json:"column_id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Priority int `json:"priority,omitempty"`
AgentType string `json:"agent_type,omitempty"`
RepositoryURL string `json:"repository_url,omitempty"`
Branch string `json:"branch,omitempty"`
AssignedTo string `json:"assigned_to,omitempty"`
Position int `json:"position,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsCreateTaskRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.BoardID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil)
}
if req.ColumnID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "column_id is required", nil)
}
if req.Title == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "title is required", nil)
}
resp, err := h.controller.CreateTask(ctx, dto.CreateTaskRequest{
BoardID: req.BoardID,
ColumnID: req.ColumnID,
Title: req.Title,
Description: req.Description,
Priority: req.Priority,
AgentType: req.AgentType,
RepositoryURL: req.RepositoryURL,
Branch: req.Branch,
AssignedTo: req.AssignedTo,
Position: req.Position,
Metadata: req.Metadata,
})
if err != nil {
h.logger.Error("failed to create task", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create task", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsGetTaskRequest struct {
ID string `json:"id"`
}
func (h *TaskHandlers) wsGetTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsGetTaskRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
resp, err := h.controller.GetTask(ctx, dto.GetTaskRequest{ID: req.ID})
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Task not found", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsUpdateTaskRequest struct {
ID string `json:"id"`
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Priority *int `json:"priority,omitempty"`
AgentType *string `json:"agent_type,omitempty"`
AssignedTo *string `json:"assigned_to,omitempty"`
Position *int `json:"position,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
func (h *TaskHandlers) wsUpdateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsUpdateTaskRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
resp, err := h.controller.UpdateTask(ctx, dto.UpdateTaskRequest{
ID: req.ID,
Title: req.Title,
Description: req.Description,
Priority: req.Priority,
AgentType: req.AgentType,
AssignedTo: req.AssignedTo,
Position: req.Position,
Metadata: req.Metadata,
})
if err != nil {
h.logger.Error("failed to update task", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update task", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsDeleteTaskRequest struct {
ID string `json:"id"`
}
func (h *TaskHandlers) wsDeleteTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsDeleteTaskRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
resp, err := h.controller.DeleteTask(ctx, dto.DeleteTaskRequest{ID: req.ID})
if err != nil {
h.logger.Error("failed to delete task", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete task", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsMoveTaskRequest struct {
ID string `json:"id"`
ColumnID string `json:"column_id"`
Position int `json:"position"`
}
func (h *TaskHandlers) wsMoveTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsMoveTaskRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
if req.ColumnID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "column_id is required", nil)
}
resp, err := h.controller.MoveTask(ctx, dto.MoveTaskRequest{
ID: req.ID,
ColumnID: req.ColumnID,
Position: req.Position,
})
if err != nil {
h.logger.Error("failed to move task", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to move task", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsUpdateTaskStateRequest struct {
ID string `json:"id"`
State string `json:"state"`
}
func (h *TaskHandlers) wsUpdateTaskState(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsUpdateTaskStateRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
if req.State == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "state is required", nil)
}
resp, err := h.controller.UpdateTaskState(ctx, dto.UpdateTaskStateRequest{
ID: req.ID,
State: v1.TaskState(req.State),
})
if err != nil {
h.logger.Error("failed to update task state", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update task state", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
@@ -1,184 +0,0 @@
package wshandlers
import (
"context"
"github.com/kandev/kandev/internal/task/service"
ws "github.com/kandev/kandev/pkg/websocket"
"go.uber.org/zap"
)
// CreateBoardRequest is the payload for board.create
type CreateBoardRequest struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
// BoardResponse is the response for board operations
type BoardResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// ListBoardsResponse is the response for board.list
type ListBoardsResponse struct {
Boards []BoardResponse `json:"boards"`
Total int `json:"total"`
}
// CreateBoard handles board.create action
func (h *Handlers) CreateBoard(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req CreateBoardRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.Name == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "name is required", nil)
}
svcReq := &service.CreateBoardRequest{
Name: req.Name,
Description: req.Description,
}
board, err := h.service.CreateBoard(ctx, svcReq)
if err != nil {
h.logger.Error("failed to create board", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create board", nil)
}
resp := BoardResponse{
ID: board.ID,
Name: board.Name,
Description: board.Description,
CreatedAt: board.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: board.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
// ListBoards handles board.list action
func (h *Handlers) ListBoards(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
boards, err := h.service.ListBoards(ctx)
if err != nil {
h.logger.Error("failed to list boards", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list boards", nil)
}
resp := ListBoardsResponse{
Boards: make([]BoardResponse, len(boards)),
Total: len(boards),
}
for i, b := range boards {
resp.Boards[i] = BoardResponse{
ID: b.ID,
Name: b.Name,
Description: b.Description,
CreatedAt: b.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: b.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
// GetBoardRequest is the payload for board.get
type GetBoardRequest struct {
ID string `json:"id"`
}
// GetBoard handles board.get action
func (h *Handlers) GetBoard(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req GetBoardRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
board, err := h.service.GetBoard(ctx, req.ID)
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Board not found", nil)
}
resp := BoardResponse{
ID: board.ID,
Name: board.Name,
Description: board.Description,
CreatedAt: board.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: board.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
// UpdateBoardRequest is the payload for board.update
type UpdateBoardRequest struct {
ID string `json:"id"`
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
}
// UpdateBoard handles board.update action
func (h *Handlers) UpdateBoard(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req UpdateBoardRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
svcReq := &service.UpdateBoardRequest{
Name: req.Name,
Description: req.Description,
}
board, err := h.service.UpdateBoard(ctx, req.ID, svcReq)
if err != nil {
h.logger.Error("failed to update board", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update board", nil)
}
resp := BoardResponse{
ID: board.ID,
Name: board.Name,
Description: board.Description,
CreatedAt: board.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: board.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
// DeleteBoardRequest is the payload for board.delete
type DeleteBoardRequest struct {
ID string `json:"id"`
}
// DeleteBoard handles board.delete action
func (h *Handlers) DeleteBoard(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req DeleteBoardRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
if err := h.service.DeleteBoard(ctx, req.ID); err != nil {
h.logger.Error("failed to delete board", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete board", nil)
}
return ws.NewResponse(msg.ID, msg.Action, map[string]bool{"success": true})
}
@@ -1,153 +0,0 @@
package wshandlers
import (
"context"
"github.com/kandev/kandev/internal/task/service"
v1 "github.com/kandev/kandev/pkg/api/v1"
ws "github.com/kandev/kandev/pkg/websocket"
"go.uber.org/zap"
)
// CreateColumnRequest is the payload for column.create
type CreateColumnRequest struct {
BoardID string `json:"board_id"`
Name string `json:"name"`
Position int `json:"position"`
State string `json:"state,omitempty"`
}
// ColumnResponse is the response for column operations
type ColumnResponse struct {
ID string `json:"id"`
BoardID string `json:"board_id"`
Name string `json:"name"`
Position int `json:"position"`
State string `json:"state"`
CreatedAt string `json:"created_at"`
}
// ListColumnsResponse is the response for column.list
type ListColumnsResponse struct {
Columns []ColumnResponse `json:"columns"`
Total int `json:"total"`
}
// ListColumnsRequest is the payload for column.list
type ListColumnsRequest struct {
BoardID string `json:"board_id"`
}
// ListColumns handles column.list action
func (h *Handlers) ListColumns(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req ListColumnsRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.BoardID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil)
}
columns, err := h.service.ListColumns(ctx, req.BoardID)
if err != nil {
h.logger.Error("failed to list columns", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list columns", nil)
}
resp := ListColumnsResponse{
Columns: make([]ColumnResponse, len(columns)),
Total: len(columns),
}
for i, c := range columns {
resp.Columns[i] = ColumnResponse{
ID: c.ID,
BoardID: c.BoardID,
Name: c.Name,
Position: c.Position,
State: string(c.State),
CreatedAt: c.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
// CreateColumn handles column.create action
func (h *Handlers) CreateColumn(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req CreateColumnRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.BoardID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil)
}
if req.Name == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "name is required", nil)
}
// Default state to TODO if not specified
state := v1.TaskState(req.State)
if state == "" {
state = v1.TaskStateTODO
}
svcReq := &service.CreateColumnRequest{
BoardID: req.BoardID,
Name: req.Name,
Position: req.Position,
State: state,
}
column, err := h.service.CreateColumn(ctx, svcReq)
if err != nil {
h.logger.Error("failed to create column", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create column", nil)
}
resp := ColumnResponse{
ID: column.ID,
BoardID: column.BoardID,
Name: column.Name,
Position: column.Position,
State: string(column.State),
CreatedAt: column.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
// GetColumnRequest is the payload for column.get
type GetColumnRequest struct {
ID string `json:"id"`
}
// GetColumn handles column.get action
func (h *Handlers) GetColumn(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req GetColumnRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
column, err := h.service.GetColumn(ctx, req.ID)
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Column not found", nil)
}
resp := ColumnResponse{
ID: column.ID,
BoardID: column.BoardID,
Name: column.Name,
Position: column.Position,
State: string(column.State),
CreatedAt: column.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
@@ -1,48 +0,0 @@
// Package wshandlers provides WebSocket message handlers for the task service.
package wshandlers
import (
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/task/service"
ws "github.com/kandev/kandev/pkg/websocket"
"go.uber.org/zap"
)
// Handlers contains WebSocket handlers for the task API
type Handlers struct {
service *service.Service
logger *logger.Logger
}
// NewHandlers creates a new WebSocket handlers instance
func NewHandlers(svc *service.Service, log *logger.Logger) *Handlers {
return &Handlers{
service: svc,
logger: log.WithFields(zap.String("component", "task-ws-handlers")),
}
}
// RegisterHandlers registers all task handlers with the dispatcher
func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) {
// Board handlers
d.RegisterFunc(ws.ActionBoardList, h.ListBoards)
d.RegisterFunc(ws.ActionBoardCreate, h.CreateBoard)
d.RegisterFunc(ws.ActionBoardGet, h.GetBoard)
d.RegisterFunc(ws.ActionBoardUpdate, h.UpdateBoard)
d.RegisterFunc(ws.ActionBoardDelete, h.DeleteBoard)
// Column handlers
d.RegisterFunc(ws.ActionColumnList, h.ListColumns)
d.RegisterFunc(ws.ActionColumnCreate, h.CreateColumn)
d.RegisterFunc(ws.ActionColumnGet, h.GetColumn)
// Task handlers
d.RegisterFunc(ws.ActionTaskList, h.ListTasks)
d.RegisterFunc(ws.ActionTaskCreate, h.CreateTask)
d.RegisterFunc(ws.ActionTaskGet, h.GetTask)
d.RegisterFunc(ws.ActionTaskUpdate, h.UpdateTask)
d.RegisterFunc(ws.ActionTaskDelete, h.DeleteTask)
d.RegisterFunc(ws.ActionTaskMove, h.MoveTask)
d.RegisterFunc(ws.ActionTaskState, h.UpdateTaskState)
}
@@ -1,283 +0,0 @@
package wshandlers
import (
"context"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/service"
v1 "github.com/kandev/kandev/pkg/api/v1"
ws "github.com/kandev/kandev/pkg/websocket"
"go.uber.org/zap"
)
// CreateTaskRequest is the payload for task.create
type CreateTaskRequest struct {
BoardID string `json:"board_id"`
ColumnID string `json:"column_id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Priority int `json:"priority,omitempty"`
AgentType string `json:"agent_type,omitempty"`
RepositoryURL string `json:"repository_url,omitempty"`
Branch string `json:"branch,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// TaskResponse is the response for task operations
type TaskResponse struct {
ID string `json:"id"`
BoardID string `json:"board_id"`
ColumnID string `json:"column_id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
State string `json:"state"`
Priority int `json:"priority"`
AgentType string `json:"agent_type,omitempty"`
RepositoryURL string `json:"repository_url,omitempty"`
Branch string `json:"branch,omitempty"`
Position int `json:"position"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// ListTasksRequest is the payload for task.list
type ListTasksRequest struct {
BoardID string `json:"board_id"`
}
// ListTasksResponse is the response for task.list
type ListTasksResponse struct {
Tasks []TaskResponse `json:"tasks"`
Total int `json:"total"`
}
// CreateTask handles task.create action
func (h *Handlers) CreateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req CreateTaskRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.BoardID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil)
}
if req.ColumnID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "column_id is required", nil)
}
if req.Title == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "title is required", nil)
}
svcReq := &service.CreateTaskRequest{
BoardID: req.BoardID,
ColumnID: req.ColumnID,
Title: req.Title,
Description: req.Description,
Priority: req.Priority,
AgentType: req.AgentType,
RepositoryURL: req.RepositoryURL,
Branch: req.Branch,
Metadata: req.Metadata,
}
task, err := h.service.CreateTask(ctx, svcReq)
if err != nil {
h.logger.Error("failed to create task", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create task", nil)
}
return ws.NewResponse(msg.ID, msg.Action, taskToResponse(task))
}
// ListTasks handles task.list action
func (h *Handlers) ListTasks(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req ListTasksRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.BoardID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil)
}
tasks, err := h.service.ListTasks(ctx, req.BoardID)
if err != nil {
h.logger.Error("failed to list tasks", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list tasks", nil)
}
resp := ListTasksResponse{
Tasks: make([]TaskResponse, len(tasks)),
Total: len(tasks),
}
for i, t := range tasks {
resp.Tasks[i] = taskToResponse(t)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
// GetTaskRequest is the payload for task.get
type GetTaskRequest struct {
ID string `json:"id"`
}
// GetTask handles task.get action
func (h *Handlers) GetTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req GetTaskRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
task, err := h.service.GetTask(ctx, req.ID)
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Task not found", nil)
}
return ws.NewResponse(msg.ID, msg.Action, taskToResponse(task))
}
// UpdateTaskRequest is the payload for task.update
type UpdateTaskRequest struct {
ID string `json:"id"`
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Priority *int `json:"priority,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// UpdateTask handles task.update action
func (h *Handlers) UpdateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req UpdateTaskRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
svcReq := &service.UpdateTaskRequest{
Title: req.Title,
Description: req.Description,
Priority: req.Priority,
Metadata: req.Metadata,
}
task, err := h.service.UpdateTask(ctx, req.ID, svcReq)
if err != nil {
h.logger.Error("failed to update task", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update task", nil)
}
return ws.NewResponse(msg.ID, msg.Action, taskToResponse(task))
}
// DeleteTaskRequest is the payload for task.delete
type DeleteTaskRequest struct {
ID string `json:"id"`
}
// DeleteTask handles task.delete action
func (h *Handlers) DeleteTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req DeleteTaskRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
if err := h.service.DeleteTask(ctx, req.ID); err != nil {
h.logger.Error("failed to delete task", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete task", nil)
}
return ws.NewResponse(msg.ID, msg.Action, map[string]bool{"success": true})
}
// MoveTaskRequest is the payload for task.move
type MoveTaskRequest struct {
ID string `json:"id"`
ColumnID string `json:"column_id"`
Position int `json:"position"`
}
// MoveTask handles task.move action
func (h *Handlers) MoveTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req MoveTaskRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
if req.ColumnID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "column_id is required", nil)
}
task, err := h.service.MoveTask(ctx, req.ID, req.ColumnID, req.Position)
if err != nil {
h.logger.Error("failed to move task", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to move task", nil)
}
return ws.NewResponse(msg.ID, msg.Action, taskToResponse(task))
}
// UpdateTaskStateRequest is the payload for task.state
type UpdateTaskStateRequest struct {
ID string `json:"id"`
State string `json:"state"`
}
// UpdateTaskState handles task.state action
func (h *Handlers) UpdateTaskState(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req UpdateTaskStateRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
if req.State == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "state is required", nil)
}
task, err := h.service.UpdateTaskState(ctx, req.ID, v1.TaskState(req.State))
if err != nil {
h.logger.Error("failed to update task state", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update task state", nil)
}
return ws.NewResponse(msg.ID, msg.Action, taskToResponse(task))
}
// taskToResponse converts a task model to response
func taskToResponse(t *models.Task) TaskResponse {
return TaskResponse{
ID: t.ID,
BoardID: t.BoardID,
ColumnID: t.ColumnID,
Title: t.Title,
Description: t.Description,
State: string(t.State),
Priority: t.Priority,
AgentType: t.AgentType,
RepositoryURL: t.RepositoryURL,
Branch: t.Branch,
Position: t.Position,
CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: t.UpdatedAt.Format("2006-01-02T15:04:05Z"),
Metadata: t.Metadata,
}
}