From 017ee61c998d4465caa262874fff6fb527133622 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 21 Aug 2026 15:26:12 +0200 Subject: [PATCH 1/3] fix: added unique index to list_users table --- compose.yaml | 2 ++ internal/database/migrations/000002_create_lists.up.sql | 1 + 2 files changed, 3 insertions(+) diff --git a/compose.yaml b/compose.yaml index db4d8fb..4568bbf 100644 --- a/compose.yaml +++ b/compose.yaml @@ -5,6 +5,8 @@ services: restart: unless-stopped env_file: - .env + ports: + - "15432:5432" volumes: - /srv/dttmr-api/postgresql:/var/lib/postgresql/data healthcheck: diff --git a/internal/database/migrations/000002_create_lists.up.sql b/internal/database/migrations/000002_create_lists.up.sql index ed8ecec..192494f 100644 --- a/internal/database/migrations/000002_create_lists.up.sql +++ b/internal/database/migrations/000002_create_lists.up.sql @@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS list_users ( PRIMARY KEY (list_id, user_id) ); +CREATE UNIQUE INDEX idx_list_users_list_id_user_id ON list_users(list_id, user_id); CREATE INDEX idx_list_users_user_id ON list_users(user_id); CREATE TABLE IF NOT EXISTS list_items ( From 441c47163c34ff8c19353bbc35631c5f87e124d0 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 21 Aug 2026 15:31:29 +0200 Subject: [PATCH 2/3] fix: removed /api/v1 prefix from each route, since it's in the basePath --- internal/api/handler/auth.go | 8 ++++---- internal/api/handler/default.go | 2 +- internal/api/handler/list.go | 16 ++++++++-------- internal/api/handler/user.go | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/internal/api/handler/auth.go b/internal/api/handler/auth.go index b85a1cd..c4c5a5f 100644 --- a/internal/api/handler/auth.go +++ b/internal/api/handler/auth.go @@ -28,7 +28,7 @@ func NewAuthHandler(authService *domain.AuthService) *AuthHandler { // @Success 200 {object} domain.TokenPair // @Error 400 {object} response.ErrorResponse "failed to decode request body" // @Error 500 {object} response.ErrorResponse "failed to login" -// @Router /api/v1/login [post] +// @Router /login [post] func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -60,7 +60,7 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} domain.TokenPair // @Error 400 {object} response.ErrorResponse "failed to decode request body" // @Error 500 {object} response.ErrorResponse "failed to refresh token" -// @Router /api/v1/login/refresh [post] +// @Router /login/refresh [post] func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -92,7 +92,7 @@ func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} nil // @Error 400 {object} response.ErrorResponse "failed to decode request body" // @Error 500 {object} response.ErrorResponse "failed to logout" -// @Router /api/v1/logout [post] +// @Router /logout [post] func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -123,7 +123,7 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} nil // @Error 401 {object} response.ErrorResponse "failed to get auth context" // @Error 500 {object} response.ErrorResponse "failed to logout" -// @Router /api/v1/logout/all [post] +// @Router /logout/all [post] func (h *AuthHandler) LogoutAllDevices(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/internal/api/handler/default.go b/internal/api/handler/default.go index fd19134..2ae7f1b 100644 --- a/internal/api/handler/default.go +++ b/internal/api/handler/default.go @@ -25,7 +25,7 @@ type apiResponse struct { // @Accept json // @Produce json // @Success 200 {object} apiResponse -// @Router /api/v1/ [get] +// @Router / [get] func DefaultHandler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/internal/api/handler/list.go b/internal/api/handler/list.go index d0fbb33..d5f970d 100644 --- a/internal/api/handler/list.go +++ b/internal/api/handler/list.go @@ -28,7 +28,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/lists [post] +// @Router /lists [post] func (h *ListHandler) CreateList(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -68,7 +68,7 @@ func (h *ListHandler) CreateList(w http.ResponseWriter, r *http.Request) { // @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] +// @Router /lists [get] func (h *ListHandler) GetLists(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -100,7 +100,7 @@ func (h *ListHandler) GetLists(w http.ResponseWriter, r *http.Request) { // @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] +// @Router /lists/user [post] func (h *ListHandler) AddUserToList(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -140,7 +140,7 @@ func (h *ListHandler) AddUserToList(w http.ResponseWriter, r *http.Request) { // @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] +// @Router /lists/user [delete] func (h *ListHandler) RemoveUserFromList(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -180,7 +180,7 @@ func (h *ListHandler) RemoveUserFromList(w http.ResponseWriter, r *http.Request) // @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] +// @Router /lists/item [post] func (h *ListHandler) CreateListItem(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -220,7 +220,7 @@ func (h *ListHandler) CreateListItem(w http.ResponseWriter, r *http.Request) { // @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] +// @Router /lists/item [put] func (h *ListHandler) UpdateListItem(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -261,7 +261,7 @@ func (h *ListHandler) UpdateListItem(w http.ResponseWriter, r *http.Request) { // @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/items/{id} [post] +// @Router /lists/items/{id} [post] func (h *ListHandler) SetListItemCompleted(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -307,7 +307,7 @@ func (h *ListHandler) SetListItemCompleted(w http.ResponseWriter, r *http.Reques // @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] +// @Router /lists/{id} [get] func (h *ListHandler) GetListItems(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/internal/api/handler/user.go b/internal/api/handler/user.go index 57b7185..7a3e92d 100644 --- a/internal/api/handler/user.go +++ b/internal/api/handler/user.go @@ -28,7 +28,7 @@ func NewUserHandler(userService *domain.UserService) *UserHandler { // @Success 201 {object} domain.User // @Error 400 {object} response.ErrorResponse "failed to decode request body" // @Error 500 {object} response.ErrorResponse "failed to create user" -// @Router /api/v1/users [post] +// @Router /users [post] func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { ctx := r.Context() From e91e80e725c0c73f21653a6f441646a884dfae7a Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 21 Aug 2026 16:21:33 +0200 Subject: [PATCH 3/3] fix: minor improvements, wrong argument order, ambigious columns in sql, log messages, etc --- internal/api/handler/list.go | 14 +++++++------- internal/api/response/json.go | 5 +++++ internal/domain/list.go | 2 +- internal/repository/list.go | 11 +++++++++-- 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/internal/api/handler/list.go b/internal/api/handler/list.go index d5f970d..ef3d354 100644 --- a/internal/api/handler/list.go +++ b/internal/api/handler/list.go @@ -126,7 +126,7 @@ func (h *ListHandler) AddUserToList(w http.ResponseWriter, r *http.Request) { } 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) + response.Status(ctx, w, http.StatusNoContent) } // RemoveUserFromList handles the removal of a user association to a list @@ -166,7 +166,7 @@ func (h *ListHandler) RemoveUserFromList(w http.ResponseWriter, r *http.Request) } 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) + response.Status(ctx, w, http.StatusNoContent) } // CreateListItem handles creation of a new list item on a given list @@ -238,14 +238,14 @@ func (h *ListHandler) UpdateListItem(w http.ResponseWriter, r *http.Request) { return } - err = h.ListService.UpdateListItem(ctx, payload.ListItemID, authContext.UserID, payload.Title, payload.IsCompleted) + err = h.ListService.UpdateListItem(ctx, authContext.UserID, payload.ListItemID, 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) + response.Status(ctx, w, http.StatusNoContent) } // SetListItemCompleted handles updating "is_completed" of a list item @@ -286,14 +286,14 @@ func (h *ListHandler) SetListItemCompleted(w http.ResponseWriter, r *http.Reques return } - err = h.ListService.SetListItemCompleted(ctx, listItemID, authContext.UserID, payload.IsCompleted) + err = h.ListService.SetListItemCompleted(ctx, authContext.UserID, listItemID, 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) + response.Status(ctx, w, http.StatusNoContent) } // GetListItems handles return all list items of a list @@ -327,7 +327,7 @@ func (h *ListHandler) GetListItems(w http.ResponseWriter, r *http.Request) { 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)) + slog.ErrorContext(ctx, "failed to read list items", slog.Any("error", err)) response.Error(ctx, w, http.StatusInternalServerError, "failed to read list items") return } diff --git a/internal/api/response/json.go b/internal/api/response/json.go index d3f60ba..a189d4d 100644 --- a/internal/api/response/json.go +++ b/internal/api/response/json.go @@ -7,6 +7,11 @@ import ( "net/http" ) +// TODO: Move to own file +func Status(ctx context.Context, w http.ResponseWriter, status int) { + w.WriteHeader(status) +} + func JSON(ctx context.Context, w http.ResponseWriter, status int, data any) { payload, err := json.Marshal(data) if err != nil { diff --git a/internal/domain/list.go b/internal/domain/list.go index 9f5982f..ba4d21f 100644 --- a/internal/domain/list.go +++ b/internal/domain/list.go @@ -127,7 +127,7 @@ func (s *ListService) UpdateListItem(ctx context.Context, authUserID string, lis return ErrListItemTitleEmpty } - if err := s.userAllowedToAccessList(ctx, authUserID, listItemID); err != nil { + if err := s.userAllowedToAccessListItem(ctx, authUserID, listItemID); err != nil { return err } diff --git a/internal/repository/list.go b/internal/repository/list.go index 76f5be5..d793ed6 100644 --- a/internal/repository/list.go +++ b/internal/repository/list.go @@ -3,6 +3,7 @@ package repository import ( "context" "database/sql" + "errors" "fmt" "log/slog" @@ -60,11 +61,14 @@ func (r *ListRepo) CreateList(ctx context.Context, name string, userIDs []string func (r *ListRepo) GetLists(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", + "SELECT id, name, lists.created_at, modified_at FROM lists INNER JOIN list_users ON lists.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) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("failed to get lists: %w", err) } defer rows.Close() @@ -177,6 +181,9 @@ func (r *ListRepo) GetListItems(ctx context.Context, listID string) ([]domain.Li listID, ) if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } return nil, fmt.Errorf("failed to get list items: %w", err) } defer rows.Close()