feat: added workspaces

This commit is contained in:
Carlos Florêncio
2026-01-11 14:46:01 +00:00
parent de8c3abb41
commit 8579e8ec91
30 changed files with 1372 additions and 198 deletions
+1 -1
View File
@@ -139,7 +139,7 @@ test-backend:
.PHONY: test-web
test-web:
@echo "Running web app tests..."
@cd $(WEB_DIR) && $(NPM) test --passWithNoTests || true
@cd $(WEB_DIR) && $(NPM) test
#
# Code Quality
+2
View File
@@ -202,6 +202,7 @@ func main() {
gateway := gateways.NewGateway(log)
// Prepare Task Service controllers for HTTP + WebSocket handlers
workspaceController := taskcontroller.NewWorkspaceController(taskSvc)
boardController := taskcontroller.NewBoardController(taskSvc)
taskController := taskcontroller.NewTaskController(taskSvc)
@@ -314,6 +315,7 @@ func main() {
gateway.SetupRoutes(router)
// Task Service handlers (HTTP + WebSocket)
taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log)
taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log)
taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, log)
log.Info("Registered Task Service handlers (HTTP + WebSocket)")
@@ -75,8 +75,10 @@ func NewTestServer(t *testing.T) *TestServer {
gateway.SetupRoutes(router)
// Register handlers (HTTP + WS)
workspaceController := taskcontroller.NewWorkspaceController(taskSvc)
boardController := taskcontroller.NewBoardController(taskSvc)
taskController := taskcontroller.NewTaskController(taskSvc)
taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log)
taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log)
taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, log)
@@ -139,6 +141,21 @@ func NewWSClient(t *testing.T, serverURL string) *WSClient {
return client
}
func createWorkspace(t *testing.T, client *WSClient) string {
t.Helper()
resp, err := client.SendRequest("workspace-1", ws.ActionWorkspaceCreate, map[string]interface{}{
"name": "Test Workspace",
})
require.NoError(t, err)
var payload map[string]interface{}
err = resp.ParsePayload(&payload)
require.NoError(t, err)
return payload["id"].(string)
}
// readPump reads messages from the WebSocket connection
func (c *WSClient) readPump() {
defer close(c.done)
@@ -262,11 +279,14 @@ func TestBoardCRUD(t *testing.T) {
client := NewWSClient(t, ts.Server.URL)
defer client.Close()
workspaceID := createWorkspace(t, client)
// Create board
t.Run("CreateBoard", func(t *testing.T) {
resp, err := client.SendRequest("board-create-1", ws.ActionBoardCreate, map[string]interface{}{
"name": "Test Board",
"description": "A test board for integration testing",
"workspace_id": workspaceID,
"name": "Test Board",
"description": "A test board for integration testing",
})
require.NoError(t, err)
assert.Equal(t, ws.MessageTypeResponse, resp.Type)
@@ -281,7 +301,9 @@ func TestBoardCRUD(t *testing.T) {
// List boards
t.Run("ListBoards", func(t *testing.T) {
resp, err := client.SendRequest("board-list-1", ws.ActionBoardList, map[string]interface{}{})
resp, err := client.SendRequest("board-list-1", ws.ActionBoardList, map[string]interface{}{
"workspace_id": workspaceID,
})
require.NoError(t, err)
var payload map[string]interface{}
@@ -305,9 +327,12 @@ func TestColumnCRUD(t *testing.T) {
client := NewWSClient(t, ts.Server.URL)
defer client.Close()
workspaceID := createWorkspace(t, client)
// First create a board
boardResp, err := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"name": "Column Test Board",
"workspace_id": workspaceID,
"name": "Column Test Board",
})
require.NoError(t, err)
@@ -378,9 +403,12 @@ func TestTaskCRUD(t *testing.T) {
client := NewWSClient(t, ts.Server.URL)
defer client.Close()
workspaceID := createWorkspace(t, client)
// Create board and column first
boardResp, err := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"name": "Task Test Board",
"workspace_id": workspaceID,
"name": "Task Test Board",
})
require.NoError(t, err)
@@ -406,11 +434,12 @@ func TestTaskCRUD(t *testing.T) {
// Create task
t.Run("CreateTask", func(t *testing.T) {
resp, err := client.SendRequest("task-create-1", ws.ActionTaskCreate, map[string]interface{}{
"board_id": boardID,
"column_id": columnID,
"title": "Test Task",
"description": "A test task for integration testing",
"priority": 3, // HIGH priority (1=LOW, 2=MEDIUM, 3=HIGH)
"workspace_id": workspaceID,
"board_id": boardID,
"column_id": columnID,
"title": "Test Task",
"description": "A test task for integration testing",
"priority": 3, // HIGH priority (1=LOW, 2=MEDIUM, 3=HIGH)
})
require.NoError(t, err)
@@ -519,9 +548,12 @@ func TestTaskStateTransitions(t *testing.T) {
client := NewWSClient(t, ts.Server.URL)
defer client.Close()
workspaceID := createWorkspace(t, client)
// Create board, column, and task
boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"name": "State Test Board",
"workspace_id": workspaceID,
"name": "State Test Board",
})
var boardPayload map[string]interface{}
boardResp.ParsePayload(&boardPayload)
@@ -537,10 +569,11 @@ func TestTaskStateTransitions(t *testing.T) {
columnID := colPayload["id"].(string)
taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{
"board_id": boardID,
"column_id": columnID,
"title": "State Test Task",
"description": "Test state transitions",
"workspace_id": workspaceID,
"board_id": boardID,
"column_id": columnID,
"title": "State Test Task",
"description": "Test state transitions",
})
var taskPayload map[string]interface{}
taskResp.ParsePayload(&taskPayload)
@@ -590,9 +623,12 @@ func TestTaskMove(t *testing.T) {
client := NewWSClient(t, ts.Server.URL)
defer client.Close()
workspaceID := createWorkspace(t, client)
// Create board with two columns
boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"name": "Move Test Board",
"workspace_id": workspaceID,
"name": "Move Test Board",
})
var boardPayload map[string]interface{}
boardResp.ParsePayload(&boardPayload)
@@ -618,9 +654,10 @@ func TestTaskMove(t *testing.T) {
// Create task in first column
taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{
"board_id": boardID,
"column_id": col1ID,
"title": "Movable Task",
"workspace_id": workspaceID,
"board_id": boardID,
"column_id": col1ID,
"title": "Movable Task",
})
var taskPayload map[string]interface{}
taskResp.ParsePayload(&taskPayload)
@@ -630,6 +667,7 @@ func TestTaskMove(t *testing.T) {
t.Run("MoveToColumn2", func(t *testing.T) {
resp, err := client.SendRequest("move-1", ws.ActionTaskMove, map[string]interface{}{
"id": taskID,
"board_id": boardID,
"column_id": col2ID,
"position": 0,
})
@@ -671,9 +709,12 @@ func TestMultipleClients(t *testing.T) {
client2 := NewWSClient(t, ts.Server.URL)
defer client2.Close()
workspaceID := createWorkspace(t, client1)
// Client 1 creates a board
boardResp, err := client1.SendRequest("c1-board-1", ws.ActionBoardCreate, map[string]interface{}{
"name": "Shared Board",
"workspace_id": workspaceID,
"name": "Shared Board",
})
require.NoError(t, err)
@@ -723,9 +764,10 @@ func TestMultipleClients(t *testing.T) {
// Client 1 creates a task in the column
taskResp, err := client1.SendRequest("c1-task-1", ws.ActionTaskCreate, map[string]interface{}{
"board_id": boardID,
"column_id": columnID,
"title": "Task by Client 1",
"workspace_id": workspaceID,
"board_id": boardID,
"column_id": columnID,
"title": "Task by Client 1",
})
require.NoError(t, err)
@@ -821,9 +863,12 @@ func TestTaskSubscription(t *testing.T) {
client := NewWSClient(t, ts.Server.URL)
defer client.Close()
workspaceID := createWorkspace(t, client)
// Create a task first
boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"name": "Sub Test Board",
"workspace_id": workspaceID,
"name": "Sub Test Board",
})
var boardPayload map[string]interface{}
boardResp.ParsePayload(&boardPayload)
@@ -839,9 +884,10 @@ func TestTaskSubscription(t *testing.T) {
columnID := colPayload["id"].(string)
taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{
"board_id": boardID,
"column_id": columnID,
"title": "Subscribable Task",
"workspace_id": workspaceID,
"board_id": boardID,
"column_id": columnID,
"title": "Subscribable Task",
})
var taskPayload map[string]interface{}
taskResp.ParsePayload(&taskPayload)
@@ -1003,8 +1049,10 @@ func NewTestServerWithACP(t *testing.T) *TestServerWithACP {
gateway.SetupRoutes(router)
// Register handlers (HTTP + WS)
workspaceController := taskcontroller.NewWorkspaceController(taskSvc)
boardController := taskcontroller.NewBoardController(taskSvc)
taskController := taskcontroller.NewTaskController(taskSvc)
taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log)
taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log)
taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, log)
@@ -1033,9 +1081,12 @@ func TestTaskLogsAction(t *testing.T) {
client := NewWSClient(t, ts.Server.URL)
defer client.Close()
workspaceID := createWorkspace(t, client)
// Create a task first
boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"name": "Logs Test Board",
"workspace_id": workspaceID,
"name": "Logs Test Board",
})
var boardPayload map[string]interface{}
boardResp.ParsePayload(&boardPayload)
@@ -1051,9 +1102,10 @@ func TestTaskLogsAction(t *testing.T) {
columnID := colPayload["id"].(string)
taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{
"board_id": boardID,
"column_id": columnID,
"title": "Task With Logs",
"workspace_id": workspaceID,
"board_id": boardID,
"column_id": columnID,
"title": "Task With Logs",
})
var taskPayload map[string]interface{}
taskResp.ParsePayload(&taskPayload)
@@ -1184,9 +1236,12 @@ func TestHistoricalLogsOnSubscribe(t *testing.T) {
client := NewWSClient(t, ts.Server.URL)
defer client.Close()
workspaceID := createWorkspace(t, client)
// Create a task
boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"name": "Subscribe Test Board",
"workspace_id": workspaceID,
"name": "Subscribe Test Board",
})
var boardPayload map[string]interface{}
boardResp.ParsePayload(&boardPayload)
@@ -1202,9 +1257,10 @@ func TestHistoricalLogsOnSubscribe(t *testing.T) {
columnID := colPayload["id"].(string)
taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{
"board_id": boardID,
"column_id": columnID,
"title": "Subscribable Task With History",
"workspace_id": workspaceID,
"board_id": boardID,
"column_id": columnID,
"title": "Subscribable Task With History",
})
var taskPayload map[string]interface{}
taskResp.ParsePayload(&taskPayload)
@@ -1317,9 +1373,12 @@ func TestHistoricalLogsChronologicalOrder(t *testing.T) {
client := NewWSClient(t, ts.Server.URL)
defer client.Close()
workspaceID := createWorkspace(t, client)
// Create a task
boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"name": "Chrono Test Board",
"workspace_id": workspaceID,
"name": "Chrono Test Board",
})
var boardPayload map[string]interface{}
boardResp.ParsePayload(&boardPayload)
@@ -1335,9 +1394,10 @@ func TestHistoricalLogsChronologicalOrder(t *testing.T) {
columnID := colPayload["id"].(string)
taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{
"board_id": boardID,
"column_id": columnID,
"title": "Chronological Order Test Task",
"workspace_id": workspaceID,
"board_id": boardID,
"column_id": columnID,
"title": "Chronological Order Test Task",
})
var taskPayload map[string]interface{}
taskResp.ParsePayload(&taskPayload)
@@ -1435,9 +1495,12 @@ func TestConcurrentRequests(t *testing.T) {
client := NewWSClient(t, ts.Server.URL)
defer client.Close()
workspaceID := createWorkspace(t, client)
// Create board and column
boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"name": "Concurrent Test Board",
"workspace_id": workspaceID,
"name": "Concurrent Test Board",
})
var boardPayload map[string]interface{}
boardResp.ParsePayload(&boardPayload)
@@ -1470,9 +1533,10 @@ func TestConcurrentRequests(t *testing.T) {
"task-"+string(rune('0'+idx)),
ws.ActionTaskCreate,
map[string]interface{}{
"board_id": boardID,
"column_id": columnID,
"title": "Concurrent Task " + string(rune('0'+idx)),
"workspace_id": workspaceID,
"board_id": boardID,
"column_id": columnID,
"title": "Concurrent Task " + string(rune('0'+idx)),
},
)
results <- err
@@ -497,8 +497,10 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer {
gateway.SetupRoutes(router)
// Register handlers (HTTP + WS)
workspaceController := taskcontroller.NewWorkspaceController(taskSvc)
boardController := taskcontroller.NewBoardController(taskSvc)
taskController := taskcontroller.NewTaskController(taskSvc)
taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log)
taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log)
taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, log)
@@ -600,6 +602,21 @@ func NewOrchestratorWSClient(t *testing.T, serverURL string) *OrchestratorWSClie
return client
}
func createOrchestratorWorkspace(t *testing.T, client *OrchestratorWSClient) string {
t.Helper()
resp, err := client.SendRequest("workspace-1", ws.ActionWorkspaceCreate, map[string]interface{}{
"name": "Test Workspace",
})
require.NoError(t, err)
var payload map[string]interface{}
err = resp.ParsePayload(&payload)
require.NoError(t, err)
return payload["id"].(string)
}
// readPump reads messages from the WebSocket connection
func (c *OrchestratorWSClient) readPump() {
defer close(c.done)
@@ -1258,10 +1275,13 @@ func TestOrchestratorEndToEndWorkflow(t *testing.T) {
client := NewOrchestratorWSClient(t, ts.Server.URL)
defer client.Close()
workspaceID := createOrchestratorWorkspace(t, client)
// 1. Create board
boardResp, err := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"name": "E2E Test Board",
"description": "End-to-end test board",
"workspace_id": workspaceID,
"name": "E2E Test Board",
"description": "End-to-end test board",
})
require.NoError(t, err)
@@ -1284,12 +1304,13 @@ func TestOrchestratorEndToEndWorkflow(t *testing.T) {
// 3. Create task with agent type
taskResp, err := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{
"board_id": boardID,
"column_id": colID,
"title": "Implement feature X",
"description": "Create a new feature with tests",
"priority": 2,
"agent_type": "augment-agent",
"workspace_id": workspaceID,
"board_id": boardID,
"column_id": colID,
"title": "Implement feature X",
"description": "Create a new feature with tests",
"priority": 2,
"agent_type": "augment-agent",
})
require.NoError(t, err)
@@ -15,8 +15,8 @@ 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)
func (c *BoardController) ListBoards(ctx context.Context, req dto.ListBoardsRequest) (dto.ListBoardsResponse, error) {
boards, err := c.service.ListBoards(ctx, req.WorkspaceID)
if err != nil {
return dto.ListBoardsResponse{}, err
}
@@ -40,9 +40,9 @@ func (c *BoardController) GetBoard(ctx context.Context, req dto.GetBoardRequest)
func (c *BoardController) CreateBoard(ctx context.Context, req dto.CreateBoardRequest) (dto.BoardDTO, error) {
board, err := c.service.CreateBoard(ctx, &service.CreateBoardRequest{
WorkspaceID: req.WorkspaceID,
Name: req.Name,
Description: req.Description,
OwnerID: req.OwnerID,
})
if err != nil {
return dto.BoardDTO{}, err
@@ -40,6 +40,7 @@ func (c *TaskController) GetTask(ctx context.Context, req dto.GetTaskRequest) (d
func (c *TaskController) CreateTask(ctx context.Context, req dto.CreateTaskRequest) (dto.TaskDTO, error) {
task, err := c.service.CreateTask(ctx, &service.CreateTaskRequest{
WorkspaceID: req.WorkspaceID,
BoardID: req.BoardID,
ColumnID: req.ColumnID,
Title: req.Title,
@@ -82,7 +83,7 @@ func (c *TaskController) DeleteTask(ctx context.Context, req dto.DeleteTaskReque
}
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)
task, err := c.service.MoveTask(ctx, req.ID, req.BoardID, req.ColumnID, req.Position)
if err != nil {
return dto.TaskDTO{}, err
}
@@ -0,0 +1,69 @@
package controller
import (
"context"
"github.com/kandev/kandev/internal/task/dto"
"github.com/kandev/kandev/internal/task/service"
)
type WorkspaceController struct {
service *service.Service
}
func NewWorkspaceController(svc *service.Service) *WorkspaceController {
return &WorkspaceController{service: svc}
}
func (c *WorkspaceController) ListWorkspaces(ctx context.Context, _ dto.ListWorkspacesRequest) (dto.ListWorkspacesResponse, error) {
workspaces, err := c.service.ListWorkspaces(ctx)
if err != nil {
return dto.ListWorkspacesResponse{}, err
}
resp := dto.ListWorkspacesResponse{
Workspaces: make([]dto.WorkspaceDTO, 0, len(workspaces)),
Total: len(workspaces),
}
for _, workspace := range workspaces {
resp.Workspaces = append(resp.Workspaces, dto.FromWorkspace(workspace))
}
return resp, nil
}
func (c *WorkspaceController) GetWorkspace(ctx context.Context, req dto.GetWorkspaceRequest) (dto.WorkspaceDTO, error) {
workspace, err := c.service.GetWorkspace(ctx, req.ID)
if err != nil {
return dto.WorkspaceDTO{}, err
}
return dto.FromWorkspace(workspace), nil
}
func (c *WorkspaceController) CreateWorkspace(ctx context.Context, req dto.CreateWorkspaceRequest) (dto.WorkspaceDTO, error) {
workspace, err := c.service.CreateWorkspace(ctx, &service.CreateWorkspaceRequest{
Name: req.Name,
Description: req.Description,
OwnerID: req.OwnerID,
})
if err != nil {
return dto.WorkspaceDTO{}, err
}
return dto.FromWorkspace(workspace), nil
}
func (c *WorkspaceController) UpdateWorkspace(ctx context.Context, req dto.UpdateWorkspaceRequest) (dto.WorkspaceDTO, error) {
workspace, err := c.service.UpdateWorkspace(ctx, req.ID, &service.UpdateWorkspaceRequest{
Name: req.Name,
Description: req.Description,
})
if err != nil {
return dto.WorkspaceDTO{}, err
}
return dto.FromWorkspace(workspace), nil
}
func (c *WorkspaceController) DeleteWorkspace(ctx context.Context, req dto.DeleteWorkspaceRequest) (dto.SuccessResponse, error) {
if err := c.service.DeleteWorkspace(ctx, req.ID); err != nil {
return dto.SuccessResponse{}, err
}
return dto.SuccessResponse{Success: true}, nil
}
+33 -1
View File
@@ -8,6 +8,15 @@ import (
)
type BoardDTO struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type WorkspaceDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
@@ -28,6 +37,7 @@ type ColumnDTO struct {
type TaskDTO struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
BoardID string `json:"board_id"`
ColumnID string `json:"column_id"`
Title string `json:"title"`
@@ -55,6 +65,11 @@ type ListBoardsResponse struct {
Total int `json:"total"`
}
type ListWorkspacesResponse struct {
Workspaces []WorkspaceDTO `json:"workspaces"`
Total int `json:"total"`
}
type ListColumnsResponse struct {
Columns []ColumnDTO `json:"columns"`
Total int `json:"total"`
@@ -77,14 +92,30 @@ func FromBoard(board *models.Board) BoardDTO {
return BoardDTO{
ID: board.ID,
WorkspaceID: board.WorkspaceID,
Name: board.Name,
Description: description,
OwnerID: board.OwnerID,
CreatedAt: board.CreatedAt,
UpdatedAt: board.UpdatedAt,
}
}
func FromWorkspace(workspace *models.Workspace) WorkspaceDTO {
var description *string
if workspace.Description != "" {
description = &workspace.Description
}
return WorkspaceDTO{
ID: workspace.ID,
Name: workspace.Name,
Description: description,
OwnerID: workspace.OwnerID,
CreatedAt: workspace.CreatedAt,
UpdatedAt: workspace.UpdatedAt,
}
}
func FromColumn(column *models.Column) ColumnDTO {
return ColumnDTO{
ID: column.ID,
@@ -117,6 +148,7 @@ func FromTask(task *models.Task) TaskDTO {
return TaskDTO{
ID: task.ID,
WorkspaceID: task.WorkspaceID,
BoardID: task.BoardID,
ColumnID: task.ColumnID,
Title: task.Title,
+28 -2
View File
@@ -2,16 +2,40 @@ package dto
import v1 "github.com/kandev/kandev/pkg/api/v1"
type ListBoardsRequest struct{}
type ListBoardsRequest struct {
WorkspaceID string
}
type ListWorkspacesRequest struct{}
type GetWorkspaceRequest struct {
ID string
}
type CreateWorkspaceRequest struct {
Name string
Description string
OwnerID string
}
type UpdateWorkspaceRequest struct {
ID string
Name *string
Description *string
}
type DeleteWorkspaceRequest struct {
ID string
}
type GetBoardRequest struct {
ID string
}
type CreateBoardRequest struct {
WorkspaceID string
Name string
Description string
OwnerID string
}
type UpdateBoardRequest struct {
@@ -52,6 +76,7 @@ type GetTaskRequest struct {
}
type CreateTaskRequest struct {
WorkspaceID string
BoardID string
ColumnID string
Title string
@@ -82,6 +107,7 @@ type DeleteTaskRequest struct {
type MoveTaskRequest struct {
ID string
BoardID string
ColumnID string
Position int
}
@@ -35,6 +35,7 @@ func RegisterBoardRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, ctrl *co
func (h *BoardHandlers) registerHTTP(router *gin.Engine) {
api := router.Group("/api/v1")
api.GET("/boards", h.httpListBoards)
api.GET("/workspaces/:id/boards", h.httpListBoardsByWorkspace)
api.GET("/boards/:id", h.httpGetBoard)
api.GET("/boards/:id/columns", h.httpListColumns)
api.GET("/boards/:id/snapshot", h.httpGetBoardSnapshot)
@@ -54,7 +55,21 @@ func (h *BoardHandlers) registerWS(dispatcher *ws.Dispatcher) {
// HTTP handlers
func (h *BoardHandlers) httpListBoards(c *gin.Context) {
resp, err := h.controller.ListBoards(c.Request.Context(), dto.ListBoardsRequest{})
resp, err := h.controller.ListBoards(c.Request.Context(), dto.ListBoardsRequest{
WorkspaceID: c.Query("workspace_id"),
})
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) httpListBoardsByWorkspace(c *gin.Context) {
resp, err := h.controller.ListBoards(c.Request.Context(), dto.ListBoardsRequest{
WorkspaceID: c.Param("id"),
})
if err != nil {
h.logger.Error("failed to list boards", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list boards"})
@@ -93,7 +108,15 @@ func (h *BoardHandlers) httpGetBoardSnapshot(c *gin.Context) {
// WS handlers
func (h *BoardHandlers) wsListBoards(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
resp, err := h.controller.ListBoards(ctx, dto.ListBoardsRequest{})
var req struct {
WorkspaceID string `json:"workspace_id,omitempty"`
}
if msg.Payload != nil {
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
}
resp, err := h.controller.ListBoards(ctx, dto.ListBoardsRequest{WorkspaceID: req.WorkspaceID})
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)
@@ -102,6 +125,7 @@ func (h *BoardHandlers) wsListBoards(ctx context.Context, msg *ws.Message) (*ws.
}
type wsCreateBoardRequest struct {
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
@@ -114,8 +138,12 @@ func (h *BoardHandlers) wsCreateBoard(ctx context.Context, msg *ws.Message) (*ws
if req.Name == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "name is required", nil)
}
if req.WorkspaceID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workspace_id is required", nil)
}
resp, err := h.controller.CreateBoard(ctx, dto.CreateBoardRequest{
WorkspaceID: req.WorkspaceID,
Name: req.Name,
Description: req.Description,
})
@@ -72,6 +72,7 @@ func (h *TaskHandlers) httpGetTask(c *gin.Context) {
}
type httpCreateTaskRequest struct {
WorkspaceID string `json:"workspace_id"`
BoardID string `json:"board_id"`
ColumnID string `json:"column_id"`
Title string `json:"title"`
@@ -91,7 +92,12 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if body.WorkspaceID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id is required"})
return
}
resp, err := h.controller.CreateTask(c.Request.Context(), dto.CreateTaskRequest{
WorkspaceID: body.WorkspaceID,
BoardID: body.BoardID,
ColumnID: body.ColumnID,
Title: body.Title,
@@ -177,6 +183,7 @@ func (h *TaskHandlers) wsListTasks(ctx context.Context, msg *ws.Message) (*ws.Me
}
type wsCreateTaskRequest struct {
WorkspaceID string `json:"workspace_id"`
BoardID string `json:"board_id"`
ColumnID string `json:"column_id"`
Title string `json:"title"`
@@ -195,6 +202,9 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.WorkspaceID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workspace_id is required", nil)
}
if req.BoardID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil)
}
@@ -206,6 +216,7 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M
}
resp, err := h.controller.CreateTask(ctx, dto.CreateTaskRequest{
WorkspaceID: req.WorkspaceID,
BoardID: req.BoardID,
ColumnID: req.ColumnID,
Title: req.Title,
@@ -305,6 +316,7 @@ func (h *TaskHandlers) wsDeleteTask(ctx context.Context, msg *ws.Message) (*ws.M
type wsMoveTaskRequest struct {
ID string `json:"id"`
BoardID string `json:"board_id"`
ColumnID string `json:"column_id"`
Position int `json:"position"`
}
@@ -317,12 +329,16 @@ func (h *TaskHandlers) wsMoveTask(ctx context.Context, msg *ws.Message) (*ws.Mes
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", 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)
}
resp, err := h.controller.MoveTask(ctx, dto.MoveTaskRequest{
ID: req.ID,
BoardID: req.BoardID,
ColumnID: req.ColumnID,
Position: req.Position,
})
@@ -0,0 +1,236 @@
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"
ws "github.com/kandev/kandev/pkg/websocket"
"go.uber.org/zap"
)
type WorkspaceHandlers struct {
controller *controller.WorkspaceController
logger *logger.Logger
}
func NewWorkspaceHandlers(ctrl *controller.WorkspaceController, log *logger.Logger) *WorkspaceHandlers {
return &WorkspaceHandlers{
controller: ctrl,
logger: log.WithFields(zap.String("component", "task-workspace-handlers")),
}
}
func RegisterWorkspaceRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, ctrl *controller.WorkspaceController, log *logger.Logger) {
handlers := NewWorkspaceHandlers(ctrl, log)
handlers.registerHTTP(router)
handlers.registerWS(dispatcher)
}
func (h *WorkspaceHandlers) registerHTTP(router *gin.Engine) {
api := router.Group("/api/v1")
api.GET("/workspaces", h.httpListWorkspaces)
api.POST("/workspaces", h.httpCreateWorkspace)
api.GET("/workspaces/:id", h.httpGetWorkspace)
api.PATCH("/workspaces/:id", h.httpUpdateWorkspace)
api.DELETE("/workspaces/:id", h.httpDeleteWorkspace)
}
func (h *WorkspaceHandlers) registerWS(dispatcher *ws.Dispatcher) {
dispatcher.RegisterFunc(ws.ActionWorkspaceList, h.wsListWorkspaces)
dispatcher.RegisterFunc(ws.ActionWorkspaceCreate, h.wsCreateWorkspace)
dispatcher.RegisterFunc(ws.ActionWorkspaceGet, h.wsGetWorkspace)
dispatcher.RegisterFunc(ws.ActionWorkspaceUpdate, h.wsUpdateWorkspace)
dispatcher.RegisterFunc(ws.ActionWorkspaceDelete, h.wsDeleteWorkspace)
}
// HTTP handlers
func (h *WorkspaceHandlers) httpListWorkspaces(c *gin.Context) {
resp, err := h.controller.ListWorkspaces(c.Request.Context(), dto.ListWorkspacesRequest{})
if err != nil {
h.logger.Error("failed to list workspaces", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list workspaces"})
return
}
c.JSON(http.StatusOK, resp)
}
type httpCreateWorkspaceRequest struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
OwnerID string `json:"owner_id,omitempty"`
}
func (h *WorkspaceHandlers) httpCreateWorkspace(c *gin.Context) {
var body httpCreateWorkspaceRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if body.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
resp, err := h.controller.CreateWorkspace(c.Request.Context(), dto.CreateWorkspaceRequest{
Name: body.Name,
Description: body.Description,
OwnerID: body.OwnerID,
})
if err != nil {
handleNotFound(c, h.logger, err, "workspace not created")
return
}
c.JSON(http.StatusOK, resp)
}
func (h *WorkspaceHandlers) httpGetWorkspace(c *gin.Context) {
resp, err := h.controller.GetWorkspace(c.Request.Context(), dto.GetWorkspaceRequest{ID: c.Param("id")})
if err != nil {
handleNotFound(c, h.logger, err, "workspace not found")
return
}
c.JSON(http.StatusOK, resp)
}
type httpUpdateWorkspaceRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
}
func (h *WorkspaceHandlers) httpUpdateWorkspace(c *gin.Context) {
var body httpUpdateWorkspaceRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
resp, err := h.controller.UpdateWorkspace(c.Request.Context(), dto.UpdateWorkspaceRequest{
ID: c.Param("id"),
Name: body.Name,
Description: body.Description,
})
if err != nil {
handleNotFound(c, h.logger, err, "workspace not updated")
return
}
c.JSON(http.StatusOK, resp)
}
func (h *WorkspaceHandlers) httpDeleteWorkspace(c *gin.Context) {
resp, err := h.controller.DeleteWorkspace(c.Request.Context(), dto.DeleteWorkspaceRequest{ID: c.Param("id")})
if err != nil {
handleNotFound(c, h.logger, err, "workspace not deleted")
return
}
c.JSON(http.StatusOK, resp)
}
// WS handlers
func (h *WorkspaceHandlers) wsListWorkspaces(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
resp, err := h.controller.ListWorkspaces(ctx, dto.ListWorkspacesRequest{})
if err != nil {
h.logger.Error("failed to list workspaces", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list workspaces", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsCreateWorkspaceRequest struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
OwnerID string `json:"owner_id,omitempty"`
}
func (h *WorkspaceHandlers) wsCreateWorkspace(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsCreateWorkspaceRequest
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.CreateWorkspace(ctx, dto.CreateWorkspaceRequest{
Name: req.Name,
Description: req.Description,
OwnerID: req.OwnerID,
})
if err != nil {
h.logger.Error("failed to create workspace", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create workspace", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsGetWorkspaceRequest struct {
ID string `json:"id"`
}
func (h *WorkspaceHandlers) wsGetWorkspace(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsGetWorkspaceRequest
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.GetWorkspace(ctx, dto.GetWorkspaceRequest{ID: req.ID})
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Workspace not found", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsUpdateWorkspaceRequest struct {
ID string `json:"id"`
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
}
func (h *WorkspaceHandlers) wsUpdateWorkspace(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsUpdateWorkspaceRequest
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.UpdateWorkspace(ctx, dto.UpdateWorkspaceRequest{
ID: req.ID,
Name: req.Name,
Description: req.Description,
})
if err != nil {
h.logger.Error("failed to update workspace", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update workspace", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsDeleteWorkspaceRequest struct {
ID string `json:"id"`
}
func (h *WorkspaceHandlers) wsDeleteWorkspace(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsDeleteWorkspaceRequest
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.DeleteWorkspace(ctx, dto.DeleteWorkspaceRequest{ID: req.ID})
if err != nil {
h.logger.Error("failed to delete workspace", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete workspace", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
+12 -1
View File
@@ -9,6 +9,7 @@ import (
// Task represents a task in the database
type Task struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
BoardID string `json:"board_id"`
ColumnID string `json:"column_id"`
Title string `json:"title"`
@@ -27,6 +28,16 @@ type Task struct {
// Board represents a Kanban board
type Board struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Workspace represents a workspace
type Workspace struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
@@ -70,6 +81,7 @@ func (t *Task) ToAPI() *v1.Task {
return &v1.Task{
ID: t.ID,
WorkspaceID: t.WorkspaceID,
BoardID: t.BoardID,
Title: t.Title,
Description: t.Description,
@@ -84,4 +96,3 @@ func (t *Task) ToAPI() *v1.Task {
Metadata: t.Metadata,
}
}
@@ -34,6 +34,7 @@ func TestTaskStructInitialization(t *testing.T) {
now := time.Now().UTC()
task := Task{
ID: "task-123",
WorkspaceID: "workspace-001",
BoardID: "board-456",
ColumnID: "column-789",
Title: "Test Task",
@@ -51,6 +52,9 @@ func TestTaskStructInitialization(t *testing.T) {
if task.ID != "task-123" {
t.Errorf("expected ID task-123, got %s", task.ID)
}
if task.WorkspaceID != "workspace-001" {
t.Errorf("expected WorkspaceID workspace-001, got %s", task.WorkspaceID)
}
if task.BoardID != "board-456" {
t.Errorf("expected BoardID board-456, got %s", task.BoardID)
}
@@ -87,9 +91,9 @@ func TestBoardStructInitialization(t *testing.T) {
now := time.Now().UTC()
board := Board{
ID: "board-123",
WorkspaceID: "workspace-001",
Name: "Test Board",
Description: "A test board",
OwnerID: "owner-456",
CreatedAt: now,
UpdatedAt: now,
}
@@ -97,15 +101,15 @@ func TestBoardStructInitialization(t *testing.T) {
if board.ID != "board-123" {
t.Errorf("expected ID board-123, got %s", board.ID)
}
if board.WorkspaceID != "workspace-001" {
t.Errorf("expected WorkspaceID workspace-001, got %s", board.WorkspaceID)
}
if board.Name != "Test Board" {
t.Errorf("expected Name 'Test Board', got %s", board.Name)
}
if board.Description != "A test board" {
t.Errorf("expected Description 'A test board', got %s", board.Description)
}
if board.OwnerID != "owner-456" {
t.Errorf("expected OwnerID 'owner-456', got %s", board.OwnerID)
}
}
func TestColumnStructInitialization(t *testing.T) {
@@ -141,6 +145,7 @@ func TestTaskToAPI(t *testing.T) {
now := time.Now().UTC()
task := &Task{
ID: "task-123",
WorkspaceID: "workspace-001",
BoardID: "board-456",
ColumnID: "column-789",
Title: "Test Task",
@@ -160,6 +165,9 @@ func TestTaskToAPI(t *testing.T) {
if apiTask.ID != task.ID {
t.Errorf("expected ID %s, got %s", task.ID, apiTask.ID)
}
if apiTask.WorkspaceID != task.WorkspaceID {
t.Errorf("expected WorkspaceID %s, got %s", task.WorkspaceID, apiTask.WorkspaceID)
}
if apiTask.BoardID != task.BoardID {
t.Errorf("expected BoardID %s, got %s", task.BoardID, apiTask.BoardID)
}
@@ -190,6 +198,7 @@ func TestTaskToAPIWithEmptyOptionalFields(t *testing.T) {
now := time.Now().UTC()
task := &Task{
ID: "task-123",
WorkspaceID: "workspace-001",
BoardID: "board-456",
ColumnID: "column-789",
Title: "Test Task",
@@ -213,4 +222,3 @@ func TestTaskToAPIWithEmptyOptionalFields(t *testing.T) {
t.Errorf("expected AssignedAgentID nil, got %v", apiTask.AssignedAgentID)
}
}
@@ -9,6 +9,13 @@ import (
// Repository defines the interface for task storage operations
type Repository interface {
// Workspace operations
CreateWorkspace(ctx context.Context, workspace *models.Workspace) error
GetWorkspace(ctx context.Context, id string) (*models.Workspace, error)
UpdateWorkspace(ctx context.Context, workspace *models.Workspace) error
DeleteWorkspace(ctx context.Context, id string) error
ListWorkspaces(ctx context.Context) ([]*models.Workspace, error)
// Task operations
CreateTask(ctx context.Context, task *models.Task) error
GetTask(ctx context.Context, id string) (*models.Task, error)
@@ -17,13 +24,15 @@ type Repository interface {
ListTasks(ctx context.Context, boardID string) ([]*models.Task, error)
ListTasksByColumn(ctx context.Context, columnID string) ([]*models.Task, error)
UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error
AddTaskToBoard(ctx context.Context, taskID, boardID, columnID string, position int) error
RemoveTaskFromBoard(ctx context.Context, taskID, boardID string) error
// Board operations
CreateBoard(ctx context.Context, board *models.Board) error
GetBoard(ctx context.Context, id string) (*models.Board, error)
UpdateBoard(ctx context.Context, board *models.Board) error
DeleteBoard(ctx context.Context, id string) error
ListBoards(ctx context.Context) ([]*models.Board, error)
ListBoards(ctx context.Context, workspaceID string) ([]*models.Board, error)
// Column operations
CreateColumn(ctx context.Context, column *models.Column) error
@@ -35,4 +44,3 @@ type Repository interface {
// Close closes the repository (for database connections)
Close() error
}
+172 -12
View File
@@ -13,10 +13,13 @@ import (
// MemoryRepository provides in-memory task storage operations
type MemoryRepository struct {
tasks map[string]*models.Task
boards map[string]*models.Board
columns map[string]*models.Column
mu sync.RWMutex
workspaces map[string]*models.Workspace
tasks map[string]*models.Task
boards map[string]*models.Board
columns map[string]*models.Column
taskBoards map[string]map[string]struct{}
taskPlacements map[string]map[string]*taskPlacement
mu sync.RWMutex
}
// Ensure MemoryRepository implements Repository interface
@@ -25,17 +28,93 @@ var _ Repository = (*MemoryRepository)(nil)
// NewMemoryRepository creates a new in-memory task repository
func NewMemoryRepository() *MemoryRepository {
return &MemoryRepository{
tasks: make(map[string]*models.Task),
boards: make(map[string]*models.Board),
columns: make(map[string]*models.Column),
workspaces: make(map[string]*models.Workspace),
tasks: make(map[string]*models.Task),
boards: make(map[string]*models.Board),
columns: make(map[string]*models.Column),
taskBoards: make(map[string]map[string]struct{}),
taskPlacements: make(map[string]map[string]*taskPlacement),
}
}
type taskPlacement struct {
boardID string
columnID string
position int
}
// Close is a no-op for in-memory repository
func (r *MemoryRepository) Close() error {
return nil
}
// Workspace operations
// CreateWorkspace creates a new workspace
func (r *MemoryRepository) CreateWorkspace(ctx context.Context, workspace *models.Workspace) error {
r.mu.Lock()
defer r.mu.Unlock()
if workspace.ID == "" {
workspace.ID = uuid.New().String()
}
now := time.Now().UTC()
workspace.CreatedAt = now
workspace.UpdatedAt = now
r.workspaces[workspace.ID] = workspace
return nil
}
// GetWorkspace retrieves a workspace by ID
func (r *MemoryRepository) GetWorkspace(ctx context.Context, id string) (*models.Workspace, error) {
r.mu.RLock()
defer r.mu.RUnlock()
workspace, ok := r.workspaces[id]
if !ok {
return nil, fmt.Errorf("workspace not found: %s", id)
}
return workspace, nil
}
// UpdateWorkspace updates an existing workspace
func (r *MemoryRepository) UpdateWorkspace(ctx context.Context, workspace *models.Workspace) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.workspaces[workspace.ID]; !ok {
return fmt.Errorf("workspace not found: %s", workspace.ID)
}
workspace.UpdatedAt = time.Now().UTC()
r.workspaces[workspace.ID] = workspace
return nil
}
// DeleteWorkspace deletes a workspace by ID
func (r *MemoryRepository) DeleteWorkspace(ctx context.Context, id string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.workspaces[id]; !ok {
return fmt.Errorf("workspace not found: %s", id)
}
delete(r.workspaces, id)
return nil
}
// ListWorkspaces returns all workspaces
func (r *MemoryRepository) ListWorkspaces(ctx context.Context) ([]*models.Workspace, error) {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]*models.Workspace, 0, len(r.workspaces))
for _, workspace := range r.workspaces {
result = append(result, workspace)
}
return result, nil
}
// Task operations
// CreateTask creates a new task
@@ -51,6 +130,9 @@ func (r *MemoryRepository) CreateTask(ctx context.Context, task *models.Task) er
task.UpdatedAt = now
r.tasks[task.ID] = task
if task.BoardID != "" {
r.addTaskPlacement(task.ID, task.BoardID, task.ColumnID, task.Position)
}
return nil
}
@@ -76,6 +158,9 @@ func (r *MemoryRepository) UpdateTask(ctx context.Context, task *models.Task) er
}
task.UpdatedAt = time.Now().UTC()
r.tasks[task.ID] = task
if task.BoardID != "" {
r.addTaskPlacement(task.ID, task.BoardID, task.ColumnID, task.Position)
}
return nil
}
@@ -88,6 +173,8 @@ func (r *MemoryRepository) DeleteTask(ctx context.Context, id string) error {
return fmt.Errorf("task not found: %s", id)
}
delete(r.tasks, id)
delete(r.taskBoards, id)
delete(r.taskPlacements, id)
return nil
}
@@ -98,9 +185,15 @@ func (r *MemoryRepository) ListTasks(ctx context.Context, boardID string) ([]*mo
var result []*models.Task
for _, task := range r.tasks {
if task.BoardID == boardID {
result = append(result, task)
placement, ok := r.getTaskPlacement(task.ID, boardID)
if !ok {
continue
}
copy := *task
copy.BoardID = placement.boardID
copy.ColumnID = placement.columnID
copy.Position = placement.position
result = append(result, &copy)
}
return result, nil
}
@@ -112,8 +205,19 @@ func (r *MemoryRepository) ListTasksByColumn(ctx context.Context, columnID strin
var result []*models.Task
for _, task := range r.tasks {
if task.ColumnID == columnID {
result = append(result, task)
placements, ok := r.taskPlacements[task.ID]
if !ok {
continue
}
for _, placement := range placements {
if placement.columnID != columnID {
continue
}
copy := *task
copy.BoardID = placement.boardID
copy.ColumnID = placement.columnID
copy.Position = placement.position
result = append(result, &copy)
}
}
return result, nil
@@ -133,6 +237,35 @@ func (r *MemoryRepository) UpdateTaskState(ctx context.Context, id string, state
return nil
}
// AddTaskToBoard adds a task to a board with placement
func (r *MemoryRepository) AddTaskToBoard(ctx context.Context, taskID, boardID, columnID string, position int) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.tasks[taskID]; !ok {
return fmt.Errorf("task not found: %s", taskID)
}
r.addTaskPlacement(taskID, boardID, columnID, position)
return nil
}
// RemoveTaskFromBoard removes a task from a board
func (r *MemoryRepository) RemoveTaskFromBoard(ctx context.Context, taskID, boardID string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.tasks[taskID]; !ok {
return fmt.Errorf("task not found: %s", taskID)
}
if placements, ok := r.taskPlacements[taskID]; ok {
delete(placements, boardID)
}
if boards, ok := r.taskBoards[taskID]; ok {
delete(boards, boardID)
}
return nil
}
// Board operations
// CreateBoard creates a new board
@@ -189,12 +322,15 @@ func (r *MemoryRepository) DeleteBoard(ctx context.Context, id string) error {
}
// ListBoards returns all boards
func (r *MemoryRepository) ListBoards(ctx context.Context) ([]*models.Board, error) {
func (r *MemoryRepository) ListBoards(ctx context.Context, workspaceID string) ([]*models.Board, error) {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]*models.Board, 0, len(r.boards))
for _, board := range r.boards {
if workspaceID != "" && board.WorkspaceID != workspaceID {
continue
}
result = append(result, board)
}
return result, nil
@@ -269,3 +405,27 @@ func (r *MemoryRepository) ListColumns(ctx context.Context, boardID string) ([]*
return result, nil
}
func (r *MemoryRepository) addTaskPlacement(taskID, boardID, columnID string, position int) {
if _, ok := r.taskBoards[taskID]; !ok {
r.taskBoards[taskID] = make(map[string]struct{})
}
r.taskBoards[taskID][boardID] = struct{}{}
if _, ok := r.taskPlacements[taskID]; !ok {
r.taskPlacements[taskID] = make(map[string]*taskPlacement)
}
r.taskPlacements[taskID][boardID] = &taskPlacement{
boardID: boardID,
columnID: columnID,
position: position,
}
}
func (r *MemoryRepository) getTaskPlacement(taskID, boardID string) (*taskPlacement, bool) {
placements, ok := r.taskPlacements[taskID]
if !ok {
return nil, false
}
placement, ok := placements[boardID]
return placement, ok
}
@@ -16,6 +16,9 @@ func TestNewMemoryRepository(t *testing.T) {
if repo.tasks == nil {
t.Error("expected tasks map to be initialized")
}
if repo.workspaces == nil {
t.Error("expected workspaces map to be initialized")
}
if repo.boards == nil {
t.Error("expected boards map to be initialized")
}
@@ -38,8 +41,13 @@ func TestMemoryRepository_BoardCRUD(t *testing.T) {
repo := NewMemoryRepository()
ctx := context.Background()
workspace := &models.Workspace{ID: "ws-1", Name: "Workspace"}
if err := repo.CreateWorkspace(ctx, workspace); err != nil {
t.Fatalf("failed to create workspace: %v", err)
}
// Create
board := &models.Board{Name: "Test Board", Description: "A test board", OwnerID: "owner-123"}
board := &models.Board{WorkspaceID: workspace.ID, Name: "Test Board", Description: "A test board"}
if err := repo.CreateBoard(ctx, board); err != nil {
t.Fatalf("failed to create board: %v", err)
}
@@ -103,16 +111,19 @@ func TestMemoryRepository_ListBoards(t *testing.T) {
repo := NewMemoryRepository()
ctx := context.Background()
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-1", Name: "Board 1"})
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-2", Name: "Board 2"})
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-3", Name: "Board 3"})
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"})
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-2", Name: "Workspace 2"})
boards, err := repo.ListBoards(ctx)
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-1", WorkspaceID: "ws-1", Name: "Board 1"})
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-2", WorkspaceID: "ws-1", Name: "Board 2"})
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-3", WorkspaceID: "ws-2", Name: "Board 3"})
boards, err := repo.ListBoards(ctx, "ws-1")
if err != nil {
t.Fatalf("failed to list boards: %v", err)
}
if len(boards) != 3 {
t.Errorf("expected 3 boards, got %d", len(boards))
if len(boards) != 2 {
t.Errorf("expected 2 boards, got %d", len(boards))
}
}
@@ -206,6 +217,7 @@ func TestMemoryRepository_TaskCRUD(t *testing.T) {
// Create
task := &models.Task{
WorkspaceID: "ws-1",
BoardID: "board-123",
ColumnID: "col-123",
Title: "Test Task",
@@ -277,7 +289,7 @@ func TestMemoryRepository_UpdateTaskState(t *testing.T) {
repo := NewMemoryRepository()
ctx := context.Background()
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test", State: v1.TaskStateTODO}
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test", State: v1.TaskStateTODO}
_ = repo.CreateTask(ctx, task)
err := repo.UpdateTaskState(ctx, "task-123", v1.TaskStateInProgress)
@@ -305,9 +317,9 @@ func TestMemoryRepository_ListTasks(t *testing.T) {
repo := NewMemoryRepository()
ctx := context.Background()
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", BoardID: "board-123", ColumnID: "col-1", Title: "Task 1"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", BoardID: "board-123", ColumnID: "col-1", Title: "Task 2"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-3", BoardID: "board-456", ColumnID: "col-2", Title: "Task 3"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-1", Title: "Task 1"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-1", Title: "Task 2"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", BoardID: "board-456", ColumnID: "col-2", Title: "Task 3"})
tasks, err := repo.ListTasks(ctx, "board-123")
if err != nil {
@@ -322,9 +334,9 @@ func TestMemoryRepository_ListTasksByColumn(t *testing.T) {
repo := NewMemoryRepository()
ctx := context.Background()
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", BoardID: "board-123", ColumnID: "col-1", Title: "Task 1"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", BoardID: "board-123", ColumnID: "col-1", Title: "Task 2"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-3", BoardID: "board-123", ColumnID: "col-2", Title: "Task 3"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-1", Title: "Task 1"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-1", Title: "Task 2"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-2", Title: "Task 3"})
tasks, err := repo.ListTasksByColumn(ctx, "col-1")
if err != nil {
@@ -334,4 +346,3 @@ func TestMemoryRepository_ListTasksByColumn(t *testing.T) {
t.Errorf("expected 2 tasks for col-1, got %d", len(tasks))
}
}
+373 -27
View File
@@ -47,7 +47,7 @@ func NewSQLiteRepository(dbPath string) (*SQLiteRepository, error) {
// initSchema creates the database tables if they don't exist
func (r *SQLiteRepository) initSchema() error {
schema := `
CREATE TABLE IF NOT EXISTS boards (
CREATE TABLE IF NOT EXISTS workspaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT DEFAULT '',
@@ -56,6 +56,15 @@ func (r *SQLiteRepository) initSchema() error {
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS boards (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
description TEXT DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS columns (
id TEXT PRIMARY KEY,
board_id TEXT NOT NULL,
@@ -69,6 +78,7 @@ func (r *SQLiteRepository) initSchema() error {
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL DEFAULT '',
board_id TEXT NOT NULL,
column_id TEXT NOT NULL,
title TEXT NOT NULL,
@@ -87,9 +97,33 @@ func (r *SQLiteRepository) initSchema() error {
FOREIGN KEY (column_id) REFERENCES columns(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS task_boards (
task_id TEXT NOT NULL,
board_id TEXT NOT NULL,
PRIMARY KEY (task_id, board_id),
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS task_columns (
task_id TEXT NOT NULL,
board_id TEXT NOT NULL,
column_id TEXT NOT NULL,
position INTEGER DEFAULT 0,
PRIMARY KEY (task_id, board_id),
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE,
FOREIGN KEY (column_id) REFERENCES columns(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_tasks_board_id ON tasks(board_id);
CREATE INDEX IF NOT EXISTS idx_tasks_column_id ON tasks(column_id);
CREATE INDEX IF NOT EXISTS idx_tasks_workspace_id ON tasks(workspace_id);
CREATE INDEX IF NOT EXISTS idx_columns_board_id ON columns(board_id);
CREATE INDEX IF NOT EXISTS idx_boards_workspace_id ON boards(workspace_id);
CREATE INDEX IF NOT EXISTS idx_task_boards_board_id ON task_boards(board_id);
CREATE INDEX IF NOT EXISTS idx_task_columns_board_id ON task_columns(board_id);
CREATE INDEX IF NOT EXISTS idx_task_columns_column_id ON task_columns(column_id);
CREATE TABLE IF NOT EXISTS task_agent_execution_logs (
id TEXT PRIMARY KEY,
@@ -108,7 +142,123 @@ func (r *SQLiteRepository) initSchema() error {
CREATE INDEX IF NOT EXISTS idx_execution_logs_task_timestamp ON task_agent_execution_logs(task_id, timestamp);
`
_, err := r.db.Exec(schema)
if _, err := r.db.Exec(schema); err != nil {
return err
}
if err := r.ensureColumn("boards", "workspace_id", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := r.ensureColumn("tasks", "workspace_id", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := r.ensureDefaultWorkspace(); err != nil {
return err
}
if err := r.backfillTaskMappings(); err != nil {
return err
}
return nil
}
func (r *SQLiteRepository) ensureColumn(table, column, definition string) error {
exists, err := r.columnExists(table, column)
if err != nil {
return err
}
if exists {
return nil
}
_, err = r.db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition))
return err
}
func (r *SQLiteRepository) columnExists(table, column string) (bool, error) {
rows, err := r.db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var cid int
var name, colType string
var notNull int
var defaultValue *string
var pk int
if err := rows.Scan(&cid, &name, &colType, &notNull, &defaultValue, &pk); err != nil {
return false, err
}
if name == column {
return true, nil
}
}
return false, rows.Err()
}
func (r *SQLiteRepository) ensureDefaultWorkspace() error {
ctx := context.Background()
var count int
if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM workspaces").Scan(&count); err != nil {
return err
}
if count == 0 {
defaultID := uuid.New().String()
now := time.Now().UTC()
if _, err := r.db.ExecContext(ctx, `
INSERT INTO workspaces (id, name, description, owner_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`, defaultID, "Default Workspace", "", "", now, now); err != nil {
return err
}
}
var defaultWorkspaceID string
if err := r.db.QueryRowContext(ctx, "SELECT id FROM workspaces ORDER BY created_at LIMIT 1").Scan(&defaultWorkspaceID); err != nil {
return err
}
if _, err := r.db.ExecContext(ctx, `
UPDATE boards SET workspace_id = ? WHERE workspace_id = '' OR workspace_id IS NULL
`, defaultWorkspaceID); err != nil {
return err
}
if _, err := r.db.ExecContext(ctx, `
UPDATE tasks
SET workspace_id = (
SELECT workspace_id FROM boards WHERE boards.id = tasks.board_id
)
WHERE workspace_id = '' OR workspace_id IS NULL
`); err != nil {
return err
}
return nil
}
func (r *SQLiteRepository) backfillTaskMappings() error {
ctx := context.Background()
_, err := r.db.ExecContext(ctx, `
INSERT OR IGNORE INTO task_boards (task_id, board_id)
SELECT id, board_id FROM tasks WHERE board_id != ''
`)
if err != nil {
return err
}
_, err = r.db.ExecContext(ctx, `
INSERT OR IGNORE INTO task_columns (task_id, board_id, column_id, position)
SELECT id, board_id, column_id, position
FROM tasks
WHERE board_id != '' AND column_id != ''
`)
return err
}
@@ -122,6 +272,93 @@ func (r *SQLiteRepository) DB() *sql.DB {
return r.db
}
// Workspace operations
// CreateWorkspace creates a new workspace
func (r *SQLiteRepository) CreateWorkspace(ctx context.Context, workspace *models.Workspace) error {
if workspace.ID == "" {
workspace.ID = uuid.New().String()
}
now := time.Now().UTC()
workspace.CreatedAt = now
workspace.UpdatedAt = now
_, err := r.db.ExecContext(ctx, `
INSERT INTO workspaces (id, name, description, owner_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`, workspace.ID, workspace.Name, workspace.Description, workspace.OwnerID, workspace.CreatedAt, workspace.UpdatedAt)
return err
}
// GetWorkspace retrieves a workspace by ID
func (r *SQLiteRepository) GetWorkspace(ctx context.Context, id string) (*models.Workspace, error) {
workspace := &models.Workspace{}
err := r.db.QueryRowContext(ctx, `
SELECT id, name, description, owner_id, created_at, updated_at
FROM workspaces WHERE id = ?
`, id).Scan(&workspace.ID, &workspace.Name, &workspace.Description, &workspace.OwnerID, &workspace.CreatedAt, &workspace.UpdatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("workspace not found: %s", id)
}
return workspace, err
}
// UpdateWorkspace updates an existing workspace
func (r *SQLiteRepository) UpdateWorkspace(ctx context.Context, workspace *models.Workspace) error {
workspace.UpdatedAt = time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
UPDATE workspaces SET name = ?, description = ?, updated_at = ? WHERE id = ?
`, workspace.Name, workspace.Description, workspace.UpdatedAt, workspace.ID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("workspace not found: %s", workspace.ID)
}
return nil
}
// DeleteWorkspace deletes a workspace by ID
func (r *SQLiteRepository) DeleteWorkspace(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM workspaces WHERE id = ?`, id)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("workspace not found: %s", id)
}
return nil
}
// ListWorkspaces returns all workspaces
func (r *SQLiteRepository) ListWorkspaces(ctx context.Context) ([]*models.Workspace, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, name, description, owner_id, created_at, updated_at FROM workspaces ORDER BY created_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var result []*models.Workspace
for rows.Next() {
workspace := &models.Workspace{}
if err := rows.Scan(&workspace.ID, &workspace.Name, &workspace.Description, &workspace.OwnerID, &workspace.CreatedAt, &workspace.UpdatedAt); err != nil {
return nil, err
}
result = append(result, workspace)
}
return result, rows.Err()
}
// Task operations
// CreateTask creates a new task
@@ -138,12 +375,28 @@ func (r *SQLiteRepository) CreateTask(ctx context.Context, task *models.Task) er
metadata = []byte("{}")
}
_, err = r.db.ExecContext(ctx, `
INSERT INTO tasks (id, board_id, column_id, title, description, state, priority, agent_type, repository_url, branch, assigned_to, position, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, task.ID, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.AgentType, task.RepositoryURL, task.Branch, task.AssignedTo, task.Position, string(metadata), task.CreatedAt, task.UpdatedAt)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
return err
_, err = tx.ExecContext(ctx, `
INSERT INTO tasks (id, workspace_id, board_id, column_id, title, description, state, priority, agent_type, repository_url, branch, assigned_to, position, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, task.ID, task.WorkspaceID, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.AgentType, task.RepositoryURL, task.Branch, task.AssignedTo, task.Position, string(metadata), task.CreatedAt, task.UpdatedAt)
if err != nil {
tx.Rollback()
return err
}
if task.BoardID != "" {
if err := r.addTaskToBoardTx(ctx, tx, task.ID, task.BoardID, task.ColumnID, task.Position); err != nil {
tx.Rollback()
return err
}
}
return tx.Commit()
}
// GetTask retrieves a task by ID
@@ -152,9 +405,9 @@ func (r *SQLiteRepository) GetTask(ctx context.Context, id string) (*models.Task
var metadata string
err := r.db.QueryRowContext(ctx, `
SELECT id, board_id, column_id, title, description, state, priority, agent_type, repository_url, branch, assigned_to, position, metadata, created_at, updated_at
SELECT id, workspace_id, board_id, column_id, title, description, state, priority, agent_type, repository_url, branch, assigned_to, position, metadata, created_at, updated_at
FROM tasks WHERE id = ?
`, id).Scan(&task.ID, &task.BoardID, &task.ColumnID, &task.Title, &task.Description, &task.State, &task.Priority, &task.AgentType, &task.RepositoryURL, &task.Branch, &task.AssignedTo, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt)
`, id).Scan(&task.ID, &task.WorkspaceID, &task.BoardID, &task.ColumnID, &task.Title, &task.Description, &task.State, &task.Priority, &task.AgentType, &task.RepositoryURL, &task.Branch, &task.AssignedTo, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("task not found: %s", id)
@@ -177,9 +430,9 @@ func (r *SQLiteRepository) UpdateTask(ctx context.Context, task *models.Task) er
}
result, err := r.db.ExecContext(ctx, `
UPDATE tasks SET board_id = ?, column_id = ?, title = ?, description = ?, state = ?, priority = ?, agent_type = ?, repository_url = ?, branch = ?, assigned_to = ?, position = ?, metadata = ?, updated_at = ?
UPDATE tasks SET workspace_id = ?, board_id = ?, column_id = ?, title = ?, description = ?, state = ?, priority = ?, agent_type = ?, repository_url = ?, branch = ?, assigned_to = ?, position = ?, metadata = ?, updated_at = ?
WHERE id = ?
`, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.AgentType, task.RepositoryURL, task.Branch, task.AssignedTo, task.Position, string(metadata), task.UpdatedAt, task.ID)
`, task.WorkspaceID, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.AgentType, task.RepositoryURL, task.Branch, task.AssignedTo, task.Position, string(metadata), task.UpdatedAt, task.ID)
if err != nil {
return err
}
@@ -188,6 +441,12 @@ func (r *SQLiteRepository) UpdateTask(ctx context.Context, task *models.Task) er
if rows == 0 {
return fmt.Errorf("task not found: %s", task.ID)
}
if task.BoardID != "" {
if err := r.AddTaskToBoard(ctx, task.ID, task.BoardID, task.ColumnID, task.Position); err != nil {
return err
}
}
return nil
}
@@ -208,29 +467,36 @@ func (r *SQLiteRepository) DeleteTask(ctx context.Context, id string) error {
// ListTasks returns all tasks for a board
func (r *SQLiteRepository) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, board_id, column_id, title, description, state, priority, agent_type, repository_url, branch, assigned_to, position, metadata, created_at, updated_at
FROM tasks WHERE board_id = ? ORDER BY position
SELECT t.id, t.workspace_id, t.title, t.description, t.state, t.priority, t.agent_type, t.repository_url, t.branch, t.assigned_to, t.metadata, t.created_at, t.updated_at,
tc.board_id, tc.column_id, tc.position
FROM tasks t
INNER JOIN task_columns tc ON tc.task_id = t.id
WHERE tc.board_id = ?
ORDER BY tc.position
`, boardID)
if err != nil {
return nil, err
}
defer rows.Close()
return r.scanTasks(rows)
return r.scanTasksWithPlacement(rows)
}
// ListTasksByColumn returns all tasks in a column
func (r *SQLiteRepository) ListTasksByColumn(ctx context.Context, columnID string) ([]*models.Task, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, board_id, column_id, title, description, state, priority, agent_type, repository_url, branch, assigned_to, position, metadata, created_at, updated_at
FROM tasks WHERE column_id = ? ORDER BY position
SELECT t.id, t.workspace_id, t.title, t.description, t.state, t.priority, t.agent_type, t.repository_url, t.branch, t.assigned_to, t.metadata, t.created_at, t.updated_at,
tc.board_id, tc.column_id, tc.position
FROM tasks t
INNER JOIN task_columns tc ON tc.task_id = t.id
WHERE tc.column_id = ? ORDER BY tc.position
`, columnID)
if err != nil {
return nil, err
}
defer rows.Close()
return r.scanTasks(rows)
return r.scanTasksWithPlacement(rows)
}
// scanTasks scans multiple task rows
@@ -239,7 +505,39 @@ func (r *SQLiteRepository) scanTasks(rows *sql.Rows) ([]*models.Task, error) {
for rows.Next() {
task := &models.Task{}
var metadata string
err := rows.Scan(&task.ID, &task.BoardID, &task.ColumnID, &task.Title, &task.Description, &task.State, &task.Priority, &task.AgentType, &task.RepositoryURL, &task.Branch, &task.AssignedTo, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt)
err := rows.Scan(&task.ID, &task.WorkspaceID, &task.BoardID, &task.ColumnID, &task.Title, &task.Description, &task.State, &task.Priority, &task.AgentType, &task.RepositoryURL, &task.Branch, &task.AssignedTo, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt)
if err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(metadata), &task.Metadata)
result = append(result, task)
}
return result, rows.Err()
}
func (r *SQLiteRepository) scanTasksWithPlacement(rows *sql.Rows) ([]*models.Task, error) {
var result []*models.Task
for rows.Next() {
task := &models.Task{}
var metadata string
err := rows.Scan(
&task.ID,
&task.WorkspaceID,
&task.Title,
&task.Description,
&task.State,
&task.Priority,
&task.AgentType,
&task.RepositoryURL,
&task.Branch,
&task.AssignedTo,
&metadata,
&task.CreatedAt,
&task.UpdatedAt,
&task.BoardID,
&task.ColumnID,
&task.Position,
)
if err != nil {
return nil, err
}
@@ -263,6 +561,46 @@ func (r *SQLiteRepository) UpdateTaskState(ctx context.Context, id string, state
return nil
}
// AddTaskToBoard adds a task to a board with placement
func (r *SQLiteRepository) AddTaskToBoard(ctx context.Context, taskID, boardID, columnID string, position int) error {
_, err := r.db.ExecContext(ctx, `
INSERT OR IGNORE INTO task_boards (task_id, board_id) VALUES (?, ?)
`, taskID, boardID)
if err != nil {
return err
}
_, err = r.db.ExecContext(ctx, `
INSERT INTO task_columns (task_id, board_id, column_id, position)
VALUES (?, ?, ?, ?)
ON CONFLICT(task_id, board_id) DO UPDATE SET column_id = excluded.column_id, position = excluded.position
`, taskID, boardID, columnID, position)
return err
}
// RemoveTaskFromBoard removes a task from a board
func (r *SQLiteRepository) RemoveTaskFromBoard(ctx context.Context, taskID, boardID string) error {
if _, err := r.db.ExecContext(ctx, `DELETE FROM task_columns WHERE task_id = ? AND board_id = ?`, taskID, boardID); err != nil {
return err
}
_, err := r.db.ExecContext(ctx, `DELETE FROM task_boards WHERE task_id = ? AND board_id = ?`, taskID, boardID)
return err
}
func (r *SQLiteRepository) addTaskToBoardTx(ctx context.Context, tx *sql.Tx, taskID, boardID, columnID string, position int) error {
if _, err := tx.ExecContext(ctx, `
INSERT OR IGNORE INTO task_boards (task_id, board_id) VALUES (?, ?)
`, taskID, boardID); err != nil {
return err
}
_, err := tx.ExecContext(ctx, `
INSERT INTO task_columns (task_id, board_id, column_id, position)
VALUES (?, ?, ?, ?)
ON CONFLICT(task_id, board_id) DO UPDATE SET column_id = excluded.column_id, position = excluded.position
`, taskID, boardID, columnID, position)
return err
}
// Board operations
// CreateBoard creates a new board
@@ -275,9 +613,9 @@ func (r *SQLiteRepository) CreateBoard(ctx context.Context, board *models.Board)
board.UpdatedAt = now
_, err := r.db.ExecContext(ctx, `
INSERT INTO boards (id, name, description, owner_id, created_at, updated_at)
INSERT INTO boards (id, workspace_id, name, description, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`, board.ID, board.Name, board.Description, board.OwnerID, board.CreatedAt, board.UpdatedAt)
`, board.ID, board.WorkspaceID, board.Name, board.Description, board.CreatedAt, board.UpdatedAt)
return err
}
@@ -287,9 +625,9 @@ func (r *SQLiteRepository) GetBoard(ctx context.Context, id string) (*models.Boa
board := &models.Board{}
err := r.db.QueryRowContext(ctx, `
SELECT id, name, description, owner_id, created_at, updated_at
SELECT id, workspace_id, name, description, created_at, updated_at
FROM boards WHERE id = ?
`, id).Scan(&board.ID, &board.Name, &board.Description, &board.OwnerID, &board.CreatedAt, &board.UpdatedAt)
`, id).Scan(&board.ID, &board.WorkspaceID, &board.Name, &board.Description, &board.CreatedAt, &board.UpdatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("board not found: %s", id)
@@ -330,10 +668,18 @@ func (r *SQLiteRepository) DeleteBoard(ctx context.Context, id string) error {
}
// ListBoards returns all boards
func (r *SQLiteRepository) ListBoards(ctx context.Context) ([]*models.Board, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, name, description, owner_id, created_at, updated_at FROM boards ORDER BY created_at DESC
`)
func (r *SQLiteRepository) ListBoards(ctx context.Context, workspaceID string) ([]*models.Board, error) {
query := `
SELECT id, workspace_id, name, description, created_at, updated_at FROM boards
`
var args []interface{}
if workspaceID != "" {
query += " WHERE workspace_id = ?"
args = append(args, workspaceID)
}
query += " ORDER BY created_at DESC"
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
@@ -342,7 +688,7 @@ func (r *SQLiteRepository) ListBoards(ctx context.Context) ([]*models.Board, err
var result []*models.Board
for rows.Next() {
board := &models.Board{}
err := rows.Scan(&board.ID, &board.Name, &board.Description, &board.OwnerID, &board.CreatedAt, &board.UpdatedAt)
err := rows.Scan(&board.ID, &board.WorkspaceID, &board.Name, &board.Description, &board.CreatedAt, &board.UpdatedAt)
if err != nil {
return nil, err
}
@@ -55,8 +55,13 @@ func TestSQLiteRepository_BoardCRUD(t *testing.T) {
defer cleanup()
ctx := context.Background()
workspace := &models.Workspace{ID: "ws-1", Name: "Workspace"}
if err := repo.CreateWorkspace(ctx, workspace); err != nil {
t.Fatalf("failed to create workspace: %v", err)
}
// Create
board := &models.Board{Name: "Test Board", Description: "A test board", OwnerID: "owner-123"}
board := &models.Board{WorkspaceID: workspace.ID, Name: "Test Board", Description: "A test board"}
if err := repo.CreateBoard(ctx, board); err != nil {
t.Fatalf("failed to create board: %v", err)
}
@@ -122,16 +127,19 @@ func TestSQLiteRepository_ListBoards(t *testing.T) {
defer cleanup()
ctx := context.Background()
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-1", Name: "Board 1"})
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-2", Name: "Board 2"})
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-3", Name: "Board 3"})
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"})
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-2", Name: "Workspace 2"})
boards, err := repo.ListBoards(ctx)
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-1", WorkspaceID: "ws-1", Name: "Board 1"})
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-2", WorkspaceID: "ws-1", Name: "Board 2"})
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-3", WorkspaceID: "ws-2", Name: "Board 3"})
boards, err := repo.ListBoards(ctx, "ws-1")
if err != nil {
t.Fatalf("failed to list boards: %v", err)
}
if len(boards) != 3 {
t.Errorf("expected 3 boards, got %d", len(boards))
if len(boards) != 2 {
t.Errorf("expected 2 boards, got %d", len(boards))
}
}
@@ -142,8 +150,9 @@ func TestSQLiteRepository_ColumnCRUD(t *testing.T) {
defer cleanup()
ctx := context.Background()
// First create a board for foreign key
board := &models.Board{ID: "board-123", Name: "Test Board"}
// First create a workspace and board for foreign key
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
// Create column
@@ -211,7 +220,8 @@ func TestSQLiteRepository_ListColumns(t *testing.T) {
defer cleanup()
ctx := context.Background()
board := &models.Board{ID: "board-123", Name: "Test Board"}
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-1", BoardID: "board-123", Name: "To Do"})
@@ -233,14 +243,16 @@ func TestSQLiteRepository_TaskCRUD(t *testing.T) {
defer cleanup()
ctx := context.Background()
// Create board and column for foreign keys
board := &models.Board{ID: "board-123", Name: "Test Board"}
// Create workspace, board, and column for foreign keys
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "To Do"}
_ = repo.CreateColumn(ctx, column)
// Create task
task := &models.Task{
WorkspaceID: "ws-1",
BoardID: "board-123",
ColumnID: "col-123",
Title: "Test Task",
@@ -314,12 +326,13 @@ func TestSQLiteRepository_UpdateTaskState(t *testing.T) {
defer cleanup()
ctx := context.Background()
// Create board, column, and task
board := &models.Board{ID: "board-123", Name: "Test Board"}
// Create workspace, board, column, and task
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "To Do"}
_ = repo.CreateColumn(ctx, column)
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test", State: v1.TaskStateTODO}
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test", State: v1.TaskStateTODO}
_ = repo.CreateTask(ctx, task)
err := repo.UpdateTaskState(ctx, "task-123", v1.TaskStateInProgress)
@@ -349,14 +362,15 @@ func TestSQLiteRepository_ListTasks(t *testing.T) {
defer cleanup()
ctx := context.Background()
// Create board and column
board := &models.Board{ID: "board-123", Name: "Test Board"}
// Create workspace, board, and column
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "To Do"}
_ = repo.CreateColumn(ctx, column)
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", BoardID: "board-123", ColumnID: "col-123", Title: "Task 1"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", BoardID: "board-123", ColumnID: "col-123", Title: "Task 2"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Task 1"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Task 2"})
tasks, err := repo.ListTasks(ctx, "board-123")
if err != nil {
@@ -372,17 +386,18 @@ func TestSQLiteRepository_ListTasksByColumn(t *testing.T) {
defer cleanup()
ctx := context.Background()
// Create board and columns
board := &models.Board{ID: "board-123", Name: "Test Board"}
// Create workspace, board, and columns
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
col1 := &models.Column{ID: "col-1", BoardID: "board-123", Name: "To Do"}
col2 := &models.Column{ID: "col-2", BoardID: "board-123", Name: "Done"}
_ = repo.CreateColumn(ctx, col1)
_ = repo.CreateColumn(ctx, col2)
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", BoardID: "board-123", ColumnID: "col-1", Title: "Task 1"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", BoardID: "board-123", ColumnID: "col-1", Title: "Task 2"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-3", BoardID: "board-123", ColumnID: "col-2", Title: "Task 3"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-1", Title: "Task 1"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-1", Title: "Task 2"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-2", Title: "Task 3"})
tasks, err := repo.ListTasksByColumn(ctx, "col-1")
if err != nil {
@@ -404,7 +419,8 @@ func TestSQLiteRepository_Persistence(t *testing.T) {
t.Fatalf("failed to create first repository: %v", err)
}
board := &models.Board{ID: "persist-board", Name: "Persistent Board"}
_ = repo1.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
board := &models.Board{ID: "persist-board", WorkspaceID: "ws-1", Name: "Persistent Board"}
_ = repo1.CreateBoard(ctx, board)
repo1.Close()
@@ -423,4 +439,3 @@ func TestSQLiteRepository_Persistence(t *testing.T) {
t.Errorf("expected name 'Persistent Board', got %s", retrieved.Name)
}
}
+88 -8
View File
@@ -35,6 +35,7 @@ func NewService(repo repository.Repository, eventBus bus.EventBus, log *logger.L
// CreateTaskRequest contains the data for creating a new task
type CreateTaskRequest struct {
WorkspaceID string `json:"workspace_id"`
BoardID string `json:"board_id"`
ColumnID string `json:"column_id"`
Title string `json:"title"`
@@ -61,13 +62,26 @@ type UpdateTaskRequest struct {
// CreateBoardRequest contains the data for creating a new board
type CreateBoardRequest struct {
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description"`
}
// UpdateBoardRequest contains the data for updating a board
type UpdateBoardRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
}
// CreateWorkspaceRequest contains the data for creating a new workspace
type CreateWorkspaceRequest struct {
Name string `json:"name"`
Description string `json:"description"`
OwnerID string `json:"owner_id"`
}
// UpdateBoardRequest contains the data for updating a board
type UpdateBoardRequest struct {
// UpdateWorkspaceRequest contains the data for updating a workspace
type UpdateWorkspaceRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
}
@@ -86,6 +100,7 @@ type CreateColumnRequest struct {
func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*models.Task, error) {
task := &models.Task{
ID: uuid.New().String(),
WorkspaceID: req.WorkspaceID,
BoardID: req.BoardID,
ColumnID: req.ColumnID,
Title: req.Title,
@@ -235,7 +250,7 @@ func (s *Service) UpdateTaskMetadata(ctx context.Context, id string, metadata ma
}
// MoveTask moves a task to a different column and position
func (s *Service) MoveTask(ctx context.Context, id string, columnID string, position int) (*models.Task, error) {
func (s *Service) MoveTask(ctx context.Context, id string, boardID string, columnID string, position int) (*models.Task, error) {
task, err := s.repo.GetTask(ctx, id)
if err != nil {
return nil, err
@@ -248,6 +263,7 @@ func (s *Service) MoveTask(ctx context.Context, id string, columnID string, posi
}
oldState := task.State
task.BoardID = boardID
task.ColumnID = columnID
task.Position = position
task.State = column.State
@@ -267,21 +283,86 @@ func (s *Service) MoveTask(ctx context.Context, id string, columnID string, posi
s.logger.Info("task moved",
zap.String("task_id", id),
zap.String("board_id", boardID),
zap.String("column_id", columnID),
zap.Int("position", position))
return task, nil
}
// Workspace operations
// CreateWorkspace creates a new workspace
func (s *Service) CreateWorkspace(ctx context.Context, req *CreateWorkspaceRequest) (*models.Workspace, error) {
workspace := &models.Workspace{
ID: uuid.New().String(),
Name: req.Name,
Description: req.Description,
OwnerID: req.OwnerID,
}
if err := s.repo.CreateWorkspace(ctx, workspace); err != nil {
s.logger.Error("failed to create workspace", zap.Error(err))
return nil, err
}
s.logger.Info("workspace created", zap.String("workspace_id", workspace.ID), zap.String("name", workspace.Name))
return workspace, nil
}
// GetWorkspace retrieves a workspace by ID
func (s *Service) GetWorkspace(ctx context.Context, id string) (*models.Workspace, error) {
return s.repo.GetWorkspace(ctx, id)
}
// UpdateWorkspace updates an existing workspace
func (s *Service) UpdateWorkspace(ctx context.Context, id string, req *UpdateWorkspaceRequest) (*models.Workspace, error) {
workspace, err := s.repo.GetWorkspace(ctx, id)
if err != nil {
return nil, err
}
if req.Name != nil {
workspace.Name = *req.Name
}
if req.Description != nil {
workspace.Description = *req.Description
}
workspace.UpdatedAt = time.Now().UTC()
if err := s.repo.UpdateWorkspace(ctx, workspace); err != nil {
s.logger.Error("failed to update workspace", zap.String("workspace_id", id), zap.Error(err))
return nil, err
}
s.logger.Info("workspace updated", zap.String("workspace_id", workspace.ID))
return workspace, nil
}
// DeleteWorkspace deletes a workspace
func (s *Service) DeleteWorkspace(ctx context.Context, id string) error {
if err := s.repo.DeleteWorkspace(ctx, id); err != nil {
s.logger.Error("failed to delete workspace", zap.String("workspace_id", id), zap.Error(err))
return err
}
s.logger.Info("workspace deleted", zap.String("workspace_id", id))
return nil
}
// ListWorkspaces returns all workspaces
func (s *Service) ListWorkspaces(ctx context.Context) ([]*models.Workspace, error) {
return s.repo.ListWorkspaces(ctx)
}
// Board operations
// CreateBoard creates a new board
func (s *Service) CreateBoard(ctx context.Context, req *CreateBoardRequest) (*models.Board, error) {
board := &models.Board{
ID: uuid.New().String(),
WorkspaceID: req.WorkspaceID,
Name: req.Name,
Description: req.Description,
OwnerID: req.OwnerID,
}
if err := s.repo.CreateBoard(ctx, board); err != nil {
@@ -333,9 +414,9 @@ func (s *Service) DeleteBoard(ctx context.Context, id string) error {
return nil
}
// ListBoards returns all boards
func (s *Service) ListBoards(ctx context.Context) ([]*models.Board, error) {
return s.repo.ListBoards(ctx)
// ListBoards returns all boards for a workspace (or all if empty)
func (s *Service) ListBoards(ctx context.Context, workspaceID string) ([]*models.Board, error) {
return s.repo.ListBoards(ctx, workspaceID)
}
// Column operations
@@ -415,4 +496,3 @@ func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *
zap.Error(err))
}
}
@@ -15,7 +15,7 @@ import (
// MockEventBus implements bus.EventBus for testing
type MockEventBus struct {
mu sync.Mutex
mu sync.Mutex
publishedEvents []*bus.Event
closed bool
}
@@ -83,12 +83,14 @@ func TestService_CreateTask(t *testing.T) {
ctx := context.Background()
// Create board and column first
board := &models.Board{ID: "board-123", Name: "Test Board"}
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "To Do", State: v1.TaskStateTODO}
_ = repo.CreateColumn(ctx, column)
req := &CreateTaskRequest{
WorkspaceID: "ws-1",
BoardID: "board-123",
ColumnID: "col-123",
Title: "Test Task",
@@ -126,7 +128,7 @@ func TestService_GetTask(t *testing.T) {
ctx := context.Background()
// Create a task directly in repo
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
_ = repo.CreateTask(ctx, task)
retrieved, err := svc.GetTask(ctx, "task-123")
@@ -152,7 +154,7 @@ func TestService_UpdateTask(t *testing.T) {
svc, eventBus, repo := createTestService(t)
ctx := context.Background()
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Original"}
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Original"}
_ = repo.CreateTask(ctx, task)
eventBus.ClearEvents()
@@ -181,7 +183,7 @@ func TestService_DeleteTask(t *testing.T) {
svc, eventBus, repo := createTestService(t)
ctx := context.Background()
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test"}
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test"}
_ = repo.CreateTask(ctx, task)
eventBus.ClearEvents()
@@ -207,8 +209,8 @@ func TestService_ListTasks(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", BoardID: "board-123", ColumnID: "col-123", Title: "Task 1"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", BoardID: "board-123", ColumnID: "col-123", Title: "Task 2"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Task 1"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Task 2"})
tasks, err := svc.ListTasks(ctx, "board-123")
if err != nil {
@@ -223,7 +225,8 @@ func TestService_UpdateTaskState(t *testing.T) {
svc, eventBus, repo := createTestService(t)
ctx := context.Background()
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test", State: v1.TaskStateTODO}
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test", State: v1.TaskStateTODO}
_ = repo.CreateTask(ctx, task)
eventBus.ClearEvents()
@@ -249,15 +252,18 @@ func TestService_MoveTask(t *testing.T) {
svc, eventBus, repo := createTestService(t)
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"})
// Create column with different state
column := &models.Column{ID: "col-done", BoardID: "board-123", Name: "Done", State: v1.TaskStateCompleted}
_ = repo.CreateColumn(ctx, column)
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test", State: v1.TaskStateTODO}
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test", State: v1.TaskStateTODO}
_ = repo.CreateTask(ctx, task)
eventBus.ClearEvents()
moved, err := svc.MoveTask(ctx, "task-123", "col-done", 0)
moved, err := svc.MoveTask(ctx, "task-123", "board-123", "col-done", 0)
if err != nil {
t.Fatalf("failed to move task: %v", err)
}
@@ -272,13 +278,14 @@ func TestService_MoveTask(t *testing.T) {
// Board tests
func TestService_CreateBoard(t *testing.T) {
svc, _, _ := createTestService(t)
svc, _, repo := createTestService(t)
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
req := &CreateBoardRequest{
WorkspaceID: "ws-1",
Name: "Test Board",
Description: "A test board",
OwnerID: "owner-123",
}
board, err := svc.CreateBoard(ctx, req)
@@ -297,7 +304,8 @@ func TestService_GetBoard(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
board := &models.Board{ID: "board-123", Name: "Test Board"}
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
retrieved, err := svc.GetBoard(ctx, "board-123")
@@ -313,7 +321,8 @@ func TestService_UpdateBoard(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
board := &models.Board{ID: "board-123", Name: "Original"}
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Original"}
_ = repo.CreateBoard(ctx, board)
newName := "Updated"
@@ -327,13 +336,12 @@ func TestService_UpdateBoard(t *testing.T) {
t.Errorf("expected name 'Updated', got %s", updated.Name)
}
}
func TestService_DeleteBoard(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
board := &models.Board{ID: "board-123", Name: "Test Board"}
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
err := svc.DeleteBoard(ctx, "board-123")
@@ -351,10 +359,11 @@ func TestService_ListBoards(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-1", Name: "Board 1"})
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-2", Name: "Board 2"})
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-1", WorkspaceID: "ws-1", Name: "Board 1"})
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-2", WorkspaceID: "ws-1", Name: "Board 2"})
boards, err := svc.ListBoards(ctx)
boards, err := svc.ListBoards(ctx, "ws-1")
if err != nil {
t.Fatalf("failed to list boards: %v", err)
}
@@ -369,7 +378,8 @@ func TestService_CreateColumn(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
board := &models.Board{ID: "board-123", Name: "Test Board"}
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
req := &CreateColumnRequest{
@@ -395,6 +405,8 @@ func TestService_GetColumn(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"})
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "To Do"}
_ = repo.CreateColumn(ctx, column)
@@ -411,6 +423,8 @@ func TestService_ListColumns(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"})
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-1", BoardID: "board-123", Name: "To Do"})
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-2", BoardID: "board-123", Name: "Done"})
@@ -422,4 +436,3 @@ func TestService_ListColumns(t *testing.T) {
t.Errorf("expected 2 columns, got %d", len(columns))
}
}
+2 -3
View File
@@ -8,8 +8,8 @@ type AgentStatus string
const (
AgentStatusPending AgentStatus = "PENDING"
AgentStatusStarting AgentStatus = "STARTING"
AgentStatusRunning AgentStatus = "RUNNING" // Agent is processing a prompt
AgentStatusReady AgentStatus = "READY" // Agent finished prompt, waiting for next
AgentStatusRunning AgentStatus = "RUNNING" // Agent is processing a prompt
AgentStatusReady AgentStatus = "READY" // Agent finished prompt, waiting for next
AgentStatusCompleted AgentStatus = "COMPLETED"
AgentStatusFailed AgentStatus = "FAILED"
AgentStatusStopped AgentStatus = "STOPPED"
@@ -64,4 +64,3 @@ type AgentLog struct {
Metadata map[string]interface{} `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
+1 -2
View File
@@ -5,9 +5,9 @@ import "time"
// Board represents a Kanban board
type Board struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_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"`
}
@@ -23,4 +23,3 @@ type UpdateBoardRequest struct {
Name *string `json:"name,omitempty" binding:"omitempty,max=255"`
Description *string `json:"description,omitempty"`
}
+1 -1
View File
@@ -17,6 +17,7 @@ const (
// Task represents a Kanban task
type Task struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
BoardID string `json:"board_id"`
Title string `json:"title"`
Description string `json:"description"`
@@ -72,4 +73,3 @@ type TaskEvent struct {
CreatedBy *string `json:"created_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
-1
View File
@@ -10,4 +10,3 @@ type User struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+25
View File
@@ -0,0 +1,25 @@
package v1
import "time"
// Workspace represents a workspace
type Workspace 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"`
}
// CreateWorkspaceRequest for creating a new workspace
type CreateWorkspaceRequest struct {
Name string `json:"name" binding:"required,max=255"`
Description *string `json:"description,omitempty"`
}
// UpdateWorkspaceRequest for updating an existing workspace
type UpdateWorkspaceRequest struct {
Name *string `json:"name,omitempty" binding:"omitempty,max=255"`
Description *string `json:"description,omitempty"`
}
+15 -9
View File
@@ -12,6 +12,13 @@ const (
ActionBoardUpdate = "board.update"
ActionBoardDelete = "board.delete"
// Workspace actions
ActionWorkspaceList = "workspace.list"
ActionWorkspaceCreate = "workspace.create"
ActionWorkspaceGet = "workspace.get"
ActionWorkspaceUpdate = "workspace.update"
ActionWorkspaceDelete = "workspace.delete"
// Column actions
ActionColumnList = "column.list"
ActionColumnCreate = "column.create"
@@ -54,14 +61,14 @@ const (
ActionTaskUnsubscribe = "task.unsubscribe"
// Notification actions (server -> client)
ActionACPProgress = "acp.progress"
ActionACPLog = "acp.log"
ActionACPResult = "acp.result"
ActionACPError = "acp.error"
ActionACPStatus = "acp.status"
ActionACPHeartbeat = "acp.heartbeat"
ActionTaskUpdated = "task.updated"
ActionAgentUpdated = "agent.updated"
ActionACPProgress = "acp.progress"
ActionACPLog = "acp.log"
ActionACPResult = "acp.result"
ActionACPError = "acp.error"
ActionACPStatus = "acp.status"
ActionACPHeartbeat = "acp.heartbeat"
ActionTaskUpdated = "task.updated"
ActionAgentUpdated = "agent.updated"
)
// Error codes
@@ -74,4 +81,3 @@ const (
ErrorCodeValidation = "VALIDATION_ERROR"
ErrorCodeUnknownAction = "UNKNOWN_ACTION"
)
-1
View File
@@ -53,4 +53,3 @@ func (d *Dispatcher) HasHandler(action string) bool {
_, ok := d.handlers[action]
return ok
}
-1
View File
@@ -128,4 +128,3 @@ func (m *Message) ParsePayload(v interface{}) error {
}
return json.Unmarshal(m.Payload, v)
}
+2 -1
View File
@@ -6,7 +6,8 @@
"dev": "next dev",
"build": "NODE_ENV=production next build",
"start": "NODE_ENV=production next start",
"lint": "eslint"
"lint": "eslint",
"test": "echo \"No tests yet\" && exit 0"
},
"dependencies": {
"@base-ui/react": "^1.0.0",