feat: added GET /lists, POST /list/items/{id} and GET /list/{id}

This commit is contained in:
2026-08-21 14:48:38 +02:00
parent ea5dd431e5
commit 51b2b74f0d
5 changed files with 269 additions and 16 deletions
+121 -1
View File
@@ -57,6 +57,38 @@ func (h *ListHandler) CreateList(w http.ResponseWriter, r *http.Request) {
response.JSON(ctx, w, http.StatusCreated, list)
}
// GetLists handles fetching lists for the current user
//
// @Summary Returns all lists of the user
// @Description Retrieve all lists the user is a part of
// @Tags List
// @Accept json
// @Produce json
// @Success 200 {object} []domain.List
// @Error 400 {object} response.ErrorResponse "failed to decode request url"
// @Error 401 {object} response.ErrorResponse "not authorized"
// @Error 500 {object} response.ErrorResponse "failed to read lists"
// @Router /api/v1/lists [get]
func (h *ListHandler) GetLists(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
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
}
lists, err := h.ListService.GetLists(ctx, authContext.UserID)
if err != nil {
slog.ErrorContext(ctx, "failed to set list item completed", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to read list items")
return
}
response.JSON(ctx, w, http.StatusOK, lists)
}
// AddUserToList handles the user association to a list
//
// @Summary Add a user to the given list
@@ -186,6 +218,7 @@ func (h *ListHandler) CreateListItem(w http.ResponseWriter, r *http.Request) {
// @Param payload body request.UpdateListItemPayload true "Update list item 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 update list item"
// @Router /api/v1/lists/item [put]
func (h *ListHandler) UpdateListItem(w http.ResponseWriter, r *http.Request) {
@@ -201,7 +234,7 @@ func (h *ListHandler) UpdateListItem(w http.ResponseWriter, r *http.Request) {
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.StatusInternalServerError, "failed to update list item")
response.Error(ctx, w, http.StatusUnauthorized, "not authorized")
return
}
@@ -214,3 +247,90 @@ func (h *ListHandler) UpdateListItem(w http.ResponseWriter, r *http.Request) {
response.JSON(ctx, w, http.StatusNoContent, nil)
}
// SetListItemCompleted handles updating "is_completed" of a list item
//
// @Summary Updates "is_completed" of list item
// @Description Update an existing list item, setting the "is_completed" field
// @Tags List
// @Accept json
// @Produce json
// @Param payload body request.SetListItemCompletedPayload true "Update list item is completed payload"
// @Success 204 {object} nil
// @Error 400 {object} response.ErrorResponse "failed to decode request url"
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 401 {object} response.ErrorResponse "not authorized"
// @Error 500 {object} response.ErrorResponse "failed to update list item"
// @Router /api/v1/lists/item/{id} [post]
func (h *ListHandler) SetListItemCompleted(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
listItemID := r.PathValue("id")
if listItemID == "" {
slog.ErrorContext(ctx, "failed to read list item id from path")
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request url")
return
}
payload, err := request.DecodeJSON[request.SetListItemCompletedPayload](r)
if err != nil {
slog.ErrorContext(ctx, "failed to decode set list item completed 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.SetListItemCompleted(ctx, listItemID, authContext.UserID, payload.IsCompleted)
if err != nil {
slog.ErrorContext(ctx, "failed to set list item completed", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to set list item completed")
return
}
response.JSON(ctx, w, http.StatusNoContent, nil)
}
// GetListItems handles return all list items of a list
//
// @Summary Returns all items from a list
// @Description Retrieve all list items of a list
// @Tags List
// @Accept json
// @Produce json
// @Success 200 {object} []domain.ListItem
// @Error 400 {object} response.ErrorResponse "failed to decode request url"
// @Error 401 {object} response.ErrorResponse "not authorized"
// @Error 500 {object} response.ErrorResponse "failed to read list items"
// @Router /api/v1/lists/{id} [get]
func (h *ListHandler) GetListItems(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
listID := r.PathValue("id")
if listID == "" {
slog.ErrorContext(ctx, "failed to read list id from path")
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request url")
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
}
items, err := h.ListService.GetListItems(ctx, authContext.UserID, listID)
if err != nil {
slog.ErrorContext(ctx, "failed to set list item completed", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to read list items")
return
}
response.JSON(ctx, w, http.StatusOK, items)
}
+4
View File
@@ -25,3 +25,7 @@ type UpdateListItemPayload struct {
Title string `json:"title"`
IsCompleted bool `json:"is_completed"`
}
type SetListItemCompletedPayload struct {
IsCompleted bool `json:"is_completed"`
}
+3
View File
@@ -42,10 +42,13 @@ func NewMux(cfg Config) http.Handler {
apiMux.Handle("POST /users", protected(userHandler.CreateUser))
apiMux.Handle("POST /lists", protected(listHandler.CreateList))
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/item", protected(listHandler.CreateListItem))
apiMux.Handle("PUT /lists/item", protected(listHandler.UpdateListItem))
apiMux.Handle("POST /list/items/{id}", protected(listHandler.SetListItemCompleted))
apiMux.Handle("GET /list/{id}", protected(listHandler.GetListItems))
mux := http.NewServeMux()
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", apiMux))
+81 -15
View File
@@ -3,6 +3,7 @@ package domain
import (
"context"
"errors"
"log/slog"
"slices"
"time"
)
@@ -34,12 +35,15 @@ type ListItem struct {
type ListRepository interface {
CreateList(ctx context.Context, name string, userIDs []string) (*List, error)
GetListsByUserID(ctx context.Context, userID string) ([]domain.List, error)
AddUserToList(ctx context.Context, listID string, userID string) error
RemoveUserFromList(ctx context.Context, listID string, userID 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)
UpdateListItem(ctx context.Context, listItemID string, title string, isCompleted bool) error
SetListItemCompleted(ctx context.Context, listItemID string, isCompleted bool) error
GetListItems(ctx context.Context, listID string) ([]ListItem, error)
}
type ListService struct {
@@ -62,6 +66,10 @@ func (s *ListService) Create(ctx context.Context, authUserID string, name string
return s.repo.CreateList(ctx, name, userIDs)
}
func (s *ListService) GetLists(ctx context.Context, authUserID string) ([]List, error) {
return s.repo.GetListsByUserID(ctx, authUserID)
}
func (s *ListService) AddUserToList(ctx context.Context, authUserID string, listID string, userID string) error {
if listID == "" {
return ErrListIDEmpty
@@ -89,13 +97,9 @@ func (s *ListService) RemoveUserFromList(ctx context.Context, authUserID string,
return ErrUserIDEmpty
}
inList, err := s.repo.IsUserInList(ctx, listID, authUserID)
if err != nil {
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
return err
}
if !inList {
return ErrUserNotInList
}
return s.repo.RemoveUserFromList(ctx, listID, userID)
}
@@ -108,13 +112,9 @@ func (s *ListService) CreateListItem(ctx context.Context, authUserID string, lis
return nil, ErrListItemTitleEmpty
}
inList, err := s.repo.IsUserInList(ctx, listID, authUserID)
if err != nil {
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
return nil, err
}
if !inList {
return nil, ErrUserNotInList
}
return s.repo.CreateListItem(ctx, listID, title)
}
@@ -127,13 +127,79 @@ func (s *ListService) UpdateListItem(ctx context.Context, authUserID string, lis
return ErrListItemTitleEmpty
}
inList, err := s.repo.IsUserInListByItemID(ctx, listItemID, authUserID)
if err != nil {
if err := s.userAllowedToAccessList(ctx, authUserID, listItemID); err != nil {
return err
}
if !inList {
return ErrUserNotInList
}
return s.repo.UpdateListItem(ctx, listItemID, title, isCompleted)
}
func (s *ListService) SetListItemCompleted(ctx context.Context, authUserID string, listItemID string, isCompleted bool) error {
if listItemID == "" {
return ErrListItemIDEmpty
}
if err := s.userAllowedToAccessListItem(ctx, authUserID, listItemID); err != nil {
return err
}
return s.repo.SetListItemCompleted(ctx, listItemID, isCompleted)
}
func (s *ListService) GetListItems(ctx context.Context, authUserID string, listID string) ([]ListItem, error) {
if listID == "" {
return nil, ErrListIDEmpty
}
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
return nil, err
}
return s.repo.GetListItems(ctx, listID)
}
func (s *ListService) userAllowedToAccessList(ctx context.Context, authUserID string, listID string) error {
inList, err := s.repo.IsUserInList(ctx, listID, authUserID)
if err != nil {
slog.ErrorContext(ctx,
"failed to check if user is in list",
slog.String("user_id", authUserID),
slog.String("list_id", listID),
slog.Any("error", err),
)
return err
}
if !inList {
slog.WarnContext(ctx,
"user tried to access list without permission",
slog.String("user_id", authUserID),
slog.String("list_id", listID),
)
return ErrUserNotInList
}
return nil
}
func (s *ListService) userAllowedToAccessListItem(ctx context.Context, authUserID string, listItemID string) error {
inList, err := s.repo.IsUserInListByItemID(ctx, listItemID, authUserID)
if err != nil {
slog.ErrorContext(ctx,
"failed to check if user is in list",
slog.String("user_id", authUserID),
slog.String("list_item_id", listItemID),
slog.Any("error", err),
)
return err
}
if !inList {
slog.WarnContext(ctx,
"user tried to access list item without permission",
slog.String("user_id", authUserID),
slog.String("list_item_id", listItemID),
)
return ErrUserNotInList
}
return nil
}
+60
View File
@@ -58,6 +58,31 @@ func (r *ListRepo) CreateList(ctx context.Context, name string, userIDs []string
return list, nil
}
func (r *ListRepo) GetListsByUserID(ctx context.Context, userID string) ([]domain.List, error) {
rows, err := r.db.QueryContext(ctx,
"SELECT id, name, created_at, modified_at FROM lists JOIN list_users ON list.id=list_users.list_id WHERE list_users.user_id = $1",
userID,
)
if err != nil {
return nil, fmt.Errorf("failed to get list items: %w", err)
}
defer rows.Close()
var lists []domain.List
for rows.Next() {
var l domain.List
err = rows.Scan(&l.ID, &l.Name, &l.CreatedAt, &l.ModifiedAt)
if err != nil {
return nil, err
}
lists = append(lists, l)
}
return lists, nil
}
func (r *ListRepo) AddUserToList(ctx context.Context, listID string, userID string) error {
_, err := r.db.ExecContext(ctx,
"INSERT INTO list_users (list_id, user_id) VALUES ($1, $2)",
@@ -134,3 +159,38 @@ func (r *ListRepo) UpdateListItem(ctx context.Context, listItemID string, title
return nil
}
func (r *ListRepo) SetListItemCompleted(ctx context.Context, listItemID string, isCompleted bool) error {
_, err := r.db.ExecContext(ctx, "UPDATE list_items SET is_completed = $1, modified_at = NOW() WHERE id = $2",
isCompleted, listItemID,
)
if err != nil {
return fmt.Errorf("failed to complete list item: %w", err)
}
return nil
}
func (r *ListRepo) GetListItems(ctx context.Context, listID string) ([]domain.ListItem, error) {
rows, err := r.db.QueryContext(ctx,
"SELECT id, title, is_completed, created_at, modified_at FROM list_items WHERE list_id = $1",
listID,
)
if err != nil {
return nil, fmt.Errorf("failed to get list items: %w", err)
}
defer rows.Close()
var items []domain.ListItem
for rows.Next() {
var l domain.ListItem
err = rows.Scan(&l.ID, &l.Title, &l.IsCompleted, &l.CreatedAt, &l.ModifiedAt)
if err != nil {
return nil, err
}
items = append(items, l)
}
return items, nil
}