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
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
+4
View File
@@ -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"`
+1
View File
@@ -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))
@@ -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"
"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
}
+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) {
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