Merge pull request 'Enable adding/removing users to list as well as add/update list items' (#11) from dev into main

This commit was merged in pull request #11.
This commit is contained in:
2026-08-20 15:55:37 +02:00
11 changed files with 407 additions and 84 deletions
+2 -2
View File
@@ -32,7 +32,7 @@ func NewAuthHandler(authService *domain.AuthService) *AuthHandler {
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
payload, err := request.DecodeLogin(r)
payload, err := request.DecodeJSON[request.LoginPayload](r)
if err != nil {
slog.ErrorContext(ctx, "failed to decode login payload", slog.Any("error", err))
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request body")
@@ -64,7 +64,7 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
payload, err := request.DecodeRefresh(r)
payload, err := request.DecodeJSON[request.RefreshPayload](r)
if err != nil {
slog.ErrorContext(ctx, "failed to decode refresh payload", slog.Any("error", err))
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request body")
+168 -3
View File
@@ -28,18 +28,25 @@ func NewListHandler(listService *domain.ListService) *ListHandler {
// @Success 201 {object} domain.List
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 500 {object} response.ErrorResponse "failed to create list"
// @Router /api/v1/list [post]
// @Router /api/v1/lists [post]
func (h *ListHandler) CreateList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
payload, err := request.DecodeCreateList(r)
payload, err := request.DecodeJSON[request.CreateListPayload](r)
if err != nil {
slog.ErrorContext(ctx, "failed to decode create list payload", slog.Any("error", err))
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request body")
return
}
list, err := h.ListService.Create(ctx, payload.Name, payload.UserIDs)
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 create list")
return
}
list, err := h.ListService.Create(ctx, authContext.UserID, payload.Name, payload.UserIDs)
if err != nil {
slog.ErrorContext(ctx, "failed to create list", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to create list")
@@ -49,3 +56,161 @@ func (h *ListHandler) CreateList(w http.ResponseWriter, r *http.Request) {
slog.InfoContext(ctx, "created list successfully", slog.Any("list_id", list.ID))
response.JSON(ctx, w, http.StatusCreated, list)
}
// AddUserToList handles the user association to a list
//
// @Summary Add a user to the given list
// @Description Associate a user with a list
// @Tags List
// @Accept json
// @Produce json
// @Param payload body request.AddUserToListPayload true "Add user to list payload"
// @Success 204 {object} nil
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 500 {object} response.ErrorResponse "failed to add user to list"
// @Router /api/v1/lists/user [post]
func (h *ListHandler) AddUserToList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
payload, err := request.DecodeJSON[request.AddUserToListPayload](r)
if err != nil {
slog.ErrorContext(ctx, "failed to decode add user to list 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.StatusInternalServerError, "failed to add user to list")
return
}
err = h.ListService.AddUserToList(ctx, authContext.UserID, payload.ListID, payload.UserID)
if err != nil {
slog.ErrorContext(ctx, "failed to add user to list", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to add user to list")
return
}
slog.InfoContext(ctx, "added user to list successfully", slog.Any("list_id", payload.ListID), slog.Any("user_id", payload.UserID))
response.JSON(ctx, w, http.StatusNoContent, nil)
}
// RemoveUserFromList handles the removal of a user association to a list
//
// @Summary Remove a user to the given list
// @Description Unassociate a user from a list
// @Tags List
// @Accept json
// @Produce json
// @Param payload body request.RemoveUserFromList true "Remove user from list payload"
// @Success 204 {object} nil
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 500 {object} response.ErrorResponse "failed to remove user from list"
// @Router /api/v1/lists/user [delete]
func (h *ListHandler) RemoveUserFromList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
payload, err := request.DecodeJSON[request.RemoveUserFromListPayload](r)
if err != nil {
slog.ErrorContext(ctx, "failed to decode remove user from list 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.StatusInternalServerError, "failed to remove user from list")
return
}
err = h.ListService.RemoveUserFromList(ctx, authContext.UserID, payload.ListID, payload.UserID)
if err != nil {
slog.ErrorContext(ctx, "failed to remove user from list", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to remove user from list")
return
}
slog.InfoContext(ctx, "removed user from list successfully", slog.Any("list_id", payload.ListID), slog.Any("user_id", payload.UserID))
response.JSON(ctx, w, http.StatusNoContent, nil)
}
// CreateListItem handles creation of a new list item on a given list
//
// @Summary Create list item
// @Description Create a new list item on a given list
// @Tags List
// @Accept json
// @Produce json
// @Param payload body request.CreateListItemPayload true "Create list item payload"
// @Success 204 {object} nil
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 500 {object} response.ErrorResponse "failed to create list item"
// @Router /api/v1/lists/item [post]
func (h *ListHandler) CreateListItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
payload, err := request.DecodeJSON[request.CreateListItemPayload](r)
if err != nil {
slog.ErrorContext(ctx, "failed to decode create list item 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.StatusInternalServerError, "failed to create list item")
return
}
item, err := h.ListService.CreateListItem(ctx, authContext.UserID, payload.ListID, payload.Title)
if err != nil {
slog.ErrorContext(ctx, "failed to create list item", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to create list item")
return
}
response.JSON(ctx, w, http.StatusCreated, item)
}
// UpdateListItem handles updating of a list item
//
// @Summary Update list item
// @Description Update an existing list item
// @Tags List
// @Accept json
// @Produce json
// @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 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) {
ctx := r.Context()
payload, err := request.DecodeJSON[request.UpdateListItemPayload](r)
if err != nil {
slog.ErrorContext(ctx, "failed to decode update list item 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.StatusInternalServerError, "failed to update list item")
return
}
err = h.ListService.UpdateListItem(ctx, payload.ListItemID, authContext.UserID, payload.Title, payload.IsCompleted)
if err != nil {
slog.ErrorContext(ctx, "failed to update list item", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to update list item")
return
}
response.JSON(ctx, w, http.StatusNoContent, nil)
}
+1 -1
View File
@@ -32,7 +32,7 @@ func NewUserHandler(userService *domain.UserService) *UserHandler {
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
payload, err := request.DecodeCreateUser(r)
payload, err := request.DecodeJSON[request.CreateUserPayload](r)
if err != nil {
slog.ErrorContext(ctx, "failed to decode create user payload", slog.Any("error", err))
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request body")
-32
View File
@@ -1,11 +1,5 @@
package request
import (
"encoding/json"
"fmt"
"net/http"
)
type LoginPayload struct {
Email string `json:"email"`
Password string `json:"password"`
@@ -14,29 +8,3 @@ type LoginPayload struct {
type RefreshPayload struct {
RefreshToken string `json:"refresh_token"`
}
func DecodeLogin(r *http.Request) (LoginPayload, error) {
var payload LoginPayload
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil {
return payload, fmt.Errorf("error decoding login payload: %w", err)
}
return payload, nil
}
func DecodeRefresh(r *http.Request) (RefreshPayload, error) {
var payload RefreshPayload
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil {
return payload, fmt.Errorf("error decoding refresh payload: %w", err)
}
return payload, nil
}
+20
View File
@@ -0,0 +1,20 @@
package request
import (
"encoding/json"
"fmt"
"net/http"
)
func DecodeJSON[T any](r *http.Request) (T, error) {
var payload T
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil {
return payload, fmt.Errorf("error decoding payload: %w", err)
}
return payload, nil
}
+16 -14
View File
@@ -1,25 +1,27 @@
package request
import (
"encoding/json"
"fmt"
"net/http"
)
type CreateListPayload struct {
Name string `json:"name"`
UserIDs []string `json:"user_ids"`
}
func DecodeCreateList(r *http.Request) (CreateListPayload, error) {
var payload CreateListPayload
type AddUserToListPayload struct {
ListID string `json:"list_id"`
UserID string `json:"user_id"`
}
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
type RemoveUserFromListPayload struct {
ListID string `json:"list_id"`
UserID string `json:"user_id"`
}
if err := decoder.Decode(&payload); err != nil {
return payload, fmt.Errorf("error decoding create list payload: %w", err)
}
type CreateListItemPayload struct {
ListID string `json:"list_id"`
Title string `json:"title"`
}
return payload, nil
type UpdateListItemPayload struct {
ListItemID string `json:"list_item_id"`
Title string `json:"title"`
IsCompleted bool `json:"is_completed"`
}
-19
View File
@@ -1,26 +1,7 @@
package request
import (
"encoding/json"
"fmt"
"net/http"
)
type CreateUserPayload struct {
Email string `json:"email"`
Name string `json:"name"`
Password string `json:"password"`
}
func DecodeCreateUser(r *http.Request) (CreateUserPayload, error) {
var payload CreateUserPayload
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil {
return payload, fmt.Errorf("error decoding create user payload: %w", err)
}
return payload, nil
}
+4
View File
@@ -39,6 +39,10 @@ func NewMux(cfg Config) http.Handler {
apiMux.Handle("POST /users", protected(userHandler.CreateUser))
apiMux.Handle("POST /lists", protected(listHandler.CreateList))
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))
mux := http.NewServeMux()
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", apiMux))
+10 -10
View File
@@ -77,26 +77,26 @@ func (s *AuthService) authenticate(ctx context.Context, email string, password s
return user, nil
}
func (s *AuthService) Login(ctx context.Context, email string, password string) (*TokenPair, error) {
func (s *AuthService) Login(ctx context.Context, email string, password string) (TokenPair, error) {
user, err := s.authenticate(ctx, email, password)
if err != nil {
return nil, err
return TokenPair{}, err
}
return s.issueTokens(ctx, user)
}
func (s *AuthService) Refresh(ctx context.Context, refreshToken string) (*TokenPair, error) {
func (s *AuthService) Refresh(ctx context.Context, refreshToken string) (TokenPair, error) {
tokenHash := hashToken(refreshToken)
userID, err := s.repo.ConsumeRefreshToken(ctx, tokenHash)
if err != nil {
return nil, err
return TokenPair{}, err
}
authUser, err := s.repo.GetUserById(ctx, userID)
if err != nil {
return nil, err
return TokenPair{}, err
}
return s.issueTokens(ctx, authUser)
@@ -113,23 +113,23 @@ type contextKey string
const AuthContextKey = contextKey("auth")
func (s *AuthService) issueTokens(ctx context.Context, authUser *AuthUser) (*TokenPair, error) {
func (s *AuthService) issueTokens(ctx context.Context, authUser *AuthUser) (TokenPair, error) {
accessToken, err := s.GenerateAccessToken(authUser)
if err != nil {
return nil, fmt.Errorf("failed to issue access token: %s", err)
return TokenPair{}, fmt.Errorf("failed to issue access token: %s", err)
}
refreshToken, err := s.GenerateRefreshToken()
if err != nil {
return nil, fmt.Errorf("failed to issue refresh token: %s", err)
return TokenPair{}, fmt.Errorf("failed to issue refresh token: %s", err)
}
err = s.repo.StoreRefreshToken(ctx, authUser.ID, hashToken(refreshToken), time.Now().Add(time.Hour*24*7))
if err != nil {
return nil, fmt.Errorf("failed to store refresh token: %s", err)
return TokenPair{}, fmt.Errorf("failed to store refresh token: %s", err)
}
return &TokenPair{
return TokenPair{
AccessToken: accessToken,
RefreshToken: refreshToken,
}, nil
+109 -3
View File
@@ -3,9 +3,20 @@ package domain
import (
"context"
"errors"
"slices"
"time"
)
var (
ErrListIDEmpty = errors.New("list id must not be empty")
ErrListNameEmpty = errors.New("list name must not be empty")
ErrUserIDEmpty = errors.New("user id must not be empty")
ErrUserIDsEmpty = errors.New("user ids must not be empty")
ErrListItemIDEmpty = errors.New("list item id must not be empty")
ErrListItemTitleEmpty = errors.New("list item title must not be empty")
ErrUserNotInList = errors.New("user not in list")
)
type List struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -13,8 +24,23 @@ type List struct {
ModifiedAt time.Time `json:"modified_at"`
}
type ListItem struct {
ID string `json:"id"`
ListID string `json:"list_id"`
Title string `json:"title"`
IsCompleted bool `json:"is_completed"`
CreatedAt time.Time `json:"created_at"`
ModifiedAt time.Time `json:"modified_at"`
}
type ListRepository interface {
CreateList(ctx context.Context, name string, userIDs []string) (*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
}
type ListService struct {
@@ -25,14 +51,94 @@ func NewListService(r ListRepository) *ListService {
return &ListService{repo: r}
}
func (s *ListService) Create(ctx context.Context, name string, userIDs []string) (*List, error) {
func (s *ListService) Create(ctx context.Context, authUserID string, name string, userIDs []string) (*List, error) {
if len(userIDs) == 0 {
return nil, errors.New("users must have at least one associated user")
return nil, ErrUserIDsEmpty
}
if name == "" {
return nil, errors.New("list name must not be empty")
return nil, ErrListNameEmpty
}
if !slices.Contains(userIDs, authUserID) {
userIDs = append(userIDs, authUserID)
}
return s.repo.CreateList(ctx, name, userIDs)
}
func (s *ListService) AddUserToList(ctx context.Context, authUserID string, listID string, userID string) error {
if listID == "" {
return ErrListIDEmpty
}
if userID == "" {
return ErrUserIDEmpty
}
inList, err := s.repo.IsUserInList(ctx, listID, authUserID)
if err != nil {
return err
}
if !inList {
return ErrUserNotInList
}
return s.repo.AddUserToList(ctx, listID, userID)
}
func (s *ListService) RemoveUserFromList(ctx context.Context, authUserID string, listID string, userID string) error {
if listID == "" {
return ErrListIDEmpty
}
if userID == "" {
return ErrUserIDEmpty
}
inList, err := s.repo.IsUserInList(ctx, listID, authUserID)
if err != nil {
return err
}
if !inList {
return ErrUserNotInList
}
return s.repo.RemoveUserFromList(ctx, listID, userID)
}
func (s *ListService) CreateListItem(ctx context.Context, authUserID string, listID string, title string) (*ListItem, error) {
if listID == "" {
return nil, ErrListIDEmpty
}
if title == "" {
return nil, ErrListItemTitleEmpty
}
inList, err := s.repo.IsUserInList(ctx, listID, authUserID)
if err != nil {
return nil, err
}
if !inList {
return nil, ErrUserNotInList
}
return s.repo.CreateListItem(ctx, listID, title)
}
func (s *ListService) UpdateListItem(ctx context.Context, authUserID string, listItemID string, title string, isCompleted bool) error {
if listItemID == "" {
return ErrListItemIDEmpty
}
if title == "" {
return ErrListItemTitleEmpty
}
inList, err := s.repo.IsUserInListByItemID(ctx, listItemID, authUserID)
if err != nil {
return err
}
if !inList {
return ErrUserNotInList
}
return s.repo.UpdateListItem(ctx, listItemID, title, isCompleted)
}
+77
View File
@@ -57,3 +57,80 @@ func (r *ListRepo) CreateList(ctx context.Context, name string, userIDs []string
return list, 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)",
listID, userID,
)
if err != nil {
return fmt.Errorf("failed to associate user/list: %w", err)
}
return nil
}
func (r *ListRepo) RemoveUserFromList(ctx context.Context, listID string, userID string) error {
_, err := r.db.ExecContext(ctx,
"DELETE FROM list_users WHERE list_id = $1 AND user_id = $2",
listID, userID,
)
if err != nil {
return fmt.Errorf("failed to remove user from list: %w", err)
}
return nil
}
func (r *ListRepo) IsUserInList(ctx context.Context, listID string, userID string) (bool, error) {
var cnt int
err := r.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM list_users WHERE list_id = $1 AND user_id = $2",
listID, userID,
).Scan(&cnt)
if err != nil {
return false, fmt.Errorf("failed to check if user is in list: %w", err)
}
return cnt > 0, nil
}
func (r *ListRepo) IsUserInListByItemID(ctx context.Context, listItemID string, userID string) (bool, error) {
var cnt int
err := r.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM list_users WHERE list_id = (SELECT list_id FROM list_items WHERE id = $1) AND user_id = $2",
listItemID, userID,
).Scan(&cnt)
if err != nil {
return false, fmt.Errorf("failed to check if user is in list: %w", err)
}
return cnt > 0, nil
}
func (r *ListRepo) CreateListItem(ctx context.Context, listID string, title string) (*domain.ListItem, error) {
l := &domain.ListItem{Title: title}
err := r.db.QueryRowContext(ctx,
"INSERT INTO list_items (list_id, title) VALUES ($1, $2) RETURNING id, is_completed, created_at, modified_at",
listID, title,
).Scan(&l.ID, &l.IsCompleted, &l.CreatedAt, &l.ModifiedAt)
if err != nil {
return nil, fmt.Errorf("failed to insert list item: %w", err)
}
return l, nil
}
func (r *ListRepo) UpdateListItem(ctx context.Context, listItemID string, title string, isCompleted bool) error {
_, err := r.db.ExecContext(ctx, "UPDATE list_items SET title = $1, is_completed = $2, modified_at = NOW() WHERE id = $3",
title, isCompleted, listItemID,
)
if err != nil {
return fmt.Errorf("failed to update list item: %w", err)
}
return nil
}