diff --git a/Makefile b/Makefile index e174925400..bacccf38c2 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 3b42a02886..295bed4caf 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -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)") diff --git a/apps/backend/internal/integration/integration_test.go b/apps/backend/internal/integration/integration_test.go index d4c750bb9d..0a5694292c 100644 --- a/apps/backend/internal/integration/integration_test.go +++ b/apps/backend/internal/integration/integration_test.go @@ -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 diff --git a/apps/backend/internal/integration/orchestrator_test.go b/apps/backend/internal/integration/orchestrator_test.go index 0238c4795a..c138d18855 100644 --- a/apps/backend/internal/integration/orchestrator_test.go +++ b/apps/backend/internal/integration/orchestrator_test.go @@ -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) diff --git a/apps/backend/internal/task/controller/board.go b/apps/backend/internal/task/controller/board.go index 750cf13cba..9d2d508dec 100644 --- a/apps/backend/internal/task/controller/board.go +++ b/apps/backend/internal/task/controller/board.go @@ -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 diff --git a/apps/backend/internal/task/controller/task.go b/apps/backend/internal/task/controller/task.go index 2b85580c5e..11b741ccce 100644 --- a/apps/backend/internal/task/controller/task.go +++ b/apps/backend/internal/task/controller/task.go @@ -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 } diff --git a/apps/backend/internal/task/controller/workspace.go b/apps/backend/internal/task/controller/workspace.go new file mode 100644 index 0000000000..6ccc7c53ed --- /dev/null +++ b/apps/backend/internal/task/controller/workspace.go @@ -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 +} diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index c35989419a..8951c3c162 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -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, diff --git a/apps/backend/internal/task/dto/requests.go b/apps/backend/internal/task/dto/requests.go index 74f012f732..ccc5d7d188 100644 --- a/apps/backend/internal/task/dto/requests.go +++ b/apps/backend/internal/task/dto/requests.go @@ -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 } diff --git a/apps/backend/internal/task/handlers/board_handlers.go b/apps/backend/internal/task/handlers/board_handlers.go index e399146721..3e76311622 100644 --- a/apps/backend/internal/task/handlers/board_handlers.go +++ b/apps/backend/internal/task/handlers/board_handlers.go @@ -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, }) diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index d4d843e2d7..fd912e7e50 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -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, }) diff --git a/apps/backend/internal/task/handlers/workspace_handlers.go b/apps/backend/internal/task/handlers/workspace_handlers.go new file mode 100644 index 0000000000..91a975964d --- /dev/null +++ b/apps/backend/internal/task/handlers/workspace_handlers.go @@ -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) +} diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index ef291a6149..9fc63493b1 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -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, } } - diff --git a/apps/backend/internal/task/models/models_test.go b/apps/backend/internal/task/models/models_test.go index 53a83de8a2..636274fb7f 100644 --- a/apps/backend/internal/task/models/models_test.go +++ b/apps/backend/internal/task/models/models_test.go @@ -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) } } - diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 940c3b1fee..aeaeb5b506 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -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 } - diff --git a/apps/backend/internal/task/repository/memory.go b/apps/backend/internal/task/repository/memory.go index f6f019a137..b6c630c3cc 100644 --- a/apps/backend/internal/task/repository/memory.go +++ b/apps/backend/internal/task/repository/memory.go @@ -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, ©) } 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, ©) } } 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 +} diff --git a/apps/backend/internal/task/repository/memory_test.go b/apps/backend/internal/task/repository/memory_test.go index d9d086db02..3071f0d81c 100644 --- a/apps/backend/internal/task/repository/memory_test.go +++ b/apps/backend/internal/task/repository/memory_test.go @@ -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)) } } - diff --git a/apps/backend/internal/task/repository/sqlite.go b/apps/backend/internal/task/repository/sqlite.go index dc7cd09142..a99eed9db9 100644 --- a/apps/backend/internal/task/repository/sqlite.go +++ b/apps/backend/internal/task/repository/sqlite.go @@ -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, ¬Null, &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 } diff --git a/apps/backend/internal/task/repository/sqlite_test.go b/apps/backend/internal/task/repository/sqlite_test.go index 863e9f58a3..13781a4b46 100644 --- a/apps/backend/internal/task/repository/sqlite_test.go +++ b/apps/backend/internal/task/repository/sqlite_test.go @@ -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) } } - diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 7be9f5e8cb..0088af5272 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -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)) } } - diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index a25b5c0144..4225d5a115 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -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)) } } - diff --git a/apps/backend/pkg/api/v1/agent.go b/apps/backend/pkg/api/v1/agent.go index 4c966b9cae..cd94897e39 100644 --- a/apps/backend/pkg/api/v1/agent.go +++ b/apps/backend/pkg/api/v1/agent.go @@ -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"` } - diff --git a/apps/backend/pkg/api/v1/board.go b/apps/backend/pkg/api/v1/board.go index d62961dd1c..b992ca647b 100644 --- a/apps/backend/pkg/api/v1/board.go +++ b/apps/backend/pkg/api/v1/board.go @@ -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"` } - diff --git a/apps/backend/pkg/api/v1/task.go b/apps/backend/pkg/api/v1/task.go index 68091d2b08..904cd49604 100644 --- a/apps/backend/pkg/api/v1/task.go +++ b/apps/backend/pkg/api/v1/task.go @@ -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"` } - diff --git a/apps/backend/pkg/api/v1/user.go b/apps/backend/pkg/api/v1/user.go index 22fa51f241..5d18f682b8 100644 --- a/apps/backend/pkg/api/v1/user.go +++ b/apps/backend/pkg/api/v1/user.go @@ -10,4 +10,3 @@ type User struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } - diff --git a/apps/backend/pkg/api/v1/workspace.go b/apps/backend/pkg/api/v1/workspace.go new file mode 100644 index 0000000000..6329f53f72 --- /dev/null +++ b/apps/backend/pkg/api/v1/workspace.go @@ -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"` +} diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index b7650c05fd..c9296a2197 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -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" ) - diff --git a/apps/backend/pkg/websocket/handler.go b/apps/backend/pkg/websocket/handler.go index 7c2c83b2bb..b864587bb4 100644 --- a/apps/backend/pkg/websocket/handler.go +++ b/apps/backend/pkg/websocket/handler.go @@ -53,4 +53,3 @@ func (d *Dispatcher) HasHandler(action string) bool { _, ok := d.handlers[action] return ok } - diff --git a/apps/backend/pkg/websocket/message.go b/apps/backend/pkg/websocket/message.go index efdfcb0e0a..254a80eb73 100644 --- a/apps/backend/pkg/websocket/message.go +++ b/apps/backend/pkg/websocket/message.go @@ -128,4 +128,3 @@ func (m *Message) ParsePayload(v interface{}) error { } return json.Unmarshal(m.Payload, v) } - diff --git a/apps/web/package.json b/apps/web/package.json index ee2d582d29..ca4c7e50a0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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",