From 7def43fbebb3356c012b76b536d278589b7c66ca Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 11 Sep 2026 19:13:03 +0200 Subject: [PATCH] feat: re-order lists --- internal/api/handler/list.go | 47 +++++++++++++++ internal/api/request/list.go | 4 ++ internal/api/router/router.go | 1 + .../000006_add_list_position.down.sql | 8 +++ .../000006_add_list_position.up.sql | 20 +++++++ internal/domain/list.go | 60 +++++++++++++++++++ internal/repository/list.go | 39 +++++++++++- 7 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 internal/database/migrations/000006_add_list_position.down.sql create mode 100644 internal/database/migrations/000006_add_list_position.up.sql diff --git a/internal/api/handler/list.go b/internal/api/handler/list.go index c103db6..af697ee 100644 --- a/internal/api/handler/list.go +++ b/internal/api/handler/list.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "log/slog" "net/http" @@ -232,6 +233,52 @@ func (h *ListHandler) RemoveUserFromList(w http.ResponseWriter, r *http.Request) response.Status(w, http.StatusNoContent) } +// OrderLists handles re-ordering a users lists +// +// @Summary Order lists of a user +// @Description Re-assigns the display order of all users lists +// @Tags List +// @Accept json +// @Produce json +// @Param payload body request.OrderListsPayload true "Order lists payload" +// @Success 204 {object} nil +// @Error 400 {object} response.ErrorResponse "failed to decode request body" +// @Error 401 {object} response.ErrorResponse "not authorized" +// @Error 500 {object} response.ErrorResponse "failed to order lists" +// @Router /lists/order [post] +func (h *ListHandler) OrderLists(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + payload, err := request.DecodeJSON[request.OrderListsPayload](r) + if err != nil { + slog.ErrorContext(ctx, "failed to decode order lists payload", slog.Any("error", err)) + response.Error(ctx, w, http.StatusBadRequest, "failed to decode request body") + return + } + + authContext, err := domain.GetAuthContext(ctx) + if err != nil { + slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err)) + response.Error(ctx, w, http.StatusUnauthorized, "not authorized") + return + } + + err = h.ListService.OrderLists(ctx, authContext.UserID, payload.ListIDs) + if err != nil { + if errors.Is(err, domain.ErrStaleListIDs) { + response.Error(ctx, w, http.StatusBadRequest, "stale list IDs") + } else { + response.Error(ctx, w, http.StatusInternalServerError, "failed to order list items") + } + + slog.ErrorContext(ctx, "failed to order lists", slog.Any("error", err)) + return + } + + slog.InfoContext(ctx, "lists re-ordered successfully", slog.String("user_id", authContext.UserID)) + response.Status(w, http.StatusNoContent) +} + // CreateListItem handles creation of a new list item on a given list // // @Summary Create list item diff --git a/internal/api/request/list.go b/internal/api/request/list.go index fcb5093..700934d 100644 --- a/internal/api/request/list.go +++ b/internal/api/request/list.go @@ -14,6 +14,10 @@ type RemoveUserFromListPayload struct { Email string `json:"email"` } +type OrderListsPayload struct { + ListIDs []string `json:"list_ids"` +} + type CreateListItemPayload struct { ListID string `json:"list_id"` Title string `json:"title"` diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 31832be..967ca26 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -65,6 +65,7 @@ func NewMux(cfg Config) http.Handler { apiMux.Handle("GET /lists", protected(listHandler.GetLists)) apiMux.Handle("POST /lists/user", protected(listHandler.AddUserToList)) apiMux.Handle("DELETE /lists/user", protected(listHandler.RemoveUserFromList)) + apiMux.Handle("POST /lists/order", protected(listHandler.OrderLists)) apiMux.Handle("POST /lists/items", protected(listHandler.CreateListItem)) apiMux.Handle("DELETE /lists/items/{id}", protected(listHandler.DeleteListItem)) apiMux.Handle("PUT /lists/items", protected(listHandler.UpdateListItem)) diff --git a/internal/database/migrations/000006_add_list_position.down.sql b/internal/database/migrations/000006_add_list_position.down.sql new file mode 100644 index 0000000..9d63b03 --- /dev/null +++ b/internal/database/migrations/000006_add_list_position.down.sql @@ -0,0 +1,8 @@ +BEGIN; + +DROP INDEX IF EXISTS idx_list_users_position; + +ALTER TABLE IF EXISTS list_users + DROP COLUMN IF EXISTS position; + +COMMIT; diff --git a/internal/database/migrations/000006_add_list_position.up.sql b/internal/database/migrations/000006_add_list_position.up.sql new file mode 100644 index 0000000..f927ead --- /dev/null +++ b/internal/database/migrations/000006_add_list_position.up.sql @@ -0,0 +1,20 @@ +BEGIN; + +ALTER TABLE IF EXISTS list_users + ADD COLUMN IF NOT EXISTS position NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS idx_list_users_user_id_position ON list_users (user_id, position); + +UPDATE list_users lu + SET position = r.rn - 1 +FROM (SELECT list_id, + user_id, + row_number() OVER ( + PARTITION BY user_id + ORDER BY created_at, list_id + ) AS rn + FROM list_users) r +WHERE lu.list_id = r.list_id + AND lu.user_id = r.user_id; + +COMMIT; diff --git a/internal/domain/list.go b/internal/domain/list.go index 68bed6d..923af15 100644 --- a/internal/domain/list.go +++ b/internal/domain/list.go @@ -4,6 +4,7 @@ import ( "context" "errors" "log/slog" + "strings" "time" ) @@ -13,6 +14,7 @@ var ( ErrListItemIDMissing = errors.New("list item id is required") ErrListItemTitleMissing = errors.New("list item title is required") ErrUserNotInList = errors.New("user not in list") + ErrStaleListIDs = errors.New("list ids out of date") ) type List struct { @@ -22,6 +24,7 @@ type List struct { ModifiedAt time.Time `json:"modified_at"` TotalItems int `json:"total_items"` CompletedItems int `json:"completed_items"` + Position int `json:"position"` } type ListItem struct { @@ -39,6 +42,8 @@ type ListRepository interface { GetLists(ctx context.Context, userID string) ([]List, error) AddUserToList(ctx context.Context, listID string, userID string) error RemoveUserFromList(ctx context.Context, listID string, userID string) error + OrderLists(ctx context.Context, userID string, listIDs []string) error + LockUsersLists(ctx context.Context, userID string) ([]string, error) IsUserInList(ctx context.Context, listID string, userID string) (bool, error) IsUserInListByItemID(ctx context.Context, listItemID string, userID string) (bool, error) CreateListItem(ctx context.Context, listID string, title string) (*ListItem, error) @@ -131,6 +136,36 @@ func (s *ListService) RemoveUserFromList(ctx context.Context, authUserID string, return s.repo.RemoveUserFromList(ctx, listID, userID) } +func (s *ListService) OrderLists(ctx context.Context, authUserID string, listIDs []string) error { + if authUserID == "" { + return ErrUserIDMissing + } + if len(listIDs) == 0 { + return ErrListIDMissing + } + + return s.tx.WithinTx(ctx, func(ctx context.Context) error { + serverIDs, err := s.repo.LockUsersLists(ctx, authUserID) + if err != nil { + return err + } + + if !isPermutation(listIDs, serverIDs) { + slog.ErrorContext(ctx, "no permutation", + slog.Any("client_lids_ids", listIDs), + slog.Any("server_list_ids", serverIDs)) + return ErrStaleListIDs + } + + err = s.repo.OrderLists(ctx, authUserID, listIDs) + if err != nil { + return err + } + + return nil + }) +} + func (s *ListService) CreateListItem(ctx context.Context, authUserID string, listID string, title string) (*ListItem, error) { if listID == "" { return nil, ErrListIDMissing @@ -257,3 +292,28 @@ func (s *ListService) userAllowedToAccessListItem(ctx context.Context, authUserI return nil } + +func isPermutation(a []string, b []string) bool { + if len(a) != len(b) { + return false + } + + aMap := make(map[string]struct{}, len(a)) + for _, v := range a { + aMap[strings.ToLower(v)] = struct{}{} + } + + seen := make(map[string]struct{}, len(a)) + for _, v := range b { + id := strings.ToLower(v) + if _, ok := aMap[id]; !ok { + return false + } + if _, dup := seen[id]; dup { + return false + } + seen[id] = struct{}{} + } + + return true +} diff --git a/internal/repository/list.go b/internal/repository/list.go index 6e92921..e8e06f5 100644 --- a/internal/repository/list.go +++ b/internal/repository/list.go @@ -38,7 +38,7 @@ func (r *ListRepo) DeleteList(ctx context.Context, listID string) error { func (r *ListRepo) GetLists(ctx context.Context, userID string) ([]domain.List, error) { rows, err := r.conn(ctx).QueryContext(ctx, - "SELECT l.id, l.name, l.created_at, l.modified_at, (SELECT COUNT(*) FROM list_items WHERE list_id=l.id), (SELECT COUNT(*) FROM list_items WHERE list_id=l.id AND is_completed=true) FROM lists AS l INNER JOIN list_users ON l.id=list_users.list_id WHERE list_users.user_id = $1", + "SELECT l.id, l.name, l.created_at, l.modified_at, (SELECT COUNT(*) FROM list_items WHERE list_id=l.id), (SELECT COUNT(*) FROM list_items WHERE list_id=l.id AND is_completed=true), lu.position FROM lists AS l INNER JOIN list_users AS lu ON l.id=lu.list_id WHERE lu.user_id = $1 ORDER BY lu.position", userID, ) if err != nil { @@ -52,7 +52,7 @@ func (r *ListRepo) GetLists(ctx context.Context, userID string) ([]domain.List, lists := make([]domain.List, 0, 16) for rows.Next() { var l domain.List - err = rows.Scan(&l.ID, &l.Name, &l.CreatedAt, &l.ModifiedAt, &l.TotalItems, &l.CompletedItems) + err = rows.Scan(&l.ID, &l.Name, &l.CreatedAt, &l.ModifiedAt, &l.TotalItems, &l.CompletedItems, &l.Position) if err != nil { return nil, err } @@ -87,6 +87,41 @@ func (r *ListRepo) RemoveUserFromList(ctx context.Context, listID string, userID return nil } +func (r *ListRepo) OrderLists(ctx context.Context, userID string, listIDs []string) error { + _, err := r.conn(ctx).ExecContext(ctx, + "UPDATE list_users AS lu SET position = o.idx - 1 FROM unnest($2::uuid[]) WITH ORDINALITY AS o(list_id, idx) WHERE lu.list_id = o.list_id AND lu.user_id=$1", + userID, listIDs, + ) + if err != nil { + return fmt.Errorf("failed to order lists: %w", err) + } + + return nil +} + +func (r *ListRepo) LockUsersLists(ctx context.Context, userID string) ([]string, error) { + rows, err := r.conn(ctx).QueryContext(ctx, + "SELECT list_id FROM list_users WHERE user_id = $1 FOR UPDATE", + userID, + ) + if err != nil { + return nil, fmt.Errorf("failed to lock users lists: %w", err) + } + + ids := make([]string, 0, 16) + for rows.Next() { + var listID string + err = rows.Scan(&listID) + if err != nil { + return nil, fmt.Errorf("failed to read list id: %w", err) + } + + ids = append(ids, listID) + } + + return ids, nil +} + func (r *ListRepo) IsUserInList(ctx context.Context, listID string, userID string) (bool, error) { var cnt int