Re-order lists #49

Merged
robin merged 3 commits from dev into main 2026-09-11 19:47:24 +02:00
7 changed files with 177 additions and 2 deletions
Showing only changes of commit 7def43fbeb - Show all commits
+47
View File
@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"errors"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -232,6 +233,52 @@ func (h *ListHandler) RemoveUserFromList(w http.ResponseWriter, r *http.Request)
response.Status(w, http.StatusNoContent) 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 // CreateListItem handles creation of a new list item on a given list
// //
// @Summary Create list item // @Summary Create list item
+4
View File
@@ -14,6 +14,10 @@ type RemoveUserFromListPayload struct {
Email string `json:"email"` Email string `json:"email"`
} }
type OrderListsPayload struct {
ListIDs []string `json:"list_ids"`
}
type CreateListItemPayload struct { type CreateListItemPayload struct {
ListID string `json:"list_id"` ListID string `json:"list_id"`
Title string `json:"title"` Title string `json:"title"`
+1
View File
@@ -65,6 +65,7 @@ func NewMux(cfg Config) http.Handler {
apiMux.Handle("GET /lists", protected(listHandler.GetLists)) apiMux.Handle("GET /lists", protected(listHandler.GetLists))
apiMux.Handle("POST /lists/user", protected(listHandler.AddUserToList)) apiMux.Handle("POST /lists/user", protected(listHandler.AddUserToList))
apiMux.Handle("DELETE /lists/user", protected(listHandler.RemoveUserFromList)) 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("POST /lists/items", protected(listHandler.CreateListItem))
apiMux.Handle("DELETE /lists/items/{id}", protected(listHandler.DeleteListItem)) apiMux.Handle("DELETE /lists/items/{id}", protected(listHandler.DeleteListItem))
apiMux.Handle("PUT /lists/items", protected(listHandler.UpdateListItem)) apiMux.Handle("PUT /lists/items", protected(listHandler.UpdateListItem))
@@ -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;
@@ -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;
+60
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"log/slog" "log/slog"
"strings"
"time" "time"
) )
@@ -13,6 +14,7 @@ var (
ErrListItemIDMissing = errors.New("list item id is required") ErrListItemIDMissing = errors.New("list item id is required")
ErrListItemTitleMissing = errors.New("list item title is required") ErrListItemTitleMissing = errors.New("list item title is required")
ErrUserNotInList = errors.New("user not in list") ErrUserNotInList = errors.New("user not in list")
ErrStaleListIDs = errors.New("list ids out of date")
) )
type List struct { type List struct {
@@ -22,6 +24,7 @@ type List struct {
ModifiedAt time.Time `json:"modified_at"` ModifiedAt time.Time `json:"modified_at"`
TotalItems int `json:"total_items"` TotalItems int `json:"total_items"`
CompletedItems int `json:"completed_items"` CompletedItems int `json:"completed_items"`
Position int `json:"position"`
} }
type ListItem struct { type ListItem struct {
@@ -39,6 +42,8 @@ type ListRepository interface {
GetLists(ctx context.Context, userID string) ([]List, error) GetLists(ctx context.Context, userID string) ([]List, error)
AddUserToList(ctx context.Context, listID string, userID string) error AddUserToList(ctx context.Context, listID string, userID string) error
RemoveUserFromList(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) IsUserInList(ctx context.Context, listID string, userID string) (bool, error)
IsUserInListByItemID(ctx context.Context, listItemID 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) 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) 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) { func (s *ListService) CreateListItem(ctx context.Context, authUserID string, listID string, title string) (*ListItem, error) {
if listID == "" { if listID == "" {
return nil, ErrListIDMissing return nil, ErrListIDMissing
@@ -257,3 +292,28 @@ func (s *ListService) userAllowedToAccessListItem(ctx context.Context, authUserI
return nil 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
}
+37 -2
View File
@@ -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) { func (r *ListRepo) GetLists(ctx context.Context, userID string) ([]domain.List, error) {
rows, err := r.conn(ctx).QueryContext(ctx, 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, userID,
) )
if err != nil { if err != nil {
@@ -52,7 +52,7 @@ func (r *ListRepo) GetLists(ctx context.Context, userID string) ([]domain.List,
lists := make([]domain.List, 0, 16) lists := make([]domain.List, 0, 16)
for rows.Next() { for rows.Next() {
var l domain.List 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 { if err != nil {
return nil, err return nil, err
} }
@@ -87,6 +87,41 @@ func (r *ListRepo) RemoveUserFromList(ctx context.Context, listID string, userID
return nil 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) { func (r *ListRepo) IsUserInList(ctx context.Context, listID string, userID string) (bool, error) {
var cnt int var cnt int