diff --git a/internal/api/handler/auth.go b/internal/api/handler/auth.go index efdab88..f756e56 100644 --- a/internal/api/handler/auth.go +++ b/internal/api/handler/auth.go @@ -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") diff --git a/internal/api/handler/list.go b/internal/api/handler/list.go index 2adbfc1..7354a07 100644 --- a/internal/api/handler/list.go +++ b/internal/api/handler/list.go @@ -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) +} diff --git a/internal/api/handler/user.go b/internal/api/handler/user.go index 38753f7..16b16f5 100644 --- a/internal/api/handler/user.go +++ b/internal/api/handler/user.go @@ -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") diff --git a/internal/api/request/auth.go b/internal/api/request/auth.go index e69fe74..5d5f1c3 100644 --- a/internal/api/request/auth.go +++ b/internal/api/request/auth.go @@ -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 -} diff --git a/internal/api/request/json.go b/internal/api/request/json.go new file mode 100644 index 0000000..b9140bd --- /dev/null +++ b/internal/api/request/json.go @@ -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 +} diff --git a/internal/api/request/list.go b/internal/api/request/list.go index f9c88e3..331d23a 100644 --- a/internal/api/request/list.go +++ b/internal/api/request/list.go @@ -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"` } diff --git a/internal/api/request/user.go b/internal/api/request/user.go index 570a3ac..bca21b0 100644 --- a/internal/api/request/user.go +++ b/internal/api/request/user.go @@ -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 -} diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 2bd3141..ba0b395 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -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)) diff --git a/internal/domain/auth.go b/internal/domain/auth.go index 75e89e8..467df9c 100644 --- a/internal/domain/auth.go +++ b/internal/domain/auth.go @@ -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 diff --git a/internal/domain/list.go b/internal/domain/list.go index 5989e39..8434148 100644 --- a/internal/domain/list.go +++ b/internal/domain/list.go @@ -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) +} diff --git a/internal/repository/list.go b/internal/repository/list.go index 38e51a4..b71b518 100644 --- a/internal/repository/list.go +++ b/internal/repository/list.go @@ -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 +}