Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Favorites request failed"})
}
}
90 changes: 90 additions & 0 deletions apps/api/internal/handler/favorite_tree_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
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