From 6eb9f22cf2658e90aea3c72b695540536baa9799 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Mon, 3 Aug 2026 15:21:30 +0200 Subject: [PATCH 01/15] fix: pass TokenPair by value --- internal/domain/auth.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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 -- 2.54.0 From 9261e07e7295868fa51055da80c0e162ff0e0a16 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 14 Aug 2026 16:45:05 +0200 Subject: [PATCH 02/15] feat: list repository now supports managing users for lists, adding and updating list items --- internal/domain/list.go | 13 ++++++++++ internal/repository/list.go | 49 +++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/internal/domain/list.go b/internal/domain/list.go index 5989e39..34d1914 100644 --- a/internal/domain/list.go +++ b/internal/domain/list.go @@ -13,8 +13,21 @@ 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 + AddListItem(ctx context.Context, listID string, title string) (*ListItem, error) + UpdateListItem(ctx context.Context, listItemID string, title string, isCompleted bool) error } type ListService struct { diff --git a/internal/repository/list.go b/internal/repository/list.go index 38e51a4..0fed900 100644 --- a/internal/repository/list.go +++ b/internal/repository/list.go @@ -57,3 +57,52 @@ 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) AddListItem(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 WHERE id = $3", + title, isCompleted, listItemID, + ) + if err != nil { + return fmt.Errorf("failed to update list item: %w", err) + } + + return nil +} -- 2.54.0 From dd8c105230a0ecba744515fbfe7749fd49174f8f Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 14 Aug 2026 16:46:13 +0200 Subject: [PATCH 03/15] fix: rename `AddListItem` => `CreateListItem` --- internal/domain/list.go | 2 +- internal/repository/list.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/domain/list.go b/internal/domain/list.go index 34d1914..904f070 100644 --- a/internal/domain/list.go +++ b/internal/domain/list.go @@ -26,7 +26,7 @@ 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 - AddListItem(ctx context.Context, listID string, title string) (*ListItem, error) + CreateListItem(ctx context.Context, listID string, title string) (*ListItem, error) UpdateListItem(ctx context.Context, listItemID string, title string, isCompleted bool) error } diff --git a/internal/repository/list.go b/internal/repository/list.go index 0fed900..7e7a7ea 100644 --- a/internal/repository/list.go +++ b/internal/repository/list.go @@ -82,7 +82,7 @@ func (r *ListRepo) RemoveUserFromList(ctx context.Context, listID string, userID return nil } -func (r *ListRepo) AddListItem(ctx context.Context, listID string, title string) (*domain.ListItem, error) { +func (r *ListRepo) CreateListItem(ctx context.Context, listID string, title string) (*domain.ListItem, error) { l := &domain.ListItem{Title: title} err := r.db.QueryRowContext(ctx, -- 2.54.0 From d6b913a76308fc87046a49098413f12cd64daea5 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 14 Aug 2026 16:56:17 +0200 Subject: [PATCH 04/15] feat: ListService now supports user management and creating/updating list items --- internal/domain/list.go | 44 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/internal/domain/list.go b/internal/domain/list.go index 904f070..99acd38 100644 --- a/internal/domain/list.go +++ b/internal/domain/list.go @@ -49,3 +49,47 @@ func (s *ListService) Create(ctx context.Context, name string, userIDs []string) return s.repo.CreateList(ctx, name, userIDs) } + +func (s *ListService) AddUserToList(ctx context.Context, listID string, userID string) error { + if listID == "" { + return errors.New("list id must not be empty") + } + if userID == "" { + return errors.New("user id must not be empty") + } + + return s.repo.AddUserToList(ctx, listID, userID) +} + +func (s *ListService) RemoveUserFromList(ctx context.Context, listID string, userID string) error { + if listID == "" { + return errors.New("list id must not be empty") + } + if userID == "" { + return errors.New("user id must not be empty") + } + + return s.repo.RemoveUserFromList(ctx, listID, userID) +} + +func (s *ListService) CreateListItem(ctx context.Context, listID string, title string) (*ListItem, error) { + if listID == "" { + return nil, errors.New("list id must not be empty") + } + if title == "" { + return nil, errors.New("list item title must not be empty") + } + + return s.repo.CreateListItem(ctx, listID, title) +} + +func (s *ListService) UpdateListItem(ctx context.Context, listItemID, title string, isCompleted bool) error { + if listItemID == "" { + return errors.New("list item id must not be empty") + } + if title == "" { + return errors.New("list item title must not be empty") + } + + return s.repo.UpdateListItem(ctx, listItemID, title, isCompleted) +} -- 2.54.0 From e0ab3bb51a133c99ada1517d8bb36f68713532e0 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 14 Aug 2026 16:56:43 +0200 Subject: [PATCH 05/15] feat: added generic json payload decoder function --- internal/api/request/json.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 internal/api/request/json.go 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 +} -- 2.54.0 From 93fd79560cfb25a05e1376e5b8bd64216627392d Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 14 Aug 2026 16:58:52 +0200 Subject: [PATCH 06/15] fix: updated handlers to use new DecodeJSON generic function --- internal/api/handler/auth.go | 4 ++-- internal/api/handler/list.go | 2 +- internal/api/handler/user.go | 2 +- internal/api/request/auth.go | 32 -------------------------------- internal/api/request/list.go | 30 ++++++++++++++++-------------- internal/api/request/user.go | 19 ------------------- 6 files changed, 20 insertions(+), 69 deletions(-) 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..677dfdf 100644 --- a/internal/api/handler/list.go +++ b/internal/api/handler/list.go @@ -32,7 +32,7 @@ func NewListHandler(listService *domain.ListService) *ListHandler { 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") 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/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 -} -- 2.54.0 From 41425bc709cd502016806e73a5552326c5d4a282 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 14 Aug 2026 17:35:49 +0200 Subject: [PATCH 07/15] feat: added WIP list modification routes --- internal/api/handler/list.go | 173 +++++++++++++++++++++++++++++++++- internal/api/router/router.go | 4 + internal/domain/list.go | 8 +- 3 files changed, 180 insertions(+), 5 deletions(-) diff --git a/internal/api/handler/list.go b/internal/api/handler/list.go index 677dfdf..179b164 100644 --- a/internal/api/handler/list.go +++ b/internal/api/handler/list.go @@ -3,6 +3,7 @@ package handler import ( "log/slog" "net/http" + "slices" "github.com/robindittmar/dttmr-api/internal/api/request" "github.com/robindittmar/dttmr-api/internal/api/response" @@ -28,7 +29,7 @@ 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() @@ -39,6 +40,18 @@ func (h *ListHandler) CreateList(w http.ResponseWriter, r *http.Request) { 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") + return + } + + // TODO: should this be in ListService? + if !slices.Contains(payload.UserIDs, authContext.UserID) { + payload.UserIDs = append(payload.UserIDs, authContext.UserID) + } + list, err := h.ListService.Create(ctx, payload.Name, payload.UserIDs) if err != nil { slog.ErrorContext(ctx, "failed to create list", slog.Any("error", err)) @@ -49,3 +62,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/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/list.go b/internal/domain/list.go index 99acd38..6c938f9 100644 --- a/internal/domain/list.go +++ b/internal/domain/list.go @@ -50,7 +50,7 @@ func (s *ListService) Create(ctx context.Context, name string, userIDs []string) return s.repo.CreateList(ctx, name, userIDs) } -func (s *ListService) AddUserToList(ctx context.Context, listID string, userID string) error { +func (s *ListService) AddUserToList(ctx context.Context, ownerID string, listID string, userID string) error { if listID == "" { return errors.New("list id must not be empty") } @@ -61,7 +61,7 @@ func (s *ListService) AddUserToList(ctx context.Context, listID string, userID s return s.repo.AddUserToList(ctx, listID, userID) } -func (s *ListService) RemoveUserFromList(ctx context.Context, listID string, userID string) error { +func (s *ListService) RemoveUserFromList(ctx context.Context, ownerID string, listID string, userID string) error { if listID == "" { return errors.New("list id must not be empty") } @@ -72,7 +72,7 @@ func (s *ListService) RemoveUserFromList(ctx context.Context, listID string, use return s.repo.RemoveUserFromList(ctx, listID, userID) } -func (s *ListService) CreateListItem(ctx context.Context, listID string, title string) (*ListItem, error) { +func (s *ListService) CreateListItem(ctx context.Context, ownerID string, listID string, title string) (*ListItem, error) { if listID == "" { return nil, errors.New("list id must not be empty") } @@ -83,7 +83,7 @@ func (s *ListService) CreateListItem(ctx context.Context, listID string, title s return s.repo.CreateListItem(ctx, listID, title) } -func (s *ListService) UpdateListItem(ctx context.Context, listItemID, title string, isCompleted bool) error { +func (s *ListService) UpdateListItem(ctx context.Context, ownerID string, listItemID string, title string, isCompleted bool) error { if listItemID == "" { return errors.New("list item id must not be empty") } -- 2.54.0 From d80a17f16a474183ae545a67e9ac9431ea1bc311 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Thu, 20 Aug 2026 15:28:51 +0200 Subject: [PATCH 08/15] feat: added "IsUserInList" to list repository --- internal/domain/list.go | 1 + internal/repository/list.go | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/internal/domain/list.go b/internal/domain/list.go index 6c938f9..c603377 100644 --- a/internal/domain/list.go +++ b/internal/domain/list.go @@ -26,6 +26,7 @@ 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) CreateListItem(ctx context.Context, listID string, title string) (*ListItem, error) UpdateListItem(ctx context.Context, listItemID string, title string, isCompleted bool) error } diff --git a/internal/repository/list.go b/internal/repository/list.go index 7e7a7ea..7433864 100644 --- a/internal/repository/list.go +++ b/internal/repository/list.go @@ -82,6 +82,20 @@ func (r *ListRepo) RemoveUserFromList(ctx context.Context, listID string, userID 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) CreateListItem(ctx context.Context, listID string, title string) (*domain.ListItem, error) { l := &domain.ListItem{Title: title} -- 2.54.0 From a5d649dbf5e2d3d84bff9bf11ed22e3f4739118d Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Thu, 20 Aug 2026 15:32:12 +0200 Subject: [PATCH 09/15] refactor: renamed "ownerID" => "authUserID" for clarity --- internal/domain/list.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/domain/list.go b/internal/domain/list.go index c603377..59f5d36 100644 --- a/internal/domain/list.go +++ b/internal/domain/list.go @@ -39,7 +39,7 @@ 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") } @@ -51,7 +51,7 @@ func (s *ListService) Create(ctx context.Context, name string, userIDs []string) return s.repo.CreateList(ctx, name, userIDs) } -func (s *ListService) AddUserToList(ctx context.Context, ownerID string, listID string, userID string) error { +func (s *ListService) AddUserToList(ctx context.Context, authUserID string, listID string, userID string) error { if listID == "" { return errors.New("list id must not be empty") } @@ -62,7 +62,7 @@ func (s *ListService) AddUserToList(ctx context.Context, ownerID string, listID return s.repo.AddUserToList(ctx, listID, userID) } -func (s *ListService) RemoveUserFromList(ctx context.Context, ownerID string, listID string, userID string) error { +func (s *ListService) RemoveUserFromList(ctx context.Context, authUserID string, listID string, userID string) error { if listID == "" { return errors.New("list id must not be empty") } @@ -73,7 +73,7 @@ func (s *ListService) RemoveUserFromList(ctx context.Context, ownerID string, li return s.repo.RemoveUserFromList(ctx, listID, userID) } -func (s *ListService) CreateListItem(ctx context.Context, ownerID string, listID string, title string) (*ListItem, error) { +func (s *ListService) CreateListItem(ctx context.Context, authUserID string, listID string, title string) (*ListItem, error) { if listID == "" { return nil, errors.New("list id must not be empty") } @@ -84,7 +84,7 @@ func (s *ListService) CreateListItem(ctx context.Context, ownerID string, listID return s.repo.CreateListItem(ctx, listID, title) } -func (s *ListService) UpdateListItem(ctx context.Context, ownerID string, listItemID string, title string, isCompleted bool) error { +func (s *ListService) UpdateListItem(ctx context.Context, authUserID string, listItemID string, title string, isCompleted bool) error { if listItemID == "" { return errors.New("list item id must not be empty") } -- 2.54.0 From abb3d154f80ce84f1c6c3d4cc254b3e609e40641 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Thu, 20 Aug 2026 15:32:54 +0200 Subject: [PATCH 10/15] fix: in "CreateList", the authenticated userid is now added in the service layer, rather than the handler layer --- internal/api/handler/list.go | 8 +------- internal/domain/list.go | 5 +++++ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/internal/api/handler/list.go b/internal/api/handler/list.go index 179b164..7354a07 100644 --- a/internal/api/handler/list.go +++ b/internal/api/handler/list.go @@ -3,7 +3,6 @@ package handler import ( "log/slog" "net/http" - "slices" "github.com/robindittmar/dttmr-api/internal/api/request" "github.com/robindittmar/dttmr-api/internal/api/response" @@ -47,12 +46,7 @@ func (h *ListHandler) CreateList(w http.ResponseWriter, r *http.Request) { return } - // TODO: should this be in ListService? - if !slices.Contains(payload.UserIDs, authContext.UserID) { - payload.UserIDs = append(payload.UserIDs, authContext.UserID) - } - - list, err := h.ListService.Create(ctx, payload.Name, payload.UserIDs) + 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") diff --git a/internal/domain/list.go b/internal/domain/list.go index 59f5d36..c3275d1 100644 --- a/internal/domain/list.go +++ b/internal/domain/list.go @@ -3,6 +3,7 @@ package domain import ( "context" "errors" + "slices" "time" ) @@ -48,6 +49,10 @@ func (s *ListService) Create(ctx context.Context, authUserID string, name string return nil, errors.New("list name must not be empty") } + if !slices.Contains(userIDs, authUserID) { + userIDs = append(userIDs, authUserID) + } + return s.repo.CreateList(ctx, name, userIDs) } -- 2.54.0 From 8caa218e0d3f0786c9924a3664a8b990997f92db Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Thu, 20 Aug 2026 15:37:02 +0200 Subject: [PATCH 11/15] fix: added error vars to list service --- internal/domain/list.go | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/internal/domain/list.go b/internal/domain/list.go index c3275d1..be1bf7c 100644 --- a/internal/domain/list.go +++ b/internal/domain/list.go @@ -7,6 +7,15 @@ import ( "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") +) + type List struct { ID string `json:"id"` Name string `json:"name"` @@ -42,11 +51,11 @@ func NewListService(r ListRepository) *ListService { 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) { @@ -58,10 +67,10 @@ func (s *ListService) Create(ctx context.Context, authUserID string, name string func (s *ListService) AddUserToList(ctx context.Context, authUserID string, listID string, userID string) error { if listID == "" { - return errors.New("list id must not be empty") + return ErrListIDEmpty } if userID == "" { - return errors.New("user id must not be empty") + return ErrUserIDEmpty } return s.repo.AddUserToList(ctx, listID, userID) @@ -69,10 +78,10 @@ func (s *ListService) AddUserToList(ctx context.Context, authUserID string, list func (s *ListService) RemoveUserFromList(ctx context.Context, authUserID string, listID string, userID string) error { if listID == "" { - return errors.New("list id must not be empty") + return ErrListIDEmpty } if userID == "" { - return errors.New("user id must not be empty") + return ErrUserIDEmpty } return s.repo.RemoveUserFromList(ctx, listID, userID) @@ -80,10 +89,10 @@ func (s *ListService) RemoveUserFromList(ctx context.Context, authUserID string, func (s *ListService) CreateListItem(ctx context.Context, authUserID string, listID string, title string) (*ListItem, error) { if listID == "" { - return nil, errors.New("list id must not be empty") + return nil, ErrListIDEmpty } if title == "" { - return nil, errors.New("list item title must not be empty") + return nil, ErrListItemTitleEmpty } return s.repo.CreateListItem(ctx, listID, title) @@ -91,10 +100,10 @@ func (s *ListService) CreateListItem(ctx context.Context, authUserID string, lis func (s *ListService) UpdateListItem(ctx context.Context, authUserID string, listItemID string, title string, isCompleted bool) error { if listItemID == "" { - return errors.New("list item id must not be empty") + return ErrListItemIDEmpty } if title == "" { - return errors.New("list item title must not be empty") + return ErrListItemTitleEmpty } return s.repo.UpdateListItem(ctx, listItemID, title, isCompleted) -- 2.54.0 From d026fa4d7cdfcb14baa398b17145540f3f862ecc Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Thu, 20 Aug 2026 15:42:25 +0200 Subject: [PATCH 12/15] feat: added "IsUserInListByListItem" to list repository --- internal/domain/list.go | 1 + internal/repository/list.go | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/internal/domain/list.go b/internal/domain/list.go index be1bf7c..34538ba 100644 --- a/internal/domain/list.go +++ b/internal/domain/list.go @@ -37,6 +37,7 @@ type ListRepository interface { 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) + IsUserInListByListItem(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 } diff --git a/internal/repository/list.go b/internal/repository/list.go index 7433864..1d5faa6 100644 --- a/internal/repository/list.go +++ b/internal/repository/list.go @@ -96,6 +96,20 @@ func (r *ListRepo) IsUserInList(ctx context.Context, listID string, userID strin return cnt > 0, nil } +func (r *ListRepo) IsUserInListByListItem(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} -- 2.54.0 From 36b3f1a483ba84d093417abce82b91160dbe1a89 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Thu, 20 Aug 2026 15:43:33 +0200 Subject: [PATCH 13/15] refactor: renamed "IsUserInListByListItem" to "IsUserInListByItemID" --- internal/domain/list.go | 2 +- internal/repository/list.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/domain/list.go b/internal/domain/list.go index 34538ba..9e353df 100644 --- a/internal/domain/list.go +++ b/internal/domain/list.go @@ -37,7 +37,7 @@ type ListRepository interface { 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) - IsUserInListByListItem(ctx context.Context, listItemID 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 } diff --git a/internal/repository/list.go b/internal/repository/list.go index 1d5faa6..e15af0c 100644 --- a/internal/repository/list.go +++ b/internal/repository/list.go @@ -96,7 +96,7 @@ func (r *ListRepo) IsUserInList(ctx context.Context, listID string, userID strin return cnt > 0, nil } -func (r *ListRepo) IsUserInListByListItem(ctx context.Context, listItemID string, userID string) (bool, error) { +func (r *ListRepo) IsUserInListByItemID(ctx context.Context, listItemID string, userID string) (bool, error) { var cnt int err := r.db.QueryRowContext(ctx, -- 2.54.0 From 8c0b46f8e4c3ff415726cd57b1fa765008d37bd4 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Thu, 20 Aug 2026 15:43:59 +0200 Subject: [PATCH 14/15] feat: list functions now validate user has access to list --- internal/domain/list.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/internal/domain/list.go b/internal/domain/list.go index 9e353df..8434148 100644 --- a/internal/domain/list.go +++ b/internal/domain/list.go @@ -14,6 +14,7 @@ var ( 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 { @@ -74,6 +75,14 @@ func (s *ListService) AddUserToList(ctx context.Context, authUserID string, list 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) } @@ -85,6 +94,14 @@ func (s *ListService) RemoveUserFromList(ctx context.Context, authUserID string, 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) } @@ -96,6 +113,14 @@ func (s *ListService) CreateListItem(ctx context.Context, authUserID string, lis 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) } @@ -107,5 +132,13 @@ func (s *ListService) UpdateListItem(ctx context.Context, authUserID string, lis 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) } -- 2.54.0 From cba2507893c4cd4a09f567fa7408a301315e708e Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Thu, 20 Aug 2026 15:48:45 +0200 Subject: [PATCH 15/15] fix: update modified_at when updatig list item --- internal/repository/list.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/repository/list.go b/internal/repository/list.go index e15af0c..b71b518 100644 --- a/internal/repository/list.go +++ b/internal/repository/list.go @@ -125,7 +125,7 @@ func (r *ListRepo) CreateListItem(ctx context.Context, listID string, title stri } 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 WHERE id = $3", + _, 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 { -- 2.54.0