This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user