From b20e64eab7d120a5e0afe762368a1f10590a6494 Mon Sep 17 00:00:00 2001 From: cavidelizade Date: Mon, 13 Jul 2026 12:31:43 +0400 Subject: [PATCH 1/2] feat(favorites): server-persist cycle/module favorites with folders and ordering Cycle and module favorites lived only in localStorage (per-device, no folders, no ordering). They are now persisted server-side in a favorites tree that can be grouped into folders and reordered. Backend: extend user_favorites with is_folder + sort_order (parent_id already existed); FavoriteService + endpoints to list the tree, favorite a cycle/module, create folders, and move/rename/reorder/delete. Deleting a folder keeps the favorites inside it (they move to the top level). Frontend: a shared workspace-favorites hook + a sidebar favorites tree with create-folder, drag-to-reorder, drag-into/out-of folders, and remove; the cycle/module favorite toggles now write to the server. Removes the two localStorage favorite hooks. Closes #205 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/internal/handler/favorite.go | 1 + apps/api/internal/handler/favorite_tree.go | 164 ++++++++++++++ .../internal/handler/favorite_tree_test.go | 90 ++++++++ apps/api/internal/model/user_favorite.go | 7 +- apps/api/internal/router/router.go | 7 +- apps/api/internal/service/favorite.go | 170 ++++++++++++++ apps/api/internal/store/user_favorite.go | 94 ++++++++ ...000011_favorites_folders_ordering.down.sql | 2 + .../000011_favorites_folders_ordering.up.sql | 3 + apps/web/src/api/types.ts | 15 ++ apps/web/src/components/layout/Sidebar.tsx | 214 +----------------- .../layout/WorkspaceFavoritesTree.tsx | 195 ++++++++++++++++ apps/web/src/contexts/FavoritesContext.tsx | 17 +- apps/web/src/hooks/useCycleFavorites.ts | 66 ------ apps/web/src/hooks/useModuleFavorites.ts | 66 ------ apps/web/src/hooks/useWorkspaceFavorites.ts | 103 +++++++++ apps/web/src/pages/CyclesPage.tsx | 18 +- apps/web/src/pages/ModulesPage.tsx | 21 +- .../src/services/workspaceFavoriteService.ts | 53 +++++ 19 files changed, 952 insertions(+), 354 deletions(-) create mode 100644 apps/api/internal/handler/favorite_tree.go create mode 100644 apps/api/internal/handler/favorite_tree_test.go create mode 100644 apps/api/internal/service/favorite.go create mode 100644 apps/api/migrations/000011_favorites_folders_ordering.down.sql create mode 100644 apps/api/migrations/000011_favorites_folders_ordering.up.sql create mode 100644 apps/web/src/components/layout/WorkspaceFavoritesTree.tsx delete mode 100644 apps/web/src/hooks/useCycleFavorites.ts delete mode 100644 apps/web/src/hooks/useModuleFavorites.ts create mode 100644 apps/web/src/hooks/useWorkspaceFavorites.ts create mode 100644 apps/web/src/services/workspaceFavoriteService.ts diff --git a/apps/api/internal/handler/favorite.go b/apps/api/internal/handler/favorite.go index 3164b3fb..f815955c 100644 --- a/apps/api/internal/handler/favorite.go +++ b/apps/api/internal/handler/favorite.go @@ -13,6 +13,7 @@ import ( type FavoriteHandler struct { Project *service.ProjectService Favorites *store.UserFavoriteStore + Fav *service.FavoriteService } // ListFavoriteProjects returns the list of favorited project IDs for the current user. diff --git a/apps/api/internal/handler/favorite_tree.go b/apps/api/internal/handler/favorite_tree.go new file mode 100644 index 00000000..d2d9408e --- /dev/null +++ b/apps/api/internal/handler/favorite_tree.go @@ -0,0 +1,164 @@ +package handler + +import ( + "net/http" + + "github.com/Devlaner/devlane/api/internal/middleware" + "github.com/Devlaner/devlane/api/internal/service" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// ListFavorites returns the user's full favorites tree (entities + folders) for +// a workspace. +// GET /api/workspaces/:slug/favorites/ +func (h *FavoriteHandler) ListFavorites(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + if h.Fav == nil { + c.JSON(http.StatusOK, []any{}) + return + } + list, err := h.Fav.List(c.Request.Context(), c.Param("slug"), user.ID) + if err != nil { + h.favError(c, err) + return + } + c.JSON(http.StatusOK, list) +} + +// CreateFavorite favorites a cycle/module, or creates a folder when is_folder is +// true. +// POST /api/workspaces/:slug/favorites/ +func (h *FavoriteHandler) CreateFavorite(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + if h.Fav == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Favorites not available"}) + return + } + var body struct { + IsFolder bool `json:"is_folder"` + Name string `json:"name"` + EntityType string `json:"entity_type"` + EntityID string `json:"entity_id"` + ProjectID string `json:"project_id"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) + return + } + ctx := c.Request.Context() + slug := c.Param("slug") + if body.IsFolder { + fav, err := h.Fav.CreateFolder(ctx, slug, user.ID, body.Name) + if err != nil { + h.favError(c, err) + return + } + c.JSON(http.StatusCreated, fav) + return + } + entityID, err := uuid.Parse(body.EntityID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid entity_id"}) + return + } + projectID, err := uuid.Parse(body.ProjectID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project_id"}) + return + } + fav, err := h.Fav.AddEntity(ctx, slug, user.ID, body.EntityType, entityID, projectID, body.Name) + if err != nil { + h.favError(c, err) + return + } + c.JSON(http.StatusCreated, fav) +} + +// UpdateFavorite renames, moves, and/or reorders a favorite. +// PATCH /api/workspaces/:slug/favorites/:favId/ +func (h *FavoriteHandler) UpdateFavorite(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + if h.Fav == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Favorites not available"}) + return + } + id, err := uuid.Parse(c.Param("favId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid favorite ID"}) + return + } + var body struct { + Name *string `json:"name"` + ParentID *string `json:"parent_id"` + SortOrder *float64 `json:"sort_order"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) + return + } + // parent_id present (even as null/"") means "move"; a non-empty string must parse. + var parentID *uuid.UUID + parentSet := body.ParentID != nil + if parentSet && *body.ParentID != "" { + pid, perr := uuid.Parse(*body.ParentID) + if perr != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid parent_id"}) + return + } + parentID = &pid + } + fav, err := h.Fav.Update(c.Request.Context(), c.Param("slug"), user.ID, id, body.Name, parentSet, parentID, body.SortOrder) + if err != nil { + h.favError(c, err) + return + } + c.JSON(http.StatusOK, fav) +} + +// DeleteFavorite removes a favorite or folder. +// DELETE /api/workspaces/:slug/favorites/:favId/ +func (h *FavoriteHandler) DeleteFavorite(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + if h.Fav == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Favorites not available"}) + return + } + id, err := uuid.Parse(c.Param("favId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid favorite ID"}) + return + } + if err := h.Fav.Delete(c.Request.Context(), c.Param("slug"), user.ID, id); err != nil { + h.favError(c, err) + return + } + c.Status(http.StatusNoContent) +} + +func (h *FavoriteHandler) favError(c *gin.Context, err error) { + switch err { + case service.ErrFavoriteWorkspace, service.ErrFavoriteForbidden, service.ErrFavoriteNotFound: + c.JSON(http.StatusNotFound, gin.H{"error": "Not found"}) + case service.ErrFavoriteBadEntity, service.ErrFavoriteBadParent: + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Favorites request failed"}) + } +} diff --git a/apps/api/internal/handler/favorite_tree_test.go b/apps/api/internal/handler/favorite_tree_test.go new file mode 100644 index 00000000..403becad --- /dev/null +++ b/apps/api/internal/handler/favorite_tree_test.go @@ -0,0 +1,90 @@ +package handler_test + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/require" +) + +type favJSON struct { + ID string `json:"id"` + Name string `json:"name"` + IsFolder bool `json:"is_folder"` + ParentID *string `json:"parent_id"` + SortOrder float64 `json:"sort_order"` + EntityID string `json:"entity_identifier"` + Type string `json:"entity_type"` +} + +func favList(t *testing.T, body string) []favJSON { + t.Helper() + var out []favJSON + require.NoError(t, json.Unmarshal([]byte(body), &out)) + return out +} + +// The favorites tree: favorite a cycle, put it in a folder, reorder it, and +// deleting the folder keeps the favorite (moved to the top level). Covers #205. +func TestFavorites_CycleFolderOrdering(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + base := "/api/workspaces/" + w.Workspace.Slug + "/favorites/" + cycle := testutil.CreateCycle(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + + // Favorite the cycle. + rr := ts.POST(base, map[string]any{ + "entity_type": "cycle", + "entity_id": cycle.ID.String(), + "project_id": w.Project.ID.String(), + "name": "Sprint 1", + }, w.Session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + var favResp favJSON + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &favResp)) + require.Equal(t, cycle.ID.String(), favResp.EntityID) + + // Create a folder. + rr = ts.POST(base, map[string]any{"is_folder": true, "name": "Sprints"}, w.Session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + var folder favJSON + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &folder)) + require.True(t, folder.IsFolder) + + // Move the cycle favorite into the folder and reorder it. + rr = ts.PATCH(base+favResp.ID+"/", map[string]any{"parent_id": folder.ID, "sort_order": 10}, w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + + list := favList(t, ts.GET(base, w.Session).Body.String()) + require.Len(t, list, 2) + for _, f := range list { + if f.ID == favResp.ID { + require.NotNil(t, f.ParentID) + require.Equal(t, folder.ID, *f.ParentID) + require.EqualValues(t, 10, f.SortOrder) + } + } + + // Deleting the folder keeps the cycle favorite (parent cleared). + require.Equal(t, http.StatusNoContent, ts.DELETE(base+folder.ID+"/", w.Session).Code) + list = favList(t, ts.GET(base, w.Session).Body.String()) + require.Len(t, list, 1) + require.Equal(t, favResp.ID, list[0].ID) + require.Nil(t, list[0].ParentID) + + // Unfavorite by deleting the favorite row. + require.Equal(t, http.StatusNoContent, ts.DELETE(base+favResp.ID+"/", w.Session).Code) + require.Len(t, favList(t, ts.GET(base, w.Session).Body.String()), 0) +} + +// A non-member can't read a workspace's favorites. +func TestFavorites_NonMemberForbidden(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + stranger := testutil.CreateUser(t, ts.DB) + strangerSession := testutil.LoginAs(t, ts.DB, stranger) + rr := ts.GET("/api/workspaces/"+w.Workspace.Slug+"/favorites/", strangerSession) + require.Equal(t, http.StatusNotFound, rr.Code) +} diff --git a/apps/api/internal/model/user_favorite.go b/apps/api/internal/model/user_favorite.go index 47a2867c..bb0d585b 100644 --- a/apps/api/internal/model/user_favorite.go +++ b/apps/api/internal/model/user_favorite.go @@ -7,13 +7,18 @@ import ( "gorm.io/gorm" ) -// UserFavorite matches user_favorites. +// UserFavorite matches user_favorites. A row is either a favorited entity +// (is_folder = false) or a folder (is_folder = true) that other favorites nest +// under via parent_id. sort_order orders siblings. type UserFavorite struct { ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` Name string `gorm:"type:varchar(255);not null" json:"name"` Type string `gorm:"type:varchar(50);not null" json:"type"` EntityType string `gorm:"type:varchar(50);not null;uniqueIndex:idx_user_fav_entity" json:"entity_type"` EntityIdentifier uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_user_fav_entity" json:"entity_identifier"` + IsFolder bool `gorm:"column:is_folder;not null;default:false" json:"is_folder"` + ParentID *uuid.UUID `gorm:"column:parent_id;type:uuid" json:"parent_id,omitempty"` + SortOrder float64 `gorm:"column:sort_order;not null;default:65535" json:"sort_order"` WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` ProjectID *uuid.UUID `gorm:"type:uuid" json:"project_id,omitempty"` UserID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_user_fav_entity" json:"user_id"` diff --git a/apps/api/internal/router/router.go b/apps/api/internal/router/router.go index 2754fe89..644a2d37 100644 --- a/apps/api/internal/router/router.go +++ b/apps/api/internal/router/router.go @@ -239,7 +239,8 @@ func New(cfg Config) *gin.Engine { } projectHandler := &handler.ProjectHandler{Project: projectSvc, State: stateSvc} notifPrefHandler := &handler.NotificationPreferenceHandler{Prefs: userNotifPrefStore, Ws: workspaceStore, Projects: projectSvc} - favoriteHandler := &handler.FavoriteHandler{Project: projectSvc, Favorites: userFavoriteStore} + favoriteSvc := service.NewFavoriteService(userFavoriteStore, workspaceStore, projectSvc) + favoriteHandler := &handler.FavoriteHandler{Project: projectSvc, Favorites: userFavoriteStore, Fav: favoriteSvc} stateHandler := &handler.StateHandler{State: stateSvc} labelHandler := &handler.LabelHandler{Label: labelSvc} searchHandler := &handler.SearchHandler{Svc: searchSvc} @@ -283,6 +284,10 @@ func New(cfg Config) *gin.Engine { api.POST("/users/me/tokens/", authHandler.CreateToken) api.DELETE("/users/me/tokens/:id/", authHandler.RevokeToken) api.GET("/users/me/favorite-projects/", favoriteHandler.ListFavoriteProjects) + api.GET("/workspaces/:slug/favorites/", favoriteHandler.ListFavorites) + api.POST("/workspaces/:slug/favorites/", favoriteHandler.CreateFavorite) + api.PATCH("/workspaces/:slug/favorites/:favId/", favoriteHandler.UpdateFavorite) + api.DELETE("/workspaces/:slug/favorites/:favId/", favoriteHandler.DeleteFavorite) api.GET("/instance/settings/", instanceSettingsHandler.GetSettings) api.PATCH("/instance/settings/:key", instanceSettingsHandler.UpdateSetting) api.GET("/instance/unsplash/search", instanceSettingsHandler.UnsplashSearch) diff --git a/apps/api/internal/service/favorite.go b/apps/api/internal/service/favorite.go new file mode 100644 index 00000000..fddec3a7 --- /dev/null +++ b/apps/api/internal/service/favorite.go @@ -0,0 +1,170 @@ +package service + +import ( + "context" + "errors" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/store" + "github.com/google/uuid" +) + +var ( + ErrFavoriteNotFound = errors.New("favorite not found") + ErrFavoriteBadEntity = errors.New("unsupported favorite entity type") + ErrFavoriteBadParent = errors.New("parent must be one of your folders") + ErrFavoriteWorkspace = errors.New("workspace not found") + ErrFavoriteForbidden = errors.New("no access to this workspace") + favoriteEntityTypesSet = map[string]bool{ + store.FavoriteEntityTypeCycle: true, + store.FavoriteEntityTypeModule: true, + } +) + +// FavoriteService handles the user's favorites tree: favoriting cycles/modules, +// grouping favorites into folders, and ordering them. +type FavoriteService struct { + favs *store.UserFavoriteStore + ws *store.WorkspaceStore + projects *ProjectService +} + +func NewFavoriteService(favs *store.UserFavoriteStore, ws *store.WorkspaceStore, projects *ProjectService) *FavoriteService { + return &FavoriteService{favs: favs, ws: ws, projects: projects} +} + +func (s *FavoriteService) workspace(ctx context.Context, slug string, userID uuid.UUID) (*model.Workspace, error) { + wrk, err := s.ws.GetBySlug(ctx, slug) + if err != nil { + return nil, ErrFavoriteWorkspace + } + ok, _ := s.ws.IsMember(ctx, wrk.ID, userID) + if !ok { + return nil, ErrFavoriteForbidden + } + return wrk, nil +} + +// List returns all of the user's favorites (entities and folders) in a workspace. +func (s *FavoriteService) List(ctx context.Context, slug string, userID uuid.UUID) ([]model.UserFavorite, error) { + wrk, err := s.workspace(ctx, slug, userID) + if err != nil { + return nil, err + } + return s.favs.ListByUserAndWorkspace(ctx, userID, wrk.ID) +} + +// AddEntity favorites a cycle or module. The entity's project must be +// accessible to the caller. +func (s *FavoriteService) AddEntity(ctx context.Context, slug string, userID uuid.UUID, entityType string, entityID, projectID uuid.UUID, name string) (*model.UserFavorite, error) { + if !favoriteEntityTypesSet[entityType] { + return nil, ErrFavoriteBadEntity + } + wrk, err := s.workspace(ctx, slug, userID) + if err != nil { + return nil, err + } + // Confirm the caller can see the project the entity lives in. + if _, err := s.projects.GetByID(ctx, slug, projectID, userID); err != nil { + return nil, ErrFavoriteForbidden + } + pid := projectID + fav := &model.UserFavorite{ + Name: name, + Type: entityType, + EntityType: entityType, + EntityIdentifier: entityID, + WorkspaceID: wrk.ID, + ProjectID: &pid, + UserID: userID, + CreatedByID: &userID, + UpdatedByID: &userID, + } + return s.favs.AddEntity(ctx, fav) +} + +// RemoveEntity unfavorites a cycle or module. +func (s *FavoriteService) RemoveEntity(ctx context.Context, slug string, userID uuid.UUID, entityType string, entityID uuid.UUID) error { + if !favoriteEntityTypesSet[entityType] { + return ErrFavoriteBadEntity + } + if _, err := s.workspace(ctx, slug, userID); err != nil { + return err + } + return s.favs.RemoveEntity(ctx, userID, entityType, entityID) +} + +// CreateFolder makes a new folder to group favorites under. +func (s *FavoriteService) CreateFolder(ctx context.Context, slug string, userID uuid.UUID, name string) (*model.UserFavorite, error) { + wrk, err := s.workspace(ctx, slug, userID) + if err != nil { + return nil, err + } + f := &model.UserFavorite{ + Name: name, + WorkspaceID: wrk.ID, + UserID: userID, + CreatedByID: &userID, + UpdatedByID: &userID, + } + if err := s.favs.CreateFolder(ctx, f); err != nil { + return nil, err + } + return f, nil +} + +// Update renames, moves (into/out of a folder), and/or reorders a favorite. +func (s *FavoriteService) Update(ctx context.Context, slug string, userID, id uuid.UUID, name *string, parentSet bool, parentID *uuid.UUID, sortOrder *float64) (*model.UserFavorite, error) { + if _, err := s.workspace(ctx, slug, userID); err != nil { + return nil, err + } + fav, err := s.favs.GetOwnedByID(ctx, userID, id) + if err != nil { + return nil, err + } + if fav == nil { + return nil, ErrFavoriteNotFound + } + fields := map[string]any{} + if name != nil { + fields["name"] = *name + } + if parentSet { + if parentID != nil { + // A parent must be one of the user's own folders, and not itself. + if *parentID == id { + return nil, ErrFavoriteBadParent + } + parent, perr := s.favs.GetOwnedByID(ctx, userID, *parentID) + if perr != nil { + return nil, perr + } + if parent == nil || !parent.IsFolder { + return nil, ErrFavoriteBadParent + } + } + fields["parent_id"] = parentID + } + if sortOrder != nil { + fields["sort_order"] = *sortOrder + } + if err := s.favs.UpdateOwned(ctx, userID, id, fields); err != nil { + return nil, err + } + return s.favs.GetOwnedByID(ctx, userID, id) +} + +// Delete removes a favorite or folder (a folder's children move to the top level). +func (s *FavoriteService) Delete(ctx context.Context, slug string, userID, id uuid.UUID) error { + if _, err := s.workspace(ctx, slug, userID); err != nil { + return err + } + fav, err := s.favs.GetOwnedByID(ctx, userID, id) + if err != nil { + return err + } + if fav == nil { + return ErrFavoriteNotFound + } + return s.favs.DeleteOwned(ctx, userID, id) +} diff --git a/apps/api/internal/store/user_favorite.go b/apps/api/internal/store/user_favorite.go index 194f6d82..4fedfd03 100644 --- a/apps/api/internal/store/user_favorite.go +++ b/apps/api/internal/store/user_favorite.go @@ -17,6 +17,100 @@ const FavoriteEntityTypeIssueView = "issue_view" // FavoriteEntityTypePage is stored in user_favorites.entity_type for project pages. const FavoriteEntityTypePage = "page" +// Entity types for cycle/module favorites and for folders that group favorites. +const ( + FavoriteEntityTypeCycle = "cycle" + FavoriteEntityTypeModule = "module" + FavoriteEntityTypeFolder = "folder" +) + +// ListByUserAndWorkspace returns all of a user's favorites (entities and +// folders) in a workspace, ordered for display. +func (s *UserFavoriteStore) ListByUserAndWorkspace(ctx context.Context, userID, workspaceID uuid.UUID) ([]model.UserFavorite, error) { + var list []model.UserFavorite + err := s.db.WithContext(ctx). + Where("user_id = ? AND workspace_id = ?", userID, workspaceID). + Order("sort_order ASC, created_at ASC"). + Find(&list).Error + return list, err +} + +// GetOwnedByID returns the user's favorite by id, or nil when it doesn't exist +// or belongs to someone else. +func (s *UserFavoriteStore) GetOwnedByID(ctx context.Context, userID, id uuid.UUID) (*model.UserFavorite, error) { + var f model.UserFavorite + err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, userID).First(&f).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return &f, nil +} + +// AddEntity favorites an entity (cycle/module/…), returning the existing row if +// it's already favorited so the call is idempotent. +func (s *UserFavoriteStore) AddEntity(ctx context.Context, f *model.UserFavorite) (*model.UserFavorite, error) { + var existing model.UserFavorite + err := s.db.WithContext(ctx). + Where("user_id = ? AND entity_type = ? AND entity_identifier = ?", f.UserID, f.EntityType, f.EntityIdentifier). + First(&existing).Error + if err == nil { + return &existing, nil + } + if err != gorm.ErrRecordNotFound { + return nil, err + } + if err := s.db.WithContext(ctx).Create(f).Error; err != nil { + return nil, err + } + return f, nil +} + +// CreateFolder inserts a folder favorite. Folders carry a synthetic +// entity_identifier so they satisfy the (user, entity_type, entity_identifier) +// unique index. +func (s *UserFavoriteStore) CreateFolder(ctx context.Context, f *model.UserFavorite) error { + f.IsFolder = true + f.EntityType = FavoriteEntityTypeFolder + f.Type = FavoriteEntityTypeFolder + f.EntityIdentifier = uuid.New() + return s.db.WithContext(ctx).Create(f).Error +} + +// RemoveEntity unfavorites an entity for the user. +func (s *UserFavoriteStore) RemoveEntity(ctx context.Context, userID uuid.UUID, entityType string, entityID uuid.UUID) error { + return s.db.WithContext(ctx). + Where("user_id = ? AND entity_type = ? AND entity_identifier = ?", userID, entityType, entityID). + Delete(&model.UserFavorite{}).Error +} + +// UpdateOwned writes the given columns (name / parent_id / sort_order) for the +// user's favorite. +func (s *UserFavoriteStore) UpdateOwned(ctx context.Context, userID, id uuid.UUID, fields map[string]any) error { + if len(fields) == 0 { + return nil + } + return s.db.WithContext(ctx). + Model(&model.UserFavorite{}). + Where("id = ? AND user_id = ?", id, userID). + Updates(fields).Error +} + +// DeleteOwned removes the user's favorite. When it's a folder, its children are +// first moved to the top level so the entities inside aren't unfavorited. +func (s *UserFavoriteStore) DeleteOwned(ctx context.Context, userID, id uuid.UUID) error { + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.UserFavorite{}). + Where("user_id = ? AND parent_id = ?", userID, id). + Update("parent_id", nil).Error; err != nil { + return err + } + return tx.Where("id = ? AND user_id = ?", id, userID).Delete(&model.UserFavorite{}).Error + }) +} + // UserFavoriteStore handles user_favorites persistence. type UserFavoriteStore struct{ db *gorm.DB } diff --git a/apps/api/migrations/000011_favorites_folders_ordering.down.sql b/apps/api/migrations/000011_favorites_folders_ordering.down.sql new file mode 100644 index 00000000..960a2702 --- /dev/null +++ b/apps/api/migrations/000011_favorites_folders_ordering.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_favorites DROP COLUMN IF EXISTS is_folder; +ALTER TABLE user_favorites DROP COLUMN IF EXISTS sort_order; diff --git a/apps/api/migrations/000011_favorites_folders_ordering.up.sql b/apps/api/migrations/000011_favorites_folders_ordering.up.sql new file mode 100644 index 00000000..54ce36c8 --- /dev/null +++ b/apps/api/migrations/000011_favorites_folders_ordering.up.sql @@ -0,0 +1,3 @@ +-- Extend user_favorites for folders and ordering. parent_id already exists. +ALTER TABLE user_favorites ADD COLUMN IF NOT EXISTS is_folder BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE user_favorites ADD COLUMN IF NOT EXISTS sort_order DOUBLE PRECISION NOT NULL DEFAULT 65535; diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 2bac62a6..c8bad2b2 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -339,6 +339,21 @@ export const IntakeStatus = { } as const; /** An intake ("inbox") item: the triage row plus its work-item summary. */ +/** A user favorite: a favorited entity (cycle/module) or a folder grouping them. */ +export interface FavoriteApiResponse { + id: string; + name: string; + entity_type: string; + entity_identifier: string; + is_folder: boolean; + parent_id?: string | null; + sort_order: number; + workspace_id: string; + project_id?: string | null; + created_at?: string; + updated_at?: string; +} + export interface IntakeItemApiResponse { id: string; intake_id: string; diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx index 8be52abf..af36c789 100644 --- a/apps/web/src/components/layout/Sidebar.tsx +++ b/apps/web/src/components/layout/Sidebar.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { Link, NavLink, useLocation, useParams } from 'react-router-dom'; import { workspaceService } from '../../services/workspaceService'; @@ -7,8 +7,6 @@ import { favoriteService } from '../../services/favoriteService'; import type { WorkspaceApiResponse, ProjectApiResponse, - ModuleApiResponse, - CycleApiResponse, IssueViewApiResponse, } from '../../api/types'; import { CreateWorkItemModal } from '../CreateWorkItemModal'; @@ -18,15 +16,11 @@ import { ProjectIconDisplay } from '../ProjectIconModal'; import { useAuth } from '../../contexts/AuthContext'; import { useFavorites } from '../../contexts/FavoritesContext'; import { cn, getImageUrl } from '../../lib/utils'; -import { moduleService } from '../../services/moduleService'; -import { cycleService } from '../../services/cycleService'; import { viewService } from '../../services/viewService'; -import { slugify } from '../../lib/slug'; -import { cyclePathSegment } from '../../lib/cycle'; import { OPEN_COMMAND_PALETTE } from '../../lib/commandPaletteEvents'; import { ISSUE_VIEW_FAVORITES_CHANGED_EVENT } from '../../lib/issueViewFavoritesEvents'; -import { CYCLE_FAVORITES_CHANGED_EVENT } from '../../hooks/useCycleFavorites'; import { IntakeNavBadge } from './IntakeNavBadge'; +import { WorkspaceFavoritesTree } from './WorkspaceFavoritesTree'; const SIDEBAR_WIDTH = 256; const SIDEBAR_WIDTH_COLLAPSED = 0; @@ -469,14 +463,6 @@ export function Sidebar() { const [workspaces, setWorkspaces] = useState([]); const [projects, setProjects] = useState([]); const { favoriteProjectIds, setFavoriteProjectIds } = useFavorites(); - const [favoriteModules, setFavoriteModules] = useState< - Array<{ projectId: string; module: ModuleApiResponse }> - >([]); - const [moduleFavoritesNonce, setModuleFavoritesNonce] = useState(0); - const [favoriteCycles, setFavoriteCycles] = useState< - Array<{ projectId: string; cycle: CycleApiResponse }> - >([]); - const [cycleFavoritesNonce, setCycleFavoritesNonce] = useState(0); const [favoriteIssueViews, setFavoriteIssueViews] = useState([]); const [issueViewFavoritesNonce, setIssueViewFavoritesNonce] = useState(0); const workspaceTriggerRef = useRef(null); @@ -498,35 +484,6 @@ export function Sidebar() { const baseUrl = workspaceSlug ? `/${workspaceSlug}` : workspace ? `/${workspace.slug}` : ''; const favoriteProjects = projects.filter((p) => favoriteProjectIds.includes(p.id)); - const MODULE_STORAGE_KEY_PREFIX = 'module_favorites'; - const CYCLE_STORAGE_KEY_PREFIX = 'cycle_favorites'; - const moduleStorageKey = (workspaceId: string, projId: string) => - `${MODULE_STORAGE_KEY_PREFIX}_${workspaceId}_${projId}`; - const cycleStorageKey = (workspaceId: string, projId: string) => - `${CYCLE_STORAGE_KEY_PREFIX}_${workspaceId}_${projId}`; - - const loadModuleFavoriteIds = useCallback((workspaceId: string, projId: string) => { - try { - const raw = localStorage.getItem(moduleStorageKey(workspaceId, projId)); - if (!raw) return []; - const parsed = JSON.parse(raw) as unknown; - return Array.isArray(parsed) ? parsed.filter((x) => typeof x === 'string') : []; - } catch { - return []; - } - }, []); - - const loadCycleFavoriteIds = useCallback((workspaceId: string, projId: string) => { - try { - const raw = localStorage.getItem(cycleStorageKey(workspaceId, projId)); - if (!raw) return []; - const parsed = JSON.parse(raw) as unknown; - return Array.isArray(parsed) ? parsed.filter((x) => typeof x === 'string') : []; - } catch { - return []; - } - }, []); - useEffect(() => { let cancelled = false; workspaceService.list().then((list) => { @@ -560,87 +517,6 @@ export function Sidebar() { }; }, [slugForProjects]); - // Only the projects that actually have starred modules, as a stable string. - // Keying the fetch effect on this (instead of the whole projects array) stops - // the per-project module fetches from re-running every time projects is - // replaced with a fresh array on workspace load. - const moduleFavProjectIds = useMemo(() => { - if (!workspaceSlug) return ''; - return projects - .map((p) => p.id) - .filter((id) => loadModuleFavoriteIds(workspaceSlug, id).length > 0) - .sort() - .join(','); - // moduleFavoritesNonce forces a recompute after a toggle mutates the - // (non-reactive) localStorage the filter reads from. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [workspaceSlug, projects, loadModuleFavoriteIds, moduleFavoritesNonce]); - - useEffect(() => { - if (!workspaceSlug || !moduleFavProjectIds) { - setFavoriteModules([]); - return; - } - // Load starred modules from localStorage, then resolve names via module list. - let cancelled = false; - const run = async () => { - const entries: Array<{ projectId: string; module: ModuleApiResponse }> = []; - for (const projectId of moduleFavProjectIds.split(',')) { - const favIds = loadModuleFavoriteIds(workspaceSlug, projectId); - if (!favIds.length) continue; - const mods = await moduleService.list(workspaceSlug, projectId); - const favSet = new Set(favIds); - for (const m of mods ?? []) { - if (favSet.has(m.id)) { - entries.push({ projectId, module: m }); - } - } - } - if (!cancelled) setFavoriteModules(entries); - }; - void run(); - return () => { - cancelled = true; - }; - }, [workspaceSlug, moduleFavProjectIds, moduleFavoritesNonce, loadModuleFavoriteIds]); - - const cycleFavProjectIds = useMemo(() => { - if (!workspaceSlug) return ''; - return projects - .map((p) => p.id) - .filter((id) => loadCycleFavoriteIds(workspaceSlug, id).length > 0) - .sort() - .join(','); - // cycleFavoritesNonce forces a recompute after a toggle mutates the - // (non-reactive) localStorage the filter reads from. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [workspaceSlug, projects, loadCycleFavoriteIds, cycleFavoritesNonce]); - - useEffect(() => { - if (!workspaceSlug || !cycleFavProjectIds) { - setFavoriteCycles([]); - return; - } - let cancelled = false; - const run = async () => { - const results = await Promise.all( - cycleFavProjectIds.split(',').map(async (projectId) => { - const favSet = new Set(loadCycleFavoriteIds(workspaceSlug, projectId)); - const cycles = await cycleService.list(workspaceSlug, projectId); - return (cycles ?? []) - .filter((c) => favSet.has(c.id)) - .map((c) => ({ projectId, cycle: c })); - }), - ); - const entries = results.flat(); - if (!cancelled) setFavoriteCycles(entries); - }; - void run(); - return () => { - cancelled = true; - }; - }, [workspaceSlug, cycleFavProjectIds, cycleFavoritesNonce, loadCycleFavoriteIds]); - useEffect(() => { if (!workspaceSlug) { setFavoriteIssueViews([]); @@ -673,55 +549,6 @@ export function Sidebar() { }; }, [workspaceSlug]); - // Keep the "Favorites -> Modules" list in sync without requiring a full refresh. - useEffect(() => { - if (!workspaceSlug) return; - const handler = (e: Event) => { - const ce = e as CustomEvent<{ - workspaceId?: string; - projectId?: string; - moduleId?: string; - isFavorite?: boolean; - }>; - if (ce?.detail?.workspaceId !== workspaceSlug) return; - const { moduleId, isFavorite } = ce.detail ?? {}; - - // Optimistically remove immediately on un-favorite. - if (moduleId && isFavorite === false) { - setFavoriteModules((prev) => prev.filter(({ module }) => module.id !== moduleId)); - } - - // Always reload after a change to ensure names and newly-added items are correct. - setModuleFavoritesNonce((n) => n + 1); - }; - window.addEventListener('module-favorites-changed', handler as EventListener); - return () => { - window.removeEventListener('module-favorites-changed', handler as EventListener); - }; - }, [workspaceSlug]); - - useEffect(() => { - if (!workspaceSlug) return; - const handler = (e: Event) => { - const ce = e as CustomEvent<{ - workspaceId?: string; - projectId?: string; - cycleId?: string; - isFavorite?: boolean; - }>; - if (ce?.detail?.workspaceId !== workspaceSlug) return; - const { cycleId, isFavorite } = ce.detail ?? {}; - if (cycleId && isFavorite === false) { - setFavoriteCycles((prev) => prev.filter(({ cycle }) => cycle.id !== cycleId)); - } - setCycleFavoritesNonce((n) => n + 1); - }; - window.addEventListener(CYCLE_FAVORITES_CHANGED_EVENT, handler as EventListener); - return () => { - window.removeEventListener(CYCLE_FAVORITES_CHANGED_EVENT, handler as EventListener); - }; - }, [workspaceSlug]); - useEffect(() => { let cancelled = false; favoriteService @@ -1219,41 +1046,8 @@ export function Sidebar() { {view.name} ))} - {favoriteModules.length > 0 && ( - <> - {favoriteModules.map(({ projectId, module }) => ( - - - - -
- {module.name} -
- - ))} - - )} - {favoriteCycles.length > 0 && ( - <> - {favoriteCycles.map(({ projectId, cycle }) => ( - - - - -
- {cycle.name} -
- - ))} - + {workspaceSlug && ( + )} )} diff --git a/apps/web/src/components/layout/WorkspaceFavoritesTree.tsx b/apps/web/src/components/layout/WorkspaceFavoritesTree.tsx new file mode 100644 index 00000000..9544789d --- /dev/null +++ b/apps/web/src/components/layout/WorkspaceFavoritesTree.tsx @@ -0,0 +1,195 @@ +import { useMemo, useState } from 'react'; +import { NavLink } from 'react-router-dom'; +import { ChevronRight, Folder, FolderPlus, IterationCw, LayoutGrid, X } from 'lucide-react'; +import { cn } from '../../lib/utils'; +import type { FavoriteApiResponse } from '../../api/types'; +import { useWorkspaceFavorites } from '../../hooks/useWorkspaceFavorites'; + +function entityHref(baseUrl: string, fav: FavoriteApiResponse): string | null { + if (!fav.project_id) return null; + const p = `${baseUrl}/projects/${fav.project_id}`; + if (fav.entity_type === 'cycle') return `${p}/cycles/${fav.entity_identifier}`; + if (fav.entity_type === 'module') return `${p}/modules/${fav.entity_identifier}`; + return null; +} + +function EntityIcon({ type }: { type: string }) { + if (type === 'cycle') return ; + return ; +} + +/** + * Renders the workspace favorites tree (cycle/module favorites grouped into + * folders, ordered) with create-folder, drag-to-reorder, drag-into-folder, and + * remove. Persists every change through the favorites API. + */ +export function WorkspaceFavoritesTree({ + workspaceSlug, + baseUrl, +}: { + workspaceSlug: string; + baseUrl: string; +}) { + const { favorites, updateFavorite, removeById, createFolder } = + useWorkspaceFavorites(workspaceSlug); + const [expanded, setExpanded] = useState>({}); + const [dragId, setDragId] = useState(null); + const [dropTarget, setDropTarget] = useState(null); + + const { roots, childrenByFolder } = useMemo(() => { + const byParent = new Map(); + const top: FavoriteApiResponse[] = []; + for (const f of favorites) { + if (f.parent_id) { + byParent.set(f.parent_id, [...(byParent.get(f.parent_id) ?? []), f]); + } else { + top.push(f); + } + } + return { roots: top, childrenByFolder: byParent }; + }, [favorites]); + + if (favorites.length === 0) { + return ( + + ); + } + + const moveInto = (id: string, parentId: string | null) => { + if (id === parentId) return; + void updateFavorite(id, { parent_id: parentId }); + }; + const reorderBefore = (id: string, target: FavoriteApiResponse) => { + if (id === target.id) return; + void updateFavorite(id, { + parent_id: target.parent_id ?? null, + sort_order: target.sort_order - 1, + }); + }; + + const Row = ({ fav, nested }: { fav: FavoriteApiResponse; nested: boolean }) => { + const href = entityHref(baseUrl, fav); + const isDrop = dropTarget === fav.id; + const dragHandlers = { + draggable: true, + onDragStart: () => setDragId(fav.id), + onDragEnd: () => { + setDragId(null); + setDropTarget(null); + }, + onDragOver: (e: React.DragEvent) => { + if (dragId && dragId !== fav.id) { + e.preventDefault(); + setDropTarget(fav.id); + } + }, + onDragLeave: () => setDropTarget((t) => (t === fav.id ? null : t)), + onDrop: (e: React.DragEvent) => { + e.preventDefault(); + if (!dragId || dragId === fav.id) return; + if (fav.is_folder) moveInto(dragId, fav.id); + else reorderBefore(dragId, fav); + setDropTarget(null); + setDragId(null); + }, + }; + + const rowClass = cn( + 'group flex items-center gap-2 rounded-(--radius-md) px-2 py-1 text-[13px]', + nested && 'ml-4', + isDrop && 'ring-1 ring-(--brand-default)', + ); + + if (fav.is_folder) { + const kids = childrenByFolder.get(fav.id) ?? []; + const open = expanded[fav.id] ?? true; + return ( +
+
+ + +
+ {open && kids.map((k) => )} +
+ ); + } + + return ( +
+ + cn( + 'flex min-w-0 flex-1 items-center gap-2 outline-none', + isActive + ? 'text-(--txt-primary)' + : 'text-(--txt-secondary) hover:text-(--txt-primary)', + ) + } + > + + + + {fav.name} + + +
+ ); + }; + + return ( +
+ {roots.map((fav) => ( + + ))} + +
+ ); +} diff --git a/apps/web/src/contexts/FavoritesContext.tsx b/apps/web/src/contexts/FavoritesContext.tsx index f1a145e8..9401394e 100644 --- a/apps/web/src/contexts/FavoritesContext.tsx +++ b/apps/web/src/contexts/FavoritesContext.tsx @@ -1,17 +1,32 @@ /* eslint-disable react-refresh/only-export-components -- context file exports FavoritesProvider + useFavorites; keep for future use */ import { createContext, useContext, useState, type ReactNode } from 'react'; +import type { FavoriteApiResponse } from '../api/types'; interface FavoritesContextValue { favoriteProjectIds: string[]; setFavoriteProjectIds: (ids: string[] | ((prev: string[]) => string[])) => void; + /** The workspace favorites tree (cycles/modules + folders), shared so the + * sidebar and pages stay in sync. */ + workspaceFavorites: FavoriteApiResponse[]; + setWorkspaceFavorites: ( + favs: FavoriteApiResponse[] | ((prev: FavoriteApiResponse[]) => FavoriteApiResponse[]), + ) => void; } const FavoritesContext = createContext(null); export function FavoritesProvider({ children }: { children: ReactNode }) { const [favoriteProjectIds, setFavoriteProjectIds] = useState([]); + const [workspaceFavorites, setWorkspaceFavorites] = useState([]); return ( - + {children} ); diff --git a/apps/web/src/hooks/useCycleFavorites.ts b/apps/web/src/hooks/useCycleFavorites.ts deleted file mode 100644 index 1c509e4d..00000000 --- a/apps/web/src/hooks/useCycleFavorites.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; - -const STORAGE_KEY_PREFIX = 'cycle_favorites'; -export const CYCLE_FAVORITES_CHANGED_EVENT = 'cycle-favorites-changed'; - -function storageKey(workspaceId: string, projectId: string): string { - return `${STORAGE_KEY_PREFIX}_${workspaceId}_${projectId}`; -} - -function loadFavorites(workspaceId: string, projectId: string): string[] { - try { - const raw = localStorage.getItem(storageKey(workspaceId, projectId)); - if (!raw) return []; - const parsed = JSON.parse(raw) as unknown; - return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : []; - } catch { - return []; - } -} - -export function useCycleFavorites(workspaceId: string | undefined, projectId: string | undefined) { - const [favoriteCycleIds, setFavoriteCycleIds] = useState([]); - - useEffect(() => { - const next = workspaceId && projectId ? loadFavorites(workspaceId, projectId) : []; - queueMicrotask(() => setFavoriteCycleIds(next)); - }, [workspaceId, projectId]); - - const toggleFavorite = useCallback( - (cycleId: string) => { - if (!workspaceId || !projectId) return false; - setFavoriteCycleIds((prev) => { - const next = prev.includes(cycleId) - ? prev.filter((id) => id !== cycleId) - : [...prev, cycleId]; - try { - localStorage.setItem(storageKey(workspaceId, projectId), JSON.stringify(next)); - } catch { - // ignore - } - if (typeof window !== 'undefined') { - window.dispatchEvent( - new CustomEvent(CYCLE_FAVORITES_CHANGED_EVENT, { - detail: { - workspaceId, - projectId, - cycleId, - isFavorite: next.includes(cycleId), - }, - }), - ); - } - return next; - }); - return true; - }, - [workspaceId, projectId], - ); - - const isFavorite = useCallback( - (cycleId: string) => favoriteCycleIds.includes(cycleId), - [favoriteCycleIds], - ); - - return { favoriteCycleIds, toggleFavorite, isFavorite }; -} diff --git a/apps/web/src/hooks/useModuleFavorites.ts b/apps/web/src/hooks/useModuleFavorites.ts deleted file mode 100644 index 9d417ef9..00000000 --- a/apps/web/src/hooks/useModuleFavorites.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; - -const STORAGE_KEY_PREFIX = 'module_favorites'; -const MODULE_FAVORITES_CHANGED_EVENT = 'module-favorites-changed'; - -function storageKey(workspaceId: string, projectId: string): string { - return `${STORAGE_KEY_PREFIX}_${workspaceId}_${projectId}`; -} - -function loadFavorites(workspaceId: string, projectId: string): string[] { - try { - const raw = localStorage.getItem(storageKey(workspaceId, projectId)); - if (!raw) return []; - const parsed = JSON.parse(raw) as unknown; - return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : []; - } catch { - return []; - } -} - -export function useModuleFavorites(workspaceId: string | undefined, projectId: string | undefined) { - const [favoriteModuleIds, setFavoriteModuleIds] = useState([]); - - useEffect(() => { - const next = workspaceId && projectId ? loadFavorites(workspaceId, projectId) : []; - queueMicrotask(() => setFavoriteModuleIds(next)); - }, [workspaceId, projectId]); - - const toggleFavorite = useCallback( - (moduleId: string) => { - if (!workspaceId || !projectId) return false; - setFavoriteModuleIds((prev) => { - const next = prev.includes(moduleId) - ? prev.filter((id) => id !== moduleId) - : [...prev, moduleId]; - try { - localStorage.setItem(storageKey(workspaceId, projectId), JSON.stringify(next)); - } catch { - // ignore - } - if (typeof window !== 'undefined') { - window.dispatchEvent( - new CustomEvent(MODULE_FAVORITES_CHANGED_EVENT, { - detail: { - workspaceId, - projectId, - moduleId, - isFavorite: next.includes(moduleId), - }, - }), - ); - } - return next; - }); - return true; - }, - [workspaceId, projectId], - ); - - const isFavorite = useCallback( - (moduleId: string) => favoriteModuleIds.includes(moduleId), - [favoriteModuleIds], - ); - - return { favoriteModuleIds, toggleFavorite, isFavorite }; -} diff --git a/apps/web/src/hooks/useWorkspaceFavorites.ts b/apps/web/src/hooks/useWorkspaceFavorites.ts new file mode 100644 index 00000000..cd4191a7 --- /dev/null +++ b/apps/web/src/hooks/useWorkspaceFavorites.ts @@ -0,0 +1,103 @@ +import { useCallback, useEffect } from 'react'; +import { useFavorites } from '../contexts/FavoritesContext'; +import { workspaceFavoriteService } from '../services/workspaceFavoriteService'; +import type { FavoriteApiResponse } from '../api/types'; + +export type FavoriteEntityType = 'cycle' | 'module'; + +/** + * Loads and mutates the workspace favorites tree (cycle/module favorites + + * folders + ordering), sharing state through FavoritesContext so the sidebar + * and pages stay in sync. + */ +export function useWorkspaceFavorites(workspaceSlug: string | undefined) { + const { workspaceFavorites, setWorkspaceFavorites } = useFavorites(); + + const reload = useCallback(() => { + if (!workspaceSlug) return; + workspaceFavoriteService + .list(workspaceSlug) + .then(setWorkspaceFavorites) + .catch(() => setWorkspaceFavorites([])); + }, [workspaceSlug, setWorkspaceFavorites]); + + useEffect(() => { + reload(); + }, [reload]); + + const favoriteFor = useCallback( + (entityType: string, entityId: string): FavoriteApiResponse | undefined => + workspaceFavorites.find( + (f) => f.entity_type === entityType && f.entity_identifier === entityId, + ), + [workspaceFavorites], + ); + + const isFavorited = useCallback( + (entityType: string, entityId: string) => Boolean(favoriteFor(entityType, entityId)), + [favoriteFor], + ); + + const toggleEntity = useCallback( + async (payload: { + entity_type: FavoriteEntityType; + entity_id: string; + project_id: string; + name: string; + }) => { + if (!workspaceSlug) return; + const existing = favoriteFor(payload.entity_type, payload.entity_id); + try { + if (existing) { + await workspaceFavoriteService.remove(workspaceSlug, existing.id); + } else { + await workspaceFavoriteService.addEntity(workspaceSlug, payload); + } + } finally { + reload(); + } + }, + [workspaceSlug, favoriteFor, reload], + ); + + const createFolder = useCallback( + async (name: string) => { + if (!workspaceSlug) return; + await workspaceFavoriteService.createFolder(workspaceSlug, name); + reload(); + }, + [workspaceSlug, reload], + ); + + const updateFavorite = useCallback( + async ( + id: string, + payload: { name?: string; parent_id?: string | null; sort_order?: number }, + ) => { + if (!workspaceSlug) return; + await workspaceFavoriteService.update(workspaceSlug, id, payload); + reload(); + }, + [workspaceSlug, reload], + ); + + const removeById = useCallback( + async (id: string) => { + if (!workspaceSlug) return; + await workspaceFavoriteService.remove(workspaceSlug, id); + reload(); + }, + [workspaceSlug, reload], + ); + + return { + favorites: workspaceFavorites, + isFavorited, + favoriteFor, + toggleEntity, + createFolder, + updateFavorite, + removeById, + reload, + }; +} diff --git a/apps/web/src/pages/CyclesPage.tsx b/apps/web/src/pages/CyclesPage.tsx index 072f5d2f..10ba3123 100644 --- a/apps/web/src/pages/CyclesPage.tsx +++ b/apps/web/src/pages/CyclesPage.tsx @@ -27,7 +27,7 @@ import { PROJECT_CYCLES_FILTER_EVENT, PROJECT_CYCLES_REFRESH_EVENT, } from '../lib/projectCyclesEvents'; -import { useCycleFavorites } from '../hooks/useCycleFavorites'; +import { useWorkspaceFavorites } from '../hooks/useWorkspaceFavorites'; import { parseISODateForDisplay, parseISODateLocal } from '../lib/dateOnly'; import { cyclePathSegment } from '../lib/cycle'; import { cn, getImageUrl } from '../lib/utils'; @@ -334,7 +334,17 @@ export function CyclesPage() { const [deleteCycleId, setDeleteCycleId] = useState(null); const navigate = useNavigate(); - const { toggleFavorite, isFavorite } = useCycleFavorites(workspaceSlug, projectId); + const { isFavorited, toggleEntity } = useWorkspaceFavorites(workspaceSlug); + const isFavorite = (id: string) => isFavorited('cycle', id); + const toggleFavorite = (cycle: { id: string; name: string }) => { + if (!projectId) return; + void toggleEntity({ + entity_type: 'cycle', + entity_id: cycle.id, + project_id: projectId, + name: cycle.name, + }); + }; const [filters, setFilters] = useState({ searchQuery: null, @@ -821,7 +831,7 @@ export function CyclesPage() { onClick={(e) => { e.preventDefault(); e.stopPropagation(); - void toggleFavorite(c.id); + void toggleFavorite(c); }} > {isFavorite(c.id) ? ( @@ -983,7 +993,7 @@ export function CyclesPage() { aria-label={ isFavorite(activeCycle.id) ? 'Remove from favorites' : 'Add to favorites' } - onClick={() => void toggleFavorite(activeCycle.id)} + onClick={() => void toggleFavorite(activeCycle)} > {isFavorite(activeCycle.id) ? ( diff --git a/apps/web/src/pages/ModulesPage.tsx b/apps/web/src/pages/ModulesPage.tsx index 94b27452..d67187fc 100644 --- a/apps/web/src/pages/ModulesPage.tsx +++ b/apps/web/src/pages/ModulesPage.tsx @@ -7,7 +7,7 @@ import { useModulesFilter } from '../contexts/ModulesFilterContext'; import { workspaceService } from '../services/workspaceService'; import { projectService } from '../services/projectService'; import { moduleService, type ModuleProgress } from '../services/moduleService'; -import { useModuleFavorites } from '../hooks/useModuleFavorites'; +import { useWorkspaceFavorites } from '../hooks/useWorkspaceFavorites'; import type { WorkspaceApiResponse, ProjectApiResponse, @@ -177,10 +177,21 @@ export function ModulesPage() { const [editModule, setEditModule] = useState(null); const [editOpenDatePicker, setEditOpenDatePicker] = useState(false); const [quickDateModule, setQuickDateModule] = useState(null); - const { favoriteModuleIds, toggleFavorite, isFavorite } = useModuleFavorites( - workspaceSlug, - projectId, + const { favorites, isFavorited, toggleEntity } = useWorkspaceFavorites(workspaceSlug); + const favoriteModuleIds = useMemo( + () => favorites.filter((f) => f.entity_type === 'module').map((f) => f.entity_identifier), + [favorites], ); + const isFavorite = (id: string) => isFavorited('module', id); + const toggleFavorite = (mod: { id: string; name: string }) => { + if (!projectId) return; + void toggleEntity({ + entity_type: 'module', + entity_id: mod.id, + project_id: projectId, + name: mod.name, + }); + }; useDocumentTitle('Modules'); const searchQuery = (filter.search ?? '').trim().toLowerCase(); @@ -538,7 +549,7 @@ export function ModulesPage() { onClick={(e) => { e.preventDefault(); e.stopPropagation(); - void toggleFavorite(mod.id); + void toggleFavorite(mod); }} > {fav ? ( diff --git a/apps/web/src/services/workspaceFavoriteService.ts b/apps/web/src/services/workspaceFavoriteService.ts new file mode 100644 index 00000000..2c11ad20 --- /dev/null +++ b/apps/web/src/services/workspaceFavoriteService.ts @@ -0,0 +1,53 @@ +import { apiClient } from '../api/client'; +import type { FavoriteApiResponse } from '../api/types'; + +const base = (workspaceSlug: string) => + `/api/workspaces/${encodeURIComponent(workspaceSlug)}/favorites/`; + +/** + * Workspace favorites tree: favoriting cycles/modules, grouping them into + * folders, and ordering. Project/view/page favorites keep their own endpoints. + */ +export const workspaceFavoriteService = { + async list(workspaceSlug: string): Promise { + const { data } = await apiClient.get(base(workspaceSlug)); + return Array.isArray(data) ? data : []; + }, + + async addEntity( + workspaceSlug: string, + payload: { + entity_type: 'cycle' | 'module'; + entity_id: string; + project_id: string; + name: string; + }, + ): Promise { + const { data } = await apiClient.post(base(workspaceSlug), payload); + return data; + }, + + async createFolder(workspaceSlug: string, name: string): Promise { + const { data } = await apiClient.post(base(workspaceSlug), { + is_folder: true, + name, + }); + return data; + }, + + async update( + workspaceSlug: string, + favoriteId: string, + payload: { name?: string; parent_id?: string | null; sort_order?: number }, + ): Promise { + const { data } = await apiClient.patch( + `${base(workspaceSlug)}${encodeURIComponent(favoriteId)}/`, + payload, + ); + return data; + }, + + async remove(workspaceSlug: string, favoriteId: string): Promise { + await apiClient.delete(`${base(workspaceSlug)}${encodeURIComponent(favoriteId)}/`); + }, +}; From 5ae453e00496834f87af2a136294768fc21b2b23 Mon Sep 17 00:00:00 2001 From: cavidelizade Date: Mon, 13 Jul 2026 12:53:20 +0400 Subject: [PATCH 2/2] fix(favorites): atomic add, propagate infra errors, require non-empty names Addresses review feedback: - AddEntity is now an atomic OnConflict insert + re-read, so a concurrent favorite of the same entity stays idempotent instead of failing on the unique index. - workspace() and AddEntity surface non-sentinel (infrastructure) errors as 500s instead of masking them as 404 forbidden. - Folder and entity favorite names must be non-empty (400 otherwise), so a blank label can't be persisted. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/internal/handler/favorite_tree.go | 2 +- .../internal/handler/favorite_tree_test.go | 10 +++++++ apps/api/internal/service/favorite.go | 25 +++++++++++++++--- apps/api/internal/store/user_favorite.go | 26 ++++++++++--------- 4 files changed, 46 insertions(+), 17 deletions(-) diff --git a/apps/api/internal/handler/favorite_tree.go b/apps/api/internal/handler/favorite_tree.go index d2d9408e..d0bb6305 100644 --- a/apps/api/internal/handler/favorite_tree.go +++ b/apps/api/internal/handler/favorite_tree.go @@ -156,7 +156,7 @@ func (h *FavoriteHandler) favError(c *gin.Context, err error) { switch err { case service.ErrFavoriteWorkspace, service.ErrFavoriteForbidden, service.ErrFavoriteNotFound: c.JSON(http.StatusNotFound, gin.H{"error": "Not found"}) - case service.ErrFavoriteBadEntity, service.ErrFavoriteBadParent: + case service.ErrFavoriteBadEntity, service.ErrFavoriteBadParent, service.ErrFavoriteBadName: c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": "Favorites request failed"}) diff --git a/apps/api/internal/handler/favorite_tree_test.go b/apps/api/internal/handler/favorite_tree_test.go index 403becad..7975079a 100644 --- a/apps/api/internal/handler/favorite_tree_test.go +++ b/apps/api/internal/handler/favorite_tree_test.go @@ -79,6 +79,16 @@ func TestFavorites_CycleFolderOrdering(t *testing.T) { require.Len(t, favList(t, ts.GET(base, w.Session).Body.String()), 0) } +// An empty name is rejected for both folders and entity favorites. +func TestFavorites_EmptyNameRejected(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + base := "/api/workspaces/" + w.Workspace.Slug + "/favorites/" + + rr := ts.POST(base, map[string]any{"is_folder": true, "name": " "}, w.Session) + require.Equal(t, http.StatusBadRequest, rr.Code, "body=%s", rr.Body.String()) +} + // A non-member can't read a workspace's favorites. func TestFavorites_NonMemberForbidden(t *testing.T) { ts := testutil.NewTestServer(t) diff --git a/apps/api/internal/service/favorite.go b/apps/api/internal/service/favorite.go index fddec3a7..6011e230 100644 --- a/apps/api/internal/service/favorite.go +++ b/apps/api/internal/service/favorite.go @@ -3,6 +3,7 @@ package service import ( "context" "errors" + "strings" "github.com/Devlaner/devlane/api/internal/model" "github.com/Devlaner/devlane/api/internal/store" @@ -13,6 +14,7 @@ var ( ErrFavoriteNotFound = errors.New("favorite not found") ErrFavoriteBadEntity = errors.New("unsupported favorite entity type") ErrFavoriteBadParent = errors.New("parent must be one of your folders") + ErrFavoriteBadName = errors.New("name is required") ErrFavoriteWorkspace = errors.New("workspace not found") ErrFavoriteForbidden = errors.New("no access to this workspace") favoriteEntityTypesSet = map[string]bool{ @@ -38,7 +40,10 @@ func (s *FavoriteService) workspace(ctx context.Context, slug string, userID uui if err != nil { return nil, ErrFavoriteWorkspace } - ok, _ := s.ws.IsMember(ctx, wrk.ID, userID) + ok, err := s.ws.IsMember(ctx, wrk.ID, userID) + if err != nil { + return nil, err // surface infra failures as 500, not 404 + } if !ok { return nil, ErrFavoriteForbidden } @@ -60,13 +65,21 @@ func (s *FavoriteService) AddEntity(ctx context.Context, slug string, userID uui if !favoriteEntityTypesSet[entityType] { return nil, ErrFavoriteBadEntity } + name = strings.TrimSpace(name) + if name == "" { + return nil, ErrFavoriteBadName + } wrk, err := s.workspace(ctx, slug, userID) if err != nil { return nil, err } - // Confirm the caller can see the project the entity lives in. - if _, err := s.projects.GetByID(ctx, slug, projectID, userID); err != nil { - return nil, ErrFavoriteForbidden + // Confirm the caller can see the project the entity lives in. Access errors + // map to forbidden; anything else (infra) propagates as a 500. + if _, gerr := s.projects.GetByID(ctx, slug, projectID, userID); gerr != nil { + if gerr == ErrProjectNotFound || gerr == ErrProjectForbidden { + return nil, ErrFavoriteForbidden + } + return nil, gerr } pid := projectID fav := &model.UserFavorite{ @@ -96,6 +109,10 @@ func (s *FavoriteService) RemoveEntity(ctx context.Context, slug string, userID // CreateFolder makes a new folder to group favorites under. func (s *FavoriteService) CreateFolder(ctx context.Context, slug string, userID uuid.UUID, name string) (*model.UserFavorite, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, ErrFavoriteBadName + } wrk, err := s.workspace(ctx, slug, userID) if err != nil { return nil, err diff --git a/apps/api/internal/store/user_favorite.go b/apps/api/internal/store/user_favorite.go index 4fedfd03..5dcffe42 100644 --- a/apps/api/internal/store/user_favorite.go +++ b/apps/api/internal/store/user_favorite.go @@ -49,23 +49,25 @@ func (s *UserFavoriteStore) GetOwnedByID(ctx context.Context, userID, id uuid.UU return &f, nil } -// AddEntity favorites an entity (cycle/module/…), returning the existing row if -// it's already favorited so the call is idempotent. +// AddEntity favorites an entity (cycle/module/…) idempotently: an insert that +// races another request for the same (user, entity_type, entity_identifier) is +// a no-op, and the caller always gets the persisted row back. func (s *UserFavoriteStore) AddEntity(ctx context.Context, f *model.UserFavorite) (*model.UserFavorite, error) { - var existing model.UserFavorite - err := s.db.WithContext(ctx). - Where("user_id = ? AND entity_type = ? AND entity_identifier = ?", f.UserID, f.EntityType, f.EntityIdentifier). - First(&existing).Error - if err == nil { - return &existing, nil - } - if err != gorm.ErrRecordNotFound { + if err := s.db.WithContext(ctx). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "entity_type"}, {Name: "entity_identifier"}}, + DoNothing: true, + }). + Create(f).Error; err != nil { return nil, err } - if err := s.db.WithContext(ctx).Create(f).Error; err != nil { + var row model.UserFavorite + if err := s.db.WithContext(ctx). + Where("user_id = ? AND entity_type = ? AND entity_identifier = ?", f.UserID, f.EntityType, f.EntityIdentifier). + First(&row).Error; err != nil { return nil, err } - return f, nil + return &row, nil } // CreateFolder inserts a folder favorite. Folders carry a synthetic