From 51b2b74f0def912cc821b66144e92ee4564315b1 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 21 Aug 2026 14:48:38 +0200 Subject: [PATCH] feat: added GET /lists, POST /list/items/{id} and GET /list/{id} --- internal/api/handler/list.go | 122 +++++++++++++++++++++++++++++++++- internal/api/request/list.go | 4 ++ internal/api/router/router.go | 3 + internal/domain/list.go | 96 +++++++++++++++++++++----- internal/repository/list.go | 60 +++++++++++++++++ 5 files changed, 269 insertions(+), 16 deletions(-) diff --git a/internal/api/handler/list.go b/internal/api/handler/list.go index 1295724..a1040ca 100644 --- a/internal/api/handler/list.go +++ b/internal/api/handler/list.go @@ -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) +} diff --git a/internal/api/request/list.go b/internal/api/request/list.go index 331d23a..d425b44 100644 --- a/internal/api/request/list.go +++ b/internal/api/request/list.go @@ -25,3 +25,7 @@ type UpdateListItemPayload struct { Title string `json:"title"` IsCompleted bool `json:"is_completed"` } + +type SetListItemCompletedPayload struct { + IsCompleted bool `json:"is_completed"` +} diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 20f8eb5..7fc45d1 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -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)) diff --git a/internal/domain/list.go b/internal/domain/list.go index b74e9c9..b8a7357 100644 --- a/internal/domain/list.go +++ b/internal/domain/list.go @@ -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 +} diff --git a/internal/repository/list.go b/internal/repository/list.go index b71b518..f64fe1f 100644 --- a/internal/repository/list.go +++ b/internal/repository/list.go @@ -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 +}