Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/internal/handler/favorite.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
164 changes: 164 additions & 0 deletions apps/api/internal/handler/favorite_tree.go
Original file line number Diff line number Diff line change
@@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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, service.ErrFavoriteBadName:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Favorites request failed"})
}
}
100 changes: 100 additions & 0 deletions apps/api/internal/handler/favorite_tree_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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)
}

// 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)
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)
}
7 changes: 6 additions & 1 deletion apps/api/internal/model/user_favorite.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
7 changes: 6 additions & 1 deletion apps/api/internal/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading