diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 295bed4caf..f63ad33d6e 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -219,6 +219,7 @@ func main() { // Start the WebSocket hub go gateway.Hub.Run(ctx) + gateways.RegisterTaskNotifications(ctx, eventBus, gateway.Hub, log) // Set up historical logs provider for task subscriptions gateway.Hub.SetHistoricalLogsProvider(func(ctx context.Context, taskID string) ([]*ws.Message, error) { diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index 6d5a3bd9c3..503992a0f0 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -9,6 +9,27 @@ const ( TaskDeleted = "task.deleted" ) +// Event types for workspaces +const ( + WorkspaceCreated = "workspace.created" + WorkspaceUpdated = "workspace.updated" + WorkspaceDeleted = "workspace.deleted" +) + +// Event types for boards +const ( + BoardCreated = "board.created" + BoardUpdated = "board.updated" + BoardDeleted = "board.deleted" +) + +// Event types for columns +const ( + ColumnCreated = "column.created" + ColumnUpdated = "column.updated" + ColumnDeleted = "column.deleted" +) + // Event types for agents const ( AgentStarted = "agent.started" @@ -33,4 +54,3 @@ func BuildACPSubject(taskID string) string { func BuildACPWildcardSubject() string { return ACPMessage + ".*" } - diff --git a/apps/backend/internal/gateway/websocket/task_notifications.go b/apps/backend/internal/gateway/websocket/task_notifications.go new file mode 100644 index 0000000000..696a99fb2f --- /dev/null +++ b/apps/backend/internal/gateway/websocket/task_notifications.go @@ -0,0 +1,70 @@ +package websocket + +import ( + "context" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +type TaskEventBroadcaster struct { + hub *Hub + subscriptions []bus.Subscription + logger *logger.Logger +} + +func RegisterTaskNotifications(ctx context.Context, eventBus bus.EventBus, hub *Hub, log *logger.Logger) *TaskEventBroadcaster { + b := &TaskEventBroadcaster{ + hub: hub, + logger: log.WithFields(zap.String("component", "ws-task-broadcaster")), + } + if eventBus == nil { + return b + } + + b.subscribe(eventBus, events.WorkspaceCreated, ws.ActionWorkspaceCreated) + b.subscribe(eventBus, events.WorkspaceUpdated, ws.ActionWorkspaceUpdated) + b.subscribe(eventBus, events.WorkspaceDeleted, ws.ActionWorkspaceDeleted) + b.subscribe(eventBus, events.BoardCreated, ws.ActionBoardCreated) + b.subscribe(eventBus, events.BoardUpdated, ws.ActionBoardUpdated) + b.subscribe(eventBus, events.BoardDeleted, ws.ActionBoardDeleted) + b.subscribe(eventBus, events.ColumnCreated, ws.ActionColumnCreated) + b.subscribe(eventBus, events.ColumnUpdated, ws.ActionColumnUpdated) + b.subscribe(eventBus, events.ColumnDeleted, ws.ActionColumnDeleted) + + go func() { + <-ctx.Done() + b.Close() + }() + + return b +} + +func (b *TaskEventBroadcaster) Close() { + for _, sub := range b.subscriptions { + if sub != nil && sub.IsValid() { + _ = sub.Unsubscribe() + } + } + b.subscriptions = nil +} + +func (b *TaskEventBroadcaster) subscribe(eventBus bus.EventBus, subject, action string) { + sub, err := eventBus.Subscribe(subject, func(ctx context.Context, event *bus.Event) error { + msg, err := ws.NewNotification(action, event.Data) + if err != nil { + b.logger.Error("failed to build websocket notification", zap.String("action", action), zap.Error(err)) + return nil + } + b.hub.Broadcast(msg) + return nil + }) + if err != nil { + b.logger.Error("failed to subscribe to events", zap.String("subject", subject), zap.Error(err)) + return + } + b.subscriptions = append(b.subscriptions, sub) +} diff --git a/apps/backend/internal/task/controller/board.go b/apps/backend/internal/task/controller/board.go index 9d2d508dec..11904cacae 100644 --- a/apps/backend/internal/task/controller/board.go +++ b/apps/backend/internal/task/controller/board.go @@ -97,6 +97,7 @@ func (c *BoardController) CreateColumn(ctx context.Context, req dto.CreateColumn Name: req.Name, Position: req.Position, State: req.State, + Color: req.Color, }) if err != nil { return dto.ColumnDTO{}, err @@ -104,6 +105,23 @@ func (c *BoardController) CreateColumn(ctx context.Context, req dto.CreateColumn return dto.FromColumn(column), nil } +func (c *BoardController) UpdateColumn(ctx context.Context, req dto.UpdateColumnRequest) (dto.ColumnDTO, error) { + column, err := c.service.UpdateColumn(ctx, req.ID, &service.UpdateColumnRequest{ + Name: req.Name, + Position: req.Position, + State: req.State, + Color: req.Color, + }) + if err != nil { + return dto.ColumnDTO{}, err + } + return dto.FromColumn(column), nil +} + +func (c *BoardController) DeleteColumn(ctx context.Context, req dto.GetColumnRequest) error { + return c.service.DeleteColumn(ctx, req.ID) +} + func (c *BoardController) GetSnapshot(ctx context.Context, req dto.GetBoardSnapshotRequest) (dto.BoardSnapshotDTO, error) { board, err := c.service.GetBoard(ctx, req.BoardID) if err != nil { diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 8951c3c162..3533785086 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -31,6 +31,7 @@ type ColumnDTO struct { Name string `json:"name"` Position int `json:"position"` State v1.TaskState `json:"state"` + Color string `json:"color"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -123,6 +124,7 @@ func FromColumn(column *models.Column) ColumnDTO { Name: column.Name, Position: column.Position, State: column.State, + Color: column.Color, CreatedAt: column.CreatedAt, UpdatedAt: column.UpdatedAt, } diff --git a/apps/backend/internal/task/dto/requests.go b/apps/backend/internal/task/dto/requests.go index ccc5d7d188..bcde30757b 100644 --- a/apps/backend/internal/task/dto/requests.go +++ b/apps/backend/internal/task/dto/requests.go @@ -61,6 +61,15 @@ type CreateColumnRequest struct { Name string Position int State v1.TaskState + Color string +} + +type UpdateColumnRequest struct { + ID string + Name *string + Position *int + State *v1.TaskState + Color *string } type GetBoardSnapshotRequest struct { diff --git a/apps/backend/internal/task/handlers/board_handlers.go b/apps/backend/internal/task/handlers/board_handlers.go index 3e76311622..7f460a817e 100644 --- a/apps/backend/internal/task/handlers/board_handlers.go +++ b/apps/backend/internal/task/handlers/board_handlers.go @@ -39,6 +39,12 @@ func (h *BoardHandlers) registerHTTP(router *gin.Engine) { api.GET("/boards/:id", h.httpGetBoard) api.GET("/boards/:id/columns", h.httpListColumns) api.GET("/boards/:id/snapshot", h.httpGetBoardSnapshot) + api.POST("/boards", h.httpCreateBoard) + api.PATCH("/boards/:id", h.httpUpdateBoard) + api.DELETE("/boards/:id", h.httpDeleteBoard) + api.POST("/boards/:id/columns", h.httpCreateColumn) + api.PUT("/columns/:id", h.httpUpdateColumn) + api.DELETE("/columns/:id", h.httpDeleteColumn) } func (h *BoardHandlers) registerWS(dispatcher *ws.Dispatcher) { @@ -50,6 +56,8 @@ func (h *BoardHandlers) registerWS(dispatcher *ws.Dispatcher) { dispatcher.RegisterFunc(ws.ActionColumnList, h.wsListColumns) dispatcher.RegisterFunc(ws.ActionColumnCreate, h.wsCreateColumn) dispatcher.RegisterFunc(ws.ActionColumnGet, h.wsGetColumn) + dispatcher.RegisterFunc(ws.ActionColumnUpdate, h.wsUpdateColumn) + dispatcher.RegisterFunc(ws.ActionColumnDelete, h.wsDeleteColumn) } // HTTP handlers @@ -87,6 +95,67 @@ func (h *BoardHandlers) httpGetBoard(c *gin.Context) { c.JSON(http.StatusOK, resp) } +type httpCreateBoardRequest struct { + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +func (h *BoardHandlers) httpCreateBoard(c *gin.Context) { + var body httpCreateBoardRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + if body.Name == "" || body.WorkspaceID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id and name are required"}) + return + } + resp, err := h.controller.CreateBoard(c.Request.Context(), dto.CreateBoardRequest{ + WorkspaceID: body.WorkspaceID, + Name: body.Name, + Description: body.Description, + }) + if err != nil { + h.logger.Error("failed to create board", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create board"}) + return + } + c.JSON(http.StatusCreated, resp) +} + +type httpUpdateBoardRequest struct { + Name *string `json:"name"` + Description *string `json:"description"` +} + +func (h *BoardHandlers) httpUpdateBoard(c *gin.Context) { + var body httpUpdateBoardRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + resp, err := h.controller.UpdateBoard(c.Request.Context(), dto.UpdateBoardRequest{ + ID: c.Param("id"), + Name: body.Name, + Description: body.Description, + }) + if err != nil { + handleNotFound(c, h.logger, err, "board not found") + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *BoardHandlers) httpDeleteBoard(c *gin.Context) { + resp, err := h.controller.DeleteBoard(c.Request.Context(), dto.DeleteBoardRequest{ID: c.Param("id")}) + if err != nil { + handleNotFound(c, h.logger, err, "board not found") + return + } + c.JSON(http.StatusOK, resp) +} + func (h *BoardHandlers) httpListColumns(c *gin.Context) { resp, err := h.controller.ListColumns(c.Request.Context(), dto.ListColumnsRequest{BoardID: c.Param("id")}) if err != nil { @@ -96,6 +165,80 @@ func (h *BoardHandlers) httpListColumns(c *gin.Context) { c.JSON(http.StatusOK, resp) } +type httpCreateColumnRequest struct { + Name string `json:"name"` + Position int `json:"position"` + State v1.TaskState `json:"state"` + Color string `json:"color"` +} + +func (h *BoardHandlers) httpCreateColumn(c *gin.Context) { + var body httpCreateColumnRequest + if err := c.ShouldBindJSON(&body); err != nil { + h.logger.Error("failed to bind create column request", zap.Error(err), zap.String("board_id", c.Param("id"))) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + if body.Name == "" { + h.logger.Error("create column missing name", zap.String("board_id", c.Param("id"))) + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + state := body.State + if state == "" { + state = v1.TaskStateTODO + } + resp, err := h.controller.CreateColumn(c.Request.Context(), dto.CreateColumnRequest{ + BoardID: c.Param("id"), + Name: body.Name, + Position: body.Position, + State: state, + Color: body.Color, + }) + if err != nil { + h.logger.Error("failed to create column", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create column"}) + return + } + c.JSON(http.StatusCreated, resp) +} + +type httpUpdateColumnRequest struct { + Name *string `json:"name"` + Position *int `json:"position"` + State *v1.TaskState `json:"state"` + Color *string `json:"color"` +} + +func (h *BoardHandlers) httpUpdateColumn(c *gin.Context) { + var body httpUpdateColumnRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + resp, err := h.controller.UpdateColumn(c.Request.Context(), dto.UpdateColumnRequest{ + ID: c.Param("id"), + Name: body.Name, + Position: body.Position, + State: body.State, + Color: body.Color, + }) + if err != nil { + handleNotFound(c, h.logger, err, "column not found") + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *BoardHandlers) httpDeleteColumn(c *gin.Context) { + if err := h.controller.DeleteColumn(c.Request.Context(), dto.GetColumnRequest{ID: c.Param("id")}); err != nil { + h.logger.Error("failed to delete column", zap.Error(err), zap.String("column_id", c.Param("id"))) + handleNotFound(c, h.logger, err, "column not found") + return + } + c.Status(http.StatusNoContent) +} + func (h *BoardHandlers) httpGetBoardSnapshot(c *gin.Context) { resp, err := h.controller.GetSnapshot(c.Request.Context(), dto.GetBoardSnapshotRequest{BoardID: c.Param("id")}) if err != nil { @@ -248,6 +391,7 @@ type wsCreateColumnRequest struct { Name string `json:"name"` Position int `json:"position"` State string `json:"state,omitempty"` + Color string `json:"color,omitempty"` } func (h *BoardHandlers) wsCreateColumn(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -272,6 +416,7 @@ func (h *BoardHandlers) wsCreateColumn(ctx context.Context, msg *ws.Message) (*w Name: req.Name, Position: req.Position, State: state, + Color: req.Color, }) if err != nil { h.logger.Error("failed to create column", zap.Error(err)) @@ -299,3 +444,52 @@ func (h *BoardHandlers) wsGetColumn(ctx context.Context, msg *ws.Message) (*ws.M } return ws.NewResponse(msg.ID, msg.Action, resp) } + +type wsUpdateColumnRequest struct { + ID string `json:"id"` + Name *string `json:"name,omitempty"` + Position *int `json:"position,omitempty"` + State *v1.TaskState `json:"state,omitempty"` + Color *string `json:"color,omitempty"` +} + +func (h *BoardHandlers) wsUpdateColumn(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsUpdateColumnRequest + 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.UpdateColumn(ctx, dto.UpdateColumnRequest{ + ID: req.ID, + Name: req.Name, + Position: req.Position, + State: req.State, + Color: req.Color, + }) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Column not found", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +type wsDeleteColumnRequest struct { + ID string `json:"id"` +} + +func (h *BoardHandlers) wsDeleteColumn(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsDeleteColumnRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + if err := h.controller.DeleteColumn(ctx, dto.GetColumnRequest{ID: req.ID}); err != nil { + h.logger.Error("failed to delete column (ws)", zap.Error(err), zap.String("column_id", req.ID)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Column not found", nil) + } + return ws.NewResponse(msg.ID, msg.Action, gin.H{"deleted": true}) +} diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 9fc63493b1..da4375fd02 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -53,6 +53,7 @@ type Column struct { Name string `json:"name"` Position int `json:"position"` State v1.TaskState `json:"state"` // Maps column to task state + Color string `json:"color"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/apps/backend/internal/task/repository/sqlite.go b/apps/backend/internal/task/repository/sqlite.go index dff727ce4a..0fea33d2c5 100644 --- a/apps/backend/internal/task/repository/sqlite.go +++ b/apps/backend/internal/task/repository/sqlite.go @@ -108,6 +108,7 @@ func (r *SQLiteRepository) initSchema() error { name TEXT NOT NULL, position INTEGER DEFAULT 0, state TEXT DEFAULT 'TODO', + color TEXT NOT NULL DEFAULT '', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE @@ -187,6 +188,9 @@ func (r *SQLiteRepository) initSchema() error { if err := r.ensureColumn("tasks", "workspace_id", "TEXT NOT NULL DEFAULT ''"); err != nil { return err } + if err := r.ensureColumn("columns", "color", "TEXT NOT NULL DEFAULT ''"); err != nil { + return err + } if err := r.ensureDefaultWorkspace(); err != nil { return err @@ -283,18 +287,19 @@ func (r *SQLiteRepository) ensureDefaultWorkspace() error { name string position int state string + color string } columns := []columnSeed{ - {name: "Todo", position: 0, state: string(v1.TaskStateTODO)}, - {name: "In Progress", position: 1, state: string(v1.TaskStateInProgress)}, - {name: "Review", position: 2, state: string(v1.TaskStateReview)}, - {name: "Done", position: 3, state: string(v1.TaskStateCompleted)}, + {name: "Todo", position: 0, state: string(v1.TaskStateTODO), color: "bg-neutral-400"}, + {name: "In Progress", position: 1, state: string(v1.TaskStateInProgress), color: "bg-blue-500"}, + {name: "Review", position: 2, state: string(v1.TaskStateReview), color: "bg-yellow-500"}, + {name: "Done", position: 3, state: string(v1.TaskStateCompleted), color: "bg-green-500"}, } for _, column := range columns { if _, err := r.db.ExecContext(ctx, ` - INSERT INTO columns (id, board_id, name, position, state, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - `, uuid.New().String(), boardID, column.name, column.position, column.state, now, now); err != nil { + INSERT INTO columns (id, board_id, name, position, state, color, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, uuid.New().String(), boardID, column.name, column.position, column.state, column.color, now, now); err != nil { return err } } @@ -802,9 +807,9 @@ func (r *SQLiteRepository) CreateColumn(ctx context.Context, column *models.Colu column.UpdatedAt = now _, err := r.db.ExecContext(ctx, ` - INSERT INTO columns (id, board_id, name, position, state, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - `, column.ID, column.BoardID, column.Name, column.Position, column.State, column.CreatedAt, column.UpdatedAt) + INSERT INTO columns (id, board_id, name, position, state, color, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, column.ID, column.BoardID, column.Name, column.Position, column.State, column.Color, column.CreatedAt, column.UpdatedAt) return err } @@ -814,9 +819,9 @@ func (r *SQLiteRepository) GetColumn(ctx context.Context, id string) (*models.Co column := &models.Column{} err := r.db.QueryRowContext(ctx, ` - SELECT id, board_id, name, position, state, created_at, updated_at + SELECT id, board_id, name, position, state, color, created_at, updated_at FROM columns WHERE id = ? - `, id).Scan(&column.ID, &column.BoardID, &column.Name, &column.Position, &column.State, &column.CreatedAt, &column.UpdatedAt) + `, id).Scan(&column.ID, &column.BoardID, &column.Name, &column.Position, &column.State, &column.Color, &column.CreatedAt, &column.UpdatedAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("column not found: %s", id) @@ -829,8 +834,8 @@ func (r *SQLiteRepository) UpdateColumn(ctx context.Context, column *models.Colu column.UpdatedAt = time.Now().UTC() result, err := r.db.ExecContext(ctx, ` - UPDATE columns SET name = ?, position = ?, state = ?, updated_at = ? WHERE id = ? - `, column.Name, column.Position, column.State, column.UpdatedAt, column.ID) + UPDATE columns SET name = ?, position = ?, state = ?, color = ?, updated_at = ? WHERE id = ? + `, column.Name, column.Position, column.State, column.Color, column.UpdatedAt, column.ID) if err != nil { return err } @@ -859,7 +864,7 @@ func (r *SQLiteRepository) DeleteColumn(ctx context.Context, id string) error { // ListColumns returns all columns for a board func (r *SQLiteRepository) ListColumns(ctx context.Context, boardID string) ([]*models.Column, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id, board_id, name, position, state, created_at, updated_at + SELECT id, board_id, name, position, state, color, created_at, updated_at FROM columns WHERE board_id = ? ORDER BY position `, boardID) if err != nil { @@ -870,7 +875,7 @@ func (r *SQLiteRepository) ListColumns(ctx context.Context, boardID string) ([]* var result []*models.Column for rows.Next() { column := &models.Column{} - err := rows.Scan(&column.ID, &column.BoardID, &column.Name, &column.Position, &column.State, &column.CreatedAt, &column.UpdatedAt) + err := rows.Scan(&column.ID, &column.BoardID, &column.Name, &column.Position, &column.State, &column.Color, &column.CreatedAt, &column.UpdatedAt) if err != nil { return nil, err } diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index b47da93f06..c8ebcfa9f9 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -92,6 +92,15 @@ type CreateColumnRequest struct { Name string `json:"name"` Position int `json:"position"` State v1.TaskState `json:"state"` + Color string `json:"color"` +} + +// UpdateColumnRequest contains the data for updating a column +type UpdateColumnRequest struct { + Name *string `json:"name,omitempty"` + Position *int `json:"position,omitempty"` + State *v1.TaskState `json:"state,omitempty"` + Color *string `json:"color,omitempty"` } // Task operations @@ -306,6 +315,7 @@ func (s *Service) CreateWorkspace(ctx context.Context, req *CreateWorkspaceReque return nil, err } + s.publishWorkspaceEvent(ctx, events.WorkspaceCreated, workspace) s.logger.Info("workspace created", zap.String("workspace_id", workspace.ID), zap.String("name", workspace.Name)) return workspace, nil } @@ -335,16 +345,22 @@ func (s *Service) UpdateWorkspace(ctx context.Context, id string, req *UpdateWor return nil, err } + s.publishWorkspaceEvent(ctx, events.WorkspaceUpdated, workspace) 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 { + workspace, err := s.repo.GetWorkspace(ctx, id) + if err != nil { + return err + } 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.publishWorkspaceEvent(ctx, events.WorkspaceDeleted, workspace) s.logger.Info("workspace deleted", zap.String("workspace_id", id)) return nil } @@ -370,6 +386,7 @@ func (s *Service) CreateBoard(ctx context.Context, req *CreateBoardRequest) (*mo return nil, err } + s.publishBoardEvent(ctx, events.BoardCreated, board) s.logger.Info("board created", zap.String("board_id", board.ID), zap.String("name", board.Name)) return board, nil } @@ -399,17 +416,23 @@ func (s *Service) UpdateBoard(ctx context.Context, id string, req *UpdateBoardRe return nil, err } + s.publishBoardEvent(ctx, events.BoardUpdated, board) s.logger.Info("board updated", zap.String("board_id", board.ID)) return board, nil } // DeleteBoard deletes a board func (s *Service) DeleteBoard(ctx context.Context, id string) error { + board, err := s.repo.GetBoard(ctx, id) + if err != nil { + return err + } if err := s.repo.DeleteBoard(ctx, id); err != nil { s.logger.Error("failed to delete board", zap.String("board_id", id), zap.Error(err)) return err } + s.publishBoardEvent(ctx, events.BoardDeleted, board) s.logger.Info("board deleted", zap.String("board_id", id)) return nil } @@ -423,12 +446,17 @@ func (s *Service) ListBoards(ctx context.Context, workspaceID string) ([]*models // CreateColumn creates a new column func (s *Service) CreateColumn(ctx context.Context, req *CreateColumnRequest) (*models.Column, error) { + color := req.Color + if color == "" { + color = "bg-neutral-400" + } column := &models.Column{ ID: uuid.New().String(), BoardID: req.BoardID, Name: req.Name, Position: req.Position, State: req.State, + Color: color, } if err := s.repo.CreateColumn(ctx, column); err != nil { @@ -436,6 +464,7 @@ func (s *Service) CreateColumn(ctx context.Context, req *CreateColumnRequest) (* return nil, err } + s.publishColumnEvent(ctx, events.ColumnCreated, column) s.logger.Info("column created", zap.String("column_id", column.ID), zap.String("board_id", column.BoardID), @@ -453,6 +482,55 @@ func (s *Service) ListColumns(ctx context.Context, boardID string) ([]*models.Co return s.repo.ListColumns(ctx, boardID) } +// UpdateColumn updates an existing column +func (s *Service) UpdateColumn(ctx context.Context, id string, req *UpdateColumnRequest) (*models.Column, error) { + column, err := s.repo.GetColumn(ctx, id) + if err != nil { + return nil, err + } + + if req.Name != nil { + column.Name = *req.Name + } + if req.Position != nil { + column.Position = *req.Position + } + if req.State != nil { + column.State = *req.State + } + if req.Color != nil { + column.Color = *req.Color + } + column.UpdatedAt = time.Now().UTC() + + if err := s.repo.UpdateColumn(ctx, column); err != nil { + s.logger.Error("failed to update column", zap.Error(err)) + return nil, err + } + + s.publishColumnEvent(ctx, events.ColumnUpdated, column) + s.logger.Info("column updated", + zap.String("column_id", column.ID), + zap.String("board_id", column.BoardID), + zap.String("name", column.Name)) + return column, nil +} + +// DeleteColumn deletes an existing column +func (s *Service) DeleteColumn(ctx context.Context, id string) error { + column, err := s.repo.GetColumn(ctx, id) + if err != nil { + return err + } + if err := s.repo.DeleteColumn(ctx, id); err != nil { + s.logger.Error("failed to delete column", zap.Error(err)) + return err + } + s.publishColumnEvent(ctx, events.ColumnDeleted, column) + s.logger.Info("column deleted", zap.String("column_id", id)) + return nil +} + // publishTaskEvent publishes task events to the event bus func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *models.Task, oldState *v1.TaskState) { if s.eventBus == nil { @@ -496,3 +574,74 @@ func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task * zap.Error(err)) } } + +func (s *Service) publishWorkspaceEvent(ctx context.Context, eventType string, workspace *models.Workspace) { + if s.eventBus == nil || workspace == nil { + return + } + + data := map[string]interface{}{ + "id": workspace.ID, + "name": workspace.Name, + "description": workspace.Description, + "owner_id": workspace.OwnerID, + "created_at": workspace.CreatedAt.Format(time.RFC3339), + "updated_at": workspace.UpdatedAt.Format(time.RFC3339), + } + + event := bus.NewEvent(eventType, "task-service", data) + if err := s.eventBus.Publish(ctx, eventType, event); err != nil { + s.logger.Error("failed to publish workspace event", + zap.String("event_type", eventType), + zap.String("workspace_id", workspace.ID), + zap.Error(err)) + } +} + +func (s *Service) publishBoardEvent(ctx context.Context, eventType string, board *models.Board) { + if s.eventBus == nil || board == nil { + return + } + + data := map[string]interface{}{ + "id": board.ID, + "workspace_id": board.WorkspaceID, + "name": board.Name, + "description": board.Description, + "created_at": board.CreatedAt.Format(time.RFC3339), + "updated_at": board.UpdatedAt.Format(time.RFC3339), + } + + event := bus.NewEvent(eventType, "task-service", data) + if err := s.eventBus.Publish(ctx, eventType, event); err != nil { + s.logger.Error("failed to publish board event", + zap.String("event_type", eventType), + zap.String("board_id", board.ID), + zap.Error(err)) + } +} + +func (s *Service) publishColumnEvent(ctx context.Context, eventType string, column *models.Column) { + if s.eventBus == nil || column == nil { + return + } + + data := map[string]interface{}{ + "id": column.ID, + "board_id": column.BoardID, + "name": column.Name, + "position": column.Position, + "state": string(column.State), + "color": column.Color, + "created_at": column.CreatedAt.Format(time.RFC3339), + "updated_at": column.UpdatedAt.Format(time.RFC3339), + } + + event := bus.NewEvent(eventType, "task-service", data) + if err := s.eventBus.Publish(ctx, eventType, event); err != nil { + s.logger.Error("failed to publish column event", + zap.String("event_type", eventType), + zap.String("column_id", column.ID), + zap.Error(err)) + } +} diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index c9296a2197..d7a117a34d 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -23,6 +23,8 @@ const ( ActionColumnList = "column.list" ActionColumnCreate = "column.create" ActionColumnGet = "column.get" + ActionColumnUpdate = "column.update" + ActionColumnDelete = "column.delete" // Task actions ActionTaskList = "task.list" @@ -69,6 +71,15 @@ const ( ActionACPHeartbeat = "acp.heartbeat" ActionTaskUpdated = "task.updated" ActionAgentUpdated = "agent.updated" + ActionWorkspaceCreated = "workspace.created" + ActionWorkspaceUpdated = "workspace.updated" + ActionWorkspaceDeleted = "workspace.deleted" + ActionBoardCreated = "board.created" + ActionBoardUpdated = "board.updated" + ActionBoardDeleted = "board.deleted" + ActionColumnCreated = "column.created" + ActionColumnUpdated = "column.updated" + ActionColumnDeleted = "column.deleted" ) // Error codes diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts new file mode 100644 index 0000000000..1ea579efee --- /dev/null +++ b/apps/web/app/actions/workspaces.ts @@ -0,0 +1,118 @@ +'use server'; + +import { getBackendConfig } from '@/lib/config'; +import type { + Board, + Column, + ListBoardsResponse, + ListColumnsResponse, + ListWorkspacesResponse, + Workspace, +} from '@/lib/types/http'; + +const { apiBaseUrl } = getBackendConfig(); + +async function fetchJson(url: string, options?: RequestInit): Promise { + const response = await fetch(url, { + ...options, + cache: 'no-store', + headers: { + 'Content-Type': 'application/json', + ...(options?.headers ?? {}), + }, + }); + if (!response.ok) { + throw new Error(`Request failed: ${response.status} ${response.statusText}`); + } + if (response.status === 204) { + return undefined as T; + } + const text = await response.text(); + if (!text) { + return undefined as T; + } + return JSON.parse(text) as T; +} + +export async function listWorkspacesAction(): Promise { + return fetchJson(`${apiBaseUrl}/api/v1/workspaces`); +} + +export async function getWorkspaceAction(id: string): Promise { + return fetchJson(`${apiBaseUrl}/api/v1/workspaces/${id}`); +} + +export async function createWorkspaceAction(payload: { name: string; description?: string }) { + return fetchJson(`${apiBaseUrl}/api/v1/workspaces`, { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export async function updateWorkspaceAction(id: string, payload: { name?: string; description?: string }) { + return fetchJson(`${apiBaseUrl}/api/v1/workspaces/${id}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }); +} + +export async function deleteWorkspaceAction(id: string) { + await fetchJson(`${apiBaseUrl}/api/v1/workspaces/${id}`, { method: 'DELETE' }); +} + +export async function listBoardsAction(workspaceId: string): Promise { + const url = new URL(`${apiBaseUrl}/api/v1/boards`); + url.searchParams.set('workspace_id', workspaceId); + return fetchJson(url.toString()); +} + +export async function createBoardAction(payload: { workspace_id: string; name: string; description?: string }) { + return fetchJson(`${apiBaseUrl}/api/v1/boards`, { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export async function updateBoardAction(id: string, payload: { name?: string; description?: string }) { + return fetchJson(`${apiBaseUrl}/api/v1/boards/${id}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }); +} + +export async function deleteBoardAction(id: string) { + await fetchJson(`${apiBaseUrl}/api/v1/boards/${id}`, { method: 'DELETE' }); +} + +export async function listColumnsAction(boardId: string): Promise { + return fetchJson(`${apiBaseUrl}/api/v1/boards/${boardId}/columns`); +} + +export async function createColumnAction(payload: { + board_id: string; + name: string; + position: number; + state: string; + color: string; +}) { + return fetchJson(`${apiBaseUrl}/api/v1/boards/${payload.board_id}/columns`, { + method: 'POST', + body: JSON.stringify({ + name: payload.name, + position: payload.position, + state: payload.state, + color: payload.color, + }), + }); +} + +export async function updateColumnAction(id: string, payload: Partial>) { + return fetchJson(`${apiBaseUrl}/api/v1/columns/${id}`, { + method: 'PUT', + body: JSON.stringify(payload), + }); +} + +export async function deleteColumnAction(id: string) { + await fetchJson(`${apiBaseUrl}/api/v1/columns/${id}`, { method: 'DELETE' }); +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 9df203092d..61cc59e059 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -3,6 +3,7 @@ import "./globals.css"; import { ThemeProvider } from "@/components/theme-provider"; import { StateProvider } from "@/components/state-provider"; import { WebSocketConnector } from "@/components/ws-connector"; +import { ToastProvider } from "@/components/toast-provider"; export const metadata: Metadata = { title: "Kandev - AI Kanban", @@ -19,8 +20,10 @@ export default function RootLayout({ - - {children} + + + {children} + diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index ae13d8dcdd..92b34b11a5 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -4,10 +4,23 @@ import { fetchBoardSnapshot, fetchBoards, fetchWorkspaces } from '@/lib/ssr/http import { snapshotToState } from '@/lib/ssr/mapper'; // Server Component: runs on the server for SSR and data hydration. -export default async function Page() { +type PageProps = { + searchParams?: Promise>; +}; + +export default async function Page({ searchParams }: PageProps) { try { + const resolvedParams = searchParams ? await searchParams : {}; + const workspaceParam = resolvedParams.workspaceId; + const boardParam = resolvedParams.boardId; + const workspaceId = Array.isArray(workspaceParam) ? workspaceParam[0] : workspaceParam; + const boardIdParam = Array.isArray(boardParam) ? boardParam[0] : boardParam; + const workspaces = await fetchWorkspaces(); - const activeWorkspaceId = workspaces.workspaces[0]?.id ?? null; + const activeWorkspaceId = + workspaces.workspaces.find((workspace) => workspace.id === workspaceId)?.id ?? + workspaces.workspaces[0]?.id ?? + null; let initialState = { workspaces: { items: workspaces.workspaces.map((workspace) => ({ @@ -28,7 +41,20 @@ export default async function Page() { } const boardList = await fetchBoards(activeWorkspaceId); - const boardId = boardList.boards[0]?.id; + const boardId = + boardList.boards.find((board) => board.id === boardIdParam)?.id ?? boardList.boards[0]?.id; + initialState = { + ...initialState, + boards: { + items: boardList.boards.map((board) => ({ + id: board.id, + workspaceId: board.workspace_id, + name: board.name, + })), + activeId: boardId ?? null, + }, + }; + if (!boardId) { return ( <> diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx index c65485c323..a78954d284 100644 --- a/apps/web/app/settings/layout.tsx +++ b/apps/web/app/settings/layout.tsx @@ -1,13 +1,6 @@ -'use client'; -import { IconArrowLeft } from '@tabler/icons-react'; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, -} from '@/components/ui/breadcrumb'; -import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; -import { SettingsAppSidebar } from '@/components/settings/settings-app-sidebar'; +import { SettingsLayoutClient } from '@/components/settings/settings-layout-client'; +import { StateHydrator } from '@/components/state-hydrator'; +import { fetchWorkspaces } from '@/lib/ssr/http'; export default function SettingsLayout({ children, @@ -15,27 +8,31 @@ export default function SettingsLayout({ children: React.ReactNode; }) { return ( - - - -
-
- - - - - - Home - - - - -
-
-
- {children} -
-
-
+ {children} + ); +} + +async function SettingsLayoutServer({ children }: { children: React.ReactNode }) { + let initialState = {}; + try { + const workspaces = await fetchWorkspaces(); + initialState = { + workspaces: { + items: workspaces.workspaces.map((workspace) => ({ + id: workspace.id, + name: workspace.name, + })), + activeId: workspaces.workspaces[0]?.id ?? null, + }, + }; + } catch { + initialState = {}; + } + + return ( + <> + + {children} + ); } diff --git a/apps/web/app/settings/workspace/[id]/page.tsx b/apps/web/app/settings/workspace/[id]/page.tsx index 1bf3432b89..87fd8cdef4 100644 --- a/apps/web/app/settings/workspace/[id]/page.tsx +++ b/apps/web/app/settings/workspace/[id]/page.tsx @@ -1,379 +1,24 @@ -'use client'; +import { getWorkspaceAction, listBoardsAction, listColumnsAction } from '@/app/actions/workspaces'; +import { WorkspaceEditClient } from '@/app/settings/workspace/workspace-edit-client'; -import { use, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { IconGitBranch, IconFolder, IconPlus, IconTrash } from '@tabler/icons-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Textarea } from '@/components/ui/textarea'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; -import { Separator } from '@/components/ui/separator'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { SettingsSection } from '@/components/settings/settings-section'; -import { RepositoryCard } from '@/components/settings/repository-card'; -import { ContextCard } from '@/components/settings/context-card'; -import { SETTINGS_DATA } from '@/lib/settings/dummy-data'; -import type { Workspace, Repository, Context } from '@/lib/settings/types'; +export default async function WorkspaceEditPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + let workspace = null; + let boardsWithColumns: Array>['boards'][number] & { columns: Awaited>['columns'] }> = []; -export default function WorkspaceEditPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = use(params); - const router = useRouter(); - const [workspace, setWorkspace] = useState( - SETTINGS_DATA.workspaces.find((w) => w.id === id) - ); - const [isAddingRepo, setIsAddingRepo] = useState(false); - const [isAddingContext, setIsAddingContext] = useState(false); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [deleteConfirmText, setDeleteConfirmText] = useState(''); - - if (!workspace) { - return ( -
- - -

Workspace not found

- -
-
-
+ try { + workspace = await getWorkspaceAction(id); + const boards = await listBoardsAction(id); + boardsWithColumns = await Promise.all( + boards.boards.map(async (board) => { + const columns = await listColumnsAction(board.id); + return { ...board, columns: columns.columns }; + }) ); + } catch { + workspace = null; + boardsWithColumns = []; } - const handleUpdateWorkspaceName = (name: string) => { - setWorkspace({ ...workspace, name }); - }; - - const handleAddRepository = (e: React.FormEvent) => { - e.preventDefault(); - const formData = new FormData(e.currentTarget); - const newRepo: Repository = { - id: crypto.randomUUID(), - name: formData.get('name') as string, - path: formData.get('path') as string, - setupScript: formData.get('setupScript') as string, - cleanupScript: formData.get('cleanupScript') as string, - customScripts: [], - }; - setWorkspace({ - ...workspace, - repositories: [...workspace.repositories, newRepo], - }); - setIsAddingRepo(false); - }; - - const handleUpdateRepository = (repo: Repository) => { - setWorkspace({ - ...workspace, - repositories: workspace.repositories.map((r) => (r.id === repo.id ? repo : r)), - }); - }; - - const handleDeleteRepository = (id: string) => { - setWorkspace({ - ...workspace, - repositories: workspace.repositories.filter((r) => r.id !== id), - }); - }; - - const handleAddContext = (e: React.FormEvent) => { - e.preventDefault(); - const formData = new FormData(e.currentTarget); - const newContext: Context = { - id: crypto.randomUUID(), - name: formData.get('name') as string, - columns: [ - { id: 'todo', title: 'To Do', color: 'bg-neutral-400' }, - { id: 'in-progress', title: 'In Progress', color: 'bg-blue-500' }, - { id: 'done', title: 'Done', color: 'bg-green-500' }, - ], - }; - setWorkspace({ - ...workspace, - contexts: [...workspace.contexts, newContext], - }); - setIsAddingContext(false); - }; - - const handleUpdateContext = (context: Context) => { - setWorkspace({ - ...workspace, - contexts: workspace.contexts.map((c) => (c.id === context.id ? context : c)), - }); - }; - - const handleDeleteContext = (id: string) => { - setWorkspace({ - ...workspace, - contexts: workspace.contexts.filter((c) => c.id !== id), - }); - }; - - const handleFolderPick = (callback: (path: string) => void) => { - const input = document.createElement('input'); - input.type = 'file'; - input.webkitdirectory = true; - input.onchange = (event: Event) => { - const target = event.target as HTMLInputElement | null; - const files = target?.files; - if (files && files[0]) { - const fullPath = files[0].webkitRelativePath; - const folderName = fullPath.split('/')[0]; - callback(`/${folderName}`); - } - }; - input.click(); - }; - - const handleDeleteWorkspace = () => { - if (deleteConfirmText === 'delete') { - // In real app, this would delete from database - router.push('/settings/general'); - setDeleteDialogOpen(false); - } - }; - - return ( -
-
-

{workspace.name}

-

- Manage repositories and contexts for this workspace -

-
- - - - - - Workspace Name - - - handleUpdateWorkspaceName(e.target.value)} - /> - - - - - - } - title="Repositories" - description="Repositories in this workspace" - action={ - - } - > -
- {isAddingRepo && ( - - -
-
- - -
-
- -
- - -
-
-
- -