Merge pull request 'Added GET /lists, POST /list/items/{id} and GET /list/{id}' (#16) from dev into main

This commit was merged in pull request #16.
This commit is contained in:
2026-08-21 14:49:20 +02:00
10 changed files with 374 additions and 23 deletions
+3
View File
@@ -13,3 +13,6 @@ service:
traces:
receivers: [otlp]
exporters: [debug]
metrics:
receivers: [otlp]
exporters: [debug]
+63 -1
View File
@@ -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/refresh [post]
// @Router /api/v1/login/refresh [post]
func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -81,3 +81,65 @@ func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
response.JSON(ctx, w, http.StatusOK, tokens)
}
// Logout handles logging out a user
//
// @Summary Logout route
// @Description Logout current user
// @Tags Authorization
// @Accept json
// @Produce json
// @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]
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
payload, err := request.DecodeJSON[request.LogoutPayload](r)
if err != nil {
slog.ErrorContext(ctx, "failed to decode logout payload", slog.Any("error", err))
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request body")
return
}
err = h.AuthService.Logout(ctx, payload.RefreshToken)
if err != nil {
slog.ErrorContext(ctx, "failed to logout", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to logout")
return
}
response.JSON(ctx, w, http.StatusOK, nil)
}
// LogoutAllDevices handles logging out a user on all devices
//
// @Summary Logout all route
// @Description Logout user from all devices (revokes all refresh tokens)
// @Tags Authorization
// @Accept json
// @Produce json
// @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]
func (h *AuthHandler) LogoutAllDevices(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
authContext, err := domain.GetAuthContext(ctx)
if err != nil {
slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err))
response.Error(ctx, w, http.StatusUnauthorized, "failed to get auth context")
return
}
err = h.AuthService.LogoutAllDevices(ctx, authContext.UserID)
if err != nil {
slog.ErrorContext(ctx, "failed to logout", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to logout")
return
}
response.JSON(ctx, w, http.StatusOK, nil)
}
+121 -1
View File
@@ -57,6 +57,38 @@ func (h *ListHandler) CreateList(w http.ResponseWriter, r *http.Request) {
response.JSON(ctx, w, http.StatusCreated, list)
}
// GetLists handles fetching lists for the current user
//
// @Summary Returns all lists of the user
// @Description Retrieve all lists the user is a part of
// @Tags List
// @Accept json
// @Produce json
// @Success 200 {object} []domain.List
// @Error 400 {object} response.ErrorResponse "failed to decode request url"
// @Error 401 {object} response.ErrorResponse "not authorized"
// @Error 500 {object} response.ErrorResponse "failed to read lists"
// @Router /api/v1/lists [get]
func (h *ListHandler) GetLists(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
authContext, err := domain.GetAuthContext(ctx)
if err != nil {
slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err))
response.Error(ctx, w, http.StatusUnauthorized, "not authorized")
return
}
lists, err := h.ListService.GetLists(ctx, authContext.UserID)
if err != nil {
slog.ErrorContext(ctx, "failed to set list item completed", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to read list items")
return
}
response.JSON(ctx, w, http.StatusOK, lists)
}
// AddUserToList handles the user association to a list
//
// @Summary Add a user to the given list
@@ -186,6 +218,7 @@ func (h *ListHandler) CreateListItem(w http.ResponseWriter, r *http.Request) {
// @Param payload body request.UpdateListItemPayload true "Update list item payload"
// @Success 204 {object} nil
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 401 {object} response.ErrorResponse "not authorized"
// @Error 500 {object} response.ErrorResponse "failed to update list item"
// @Router /api/v1/lists/item [put]
func (h *ListHandler) UpdateListItem(w http.ResponseWriter, r *http.Request) {
@@ -201,7 +234,7 @@ func (h *ListHandler) UpdateListItem(w http.ResponseWriter, r *http.Request) {
authContext, err := domain.GetAuthContext(ctx)
if err != nil {
slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to update list item")
response.Error(ctx, w, http.StatusUnauthorized, "not authorized")
return
}
@@ -214,3 +247,90 @@ func (h *ListHandler) UpdateListItem(w http.ResponseWriter, r *http.Request) {
response.JSON(ctx, w, http.StatusNoContent, nil)
}
// SetListItemCompleted handles updating "is_completed" of a list item
//
// @Summary Updates "is_completed" of list item
// @Description Update an existing list item, setting the "is_completed" field
// @Tags List
// @Accept json
// @Produce json
// @Param payload body request.SetListItemCompletedPayload true "Update list item is completed payload"
// @Success 204 {object} nil
// @Error 400 {object} response.ErrorResponse "failed to decode request url"
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 401 {object} response.ErrorResponse "not authorized"
// @Error 500 {object} response.ErrorResponse "failed to update list item"
// @Router /api/v1/lists/item/{id} [post]
func (h *ListHandler) SetListItemCompleted(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
listItemID := r.PathValue("id")
if listItemID == "" {
slog.ErrorContext(ctx, "failed to read list item id from path")
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request url")
return
}
payload, err := request.DecodeJSON[request.SetListItemCompletedPayload](r)
if err != nil {
slog.ErrorContext(ctx, "failed to decode set list item completed payload", slog.Any("error", err))
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request body")
return
}
authContext, err := domain.GetAuthContext(ctx)
if err != nil {
slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err))
response.Error(ctx, w, http.StatusUnauthorized, "not authorized")
return
}
err = h.ListService.SetListItemCompleted(ctx, listItemID, authContext.UserID, payload.IsCompleted)
if err != nil {
slog.ErrorContext(ctx, "failed to set list item completed", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to set list item completed")
return
}
response.JSON(ctx, w, http.StatusNoContent, nil)
}
// GetListItems handles return all list items of a list
//
// @Summary Returns all items from a list
// @Description Retrieve all list items of a list
// @Tags List
// @Accept json
// @Produce json
// @Success 200 {object} []domain.ListItem
// @Error 400 {object} response.ErrorResponse "failed to decode request url"
// @Error 401 {object} response.ErrorResponse "not authorized"
// @Error 500 {object} response.ErrorResponse "failed to read list items"
// @Router /api/v1/lists/{id} [get]
func (h *ListHandler) GetListItems(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
listID := r.PathValue("id")
if listID == "" {
slog.ErrorContext(ctx, "failed to read list id from path")
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request url")
return
}
authContext, err := domain.GetAuthContext(ctx)
if err != nil {
slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err))
response.Error(ctx, w, http.StatusUnauthorized, "not authorized")
return
}
items, err := h.ListService.GetListItems(ctx, authContext.UserID, listID)
if err != nil {
slog.ErrorContext(ctx, "failed to set list item completed", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to read list items")
return
}
response.JSON(ctx, w, http.StatusOK, items)
}
+4
View File
@@ -8,3 +8,7 @@ type LoginPayload struct {
type RefreshPayload struct {
RefreshToken string `json:"refresh_token"`
}
type LogoutPayload struct {
RefreshToken string `json:"refresh_token"`
}
+4
View File
@@ -25,3 +25,7 @@ type UpdateListItemPayload struct {
Title string `json:"title"`
IsCompleted bool `json:"is_completed"`
}
type SetListItemCompletedPayload struct {
IsCompleted bool `json:"is_completed"`
}
+7 -1
View File
@@ -33,16 +33,22 @@ func NewMux(cfg Config) http.Handler {
apiMux := http.NewServeMux()
apiMux.HandleFunc("/", handler.DefaultHandler)
apiMux.HandleFunc("GET /health", handler.HealthHandler)
apiMux.HandleFunc("POST /login", authHandler.Login)
apiMux.HandleFunc("POST /auth/refresh", authHandler.Refresh)
apiMux.HandleFunc("POST /login/refresh", authHandler.Refresh)
apiMux.HandleFunc("POST /logout", authHandler.Logout)
apiMux.HandleFunc("POST /logout/all", authHandler.LogoutAllDevices)
apiMux.Handle("POST /users", protected(userHandler.CreateUser))
apiMux.Handle("POST /lists", protected(listHandler.CreateList))
apiMux.Handle("GET /lists", protected(listHandler.GetLists))
apiMux.Handle("POST /lists/user", protected(listHandler.AddUserToList))
apiMux.Handle("DELETE /lists/user", protected(listHandler.RemoveUserFromList))
apiMux.Handle("POST /lists/item", protected(listHandler.CreateListItem))
apiMux.Handle("PUT /lists/item", protected(listHandler.UpdateListItem))
apiMux.Handle("POST /list/items/{id}", protected(listHandler.SetListItemCompleted))
apiMux.Handle("GET /list/{id}", protected(listHandler.GetListItems))
mux := http.NewServeMux()
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", apiMux))
+12
View File
@@ -19,6 +19,8 @@ type AuthRepository interface {
GetUserByEmail(ctx context.Context, email string) (*AuthUser, error)
StoreRefreshToken(ctx context.Context, userID string, tokenHash string, expiresAt time.Time) error
ConsumeRefreshToken(ctx context.Context, tokenHash string) (string, error)
RevokeRefreshToken(ctx context.Context, tokenHash string) error
RevokeRefreshTokens(ctx context.Context, userID string) error
}
type AuthService struct {
@@ -102,6 +104,16 @@ func (s *AuthService) Refresh(ctx context.Context, refreshToken string) (TokenPa
return s.issueTokens(ctx, authUser)
}
func (s *AuthService) Logout(ctx context.Context, refreshToken string) error {
tokenHash := hashToken(refreshToken)
return s.repo.RevokeRefreshToken(ctx, tokenHash)
}
func (s *AuthService) LogoutAllDevices(ctx context.Context, userID string) error {
return s.repo.RevokeRefreshTokens(ctx, userID)
}
type JWTClaims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
+81 -20
View File
@@ -3,6 +3,7 @@ package domain
import (
"context"
"errors"
"log/slog"
"slices"
"time"
)
@@ -11,7 +12,6 @@ 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")
@@ -35,12 +35,15 @@ type ListItem struct {
type ListRepository interface {
CreateList(ctx context.Context, name string, userIDs []string) (*List, error)
GetListsByUserID(ctx context.Context, userID string) ([]domain.List, error)
AddUserToList(ctx context.Context, listID string, userID string) error
RemoveUserFromList(ctx context.Context, listID string, userID string) error
IsUserInList(ctx context.Context, listID string, userID string) (bool, error)
IsUserInListByItemID(ctx context.Context, listItemID string, userID string) (bool, error)
CreateListItem(ctx context.Context, listID string, title string) (*ListItem, error)
UpdateListItem(ctx context.Context, listItemID string, title string, isCompleted bool) error
SetListItemCompleted(ctx context.Context, listItemID string, isCompleted bool) error
GetListItems(ctx context.Context, listID string) ([]ListItem, error)
}
type ListService struct {
@@ -52,10 +55,6 @@ 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, ErrUserIDsEmpty
}
if name == "" {
return nil, ErrListNameEmpty
}
@@ -67,6 +66,10 @@ func (s *ListService) Create(ctx context.Context, authUserID string, name string
return s.repo.CreateList(ctx, name, userIDs)
}
func (s *ListService) GetLists(ctx context.Context, authUserID string) ([]List, error) {
return s.repo.GetListsByUserID(ctx, authUserID)
}
func (s *ListService) AddUserToList(ctx context.Context, authUserID string, listID string, userID string) error {
if listID == "" {
return ErrListIDEmpty
@@ -94,13 +97,9 @@ func (s *ListService) RemoveUserFromList(ctx context.Context, authUserID string,
return ErrUserIDEmpty
}
inList, err := s.repo.IsUserInList(ctx, listID, authUserID)
if err != nil {
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
return err
}
if !inList {
return ErrUserNotInList
}
return s.repo.RemoveUserFromList(ctx, listID, userID)
}
@@ -113,13 +112,9 @@ func (s *ListService) CreateListItem(ctx context.Context, authUserID string, lis
return nil, ErrListItemTitleEmpty
}
inList, err := s.repo.IsUserInList(ctx, listID, authUserID)
if err != nil {
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
return nil, err
}
if !inList {
return nil, ErrUserNotInList
}
return s.repo.CreateListItem(ctx, listID, title)
}
@@ -132,13 +127,79 @@ func (s *ListService) UpdateListItem(ctx context.Context, authUserID string, lis
return ErrListItemTitleEmpty
}
inList, err := s.repo.IsUserInListByItemID(ctx, listItemID, authUserID)
if err != nil {
if err := s.userAllowedToAccessList(ctx, authUserID, listItemID); err != nil {
return err
}
if !inList {
return ErrUserNotInList
}
return s.repo.UpdateListItem(ctx, listItemID, title, isCompleted)
}
func (s *ListService) SetListItemCompleted(ctx context.Context, authUserID string, listItemID string, isCompleted bool) error {
if listItemID == "" {
return ErrListItemIDEmpty
}
if err := s.userAllowedToAccessListItem(ctx, authUserID, listItemID); err != nil {
return err
}
return s.repo.SetListItemCompleted(ctx, listItemID, isCompleted)
}
func (s *ListService) GetListItems(ctx context.Context, authUserID string, listID string) ([]ListItem, error) {
if listID == "" {
return nil, ErrListIDEmpty
}
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
return nil, err
}
return s.repo.GetListItems(ctx, listID)
}
func (s *ListService) userAllowedToAccessList(ctx context.Context, authUserID string, listID string) error {
inList, err := s.repo.IsUserInList(ctx, listID, authUserID)
if err != nil {
slog.ErrorContext(ctx,
"failed to check if user is in list",
slog.String("user_id", authUserID),
slog.String("list_id", listID),
slog.Any("error", err),
)
return err
}
if !inList {
slog.WarnContext(ctx,
"user tried to access list without permission",
slog.String("user_id", authUserID),
slog.String("list_id", listID),
)
return ErrUserNotInList
}
return nil
}
func (s *ListService) userAllowedToAccessListItem(ctx context.Context, authUserID string, listItemID string) error {
inList, err := s.repo.IsUserInListByItemID(ctx, listItemID, authUserID)
if err != nil {
slog.ErrorContext(ctx,
"failed to check if user is in list",
slog.String("user_id", authUserID),
slog.String("list_item_id", listItemID),
slog.Any("error", err),
)
return err
}
if !inList {
slog.WarnContext(ctx,
"user tried to access list item without permission",
slog.String("user_id", authUserID),
slog.String("list_item_id", listItemID),
)
return ErrUserNotInList
}
return nil
}
+19
View File
@@ -57,6 +57,7 @@ func (r *AuthRepo) StoreRefreshToken(ctx context.Context, userID string, tokenHa
return nil
}
func (r *AuthRepo) ConsumeRefreshToken(ctx context.Context, tokenHash string) (string, error) {
var userID string
err := r.db.QueryRowContext(ctx,
@@ -72,3 +73,21 @@ func (r *AuthRepo) ConsumeRefreshToken(ctx context.Context, tokenHash string) (s
return userID, nil
}
func (r *AuthRepo) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
_, err := r.db.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE token_hash = $1", tokenHash)
if err != nil {
return fmt.Errorf("failed to revoke refresh token: %w", err)
}
return nil
}
func (r *AuthRepo) RevokeRefreshTokens(ctx context.Context, userID string) error {
_, err := r.db.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE user_id = $1", userID)
if err != nil {
return fmt.Errorf("failed to revoke refresh tokens: %w", err)
}
return nil
}
+60
View File
@@ -58,6 +58,31 @@ func (r *ListRepo) CreateList(ctx context.Context, name string, userIDs []string
return list, nil
}
func (r *ListRepo) GetListsByUserID(ctx context.Context, userID string) ([]domain.List, error) {
rows, err := r.db.QueryContext(ctx,
"SELECT id, name, created_at, modified_at FROM lists JOIN list_users ON list.id=list_users.list_id WHERE list_users.user_id = $1",
userID,
)
if err != nil {
return nil, fmt.Errorf("failed to get list items: %w", err)
}
defer rows.Close()
var lists []domain.List
for rows.Next() {
var l domain.List
err = rows.Scan(&l.ID, &l.Name, &l.CreatedAt, &l.ModifiedAt)
if err != nil {
return nil, err
}
lists = append(lists, l)
}
return lists, nil
}
func (r *ListRepo) AddUserToList(ctx context.Context, listID string, userID string) error {
_, err := r.db.ExecContext(ctx,
"INSERT INTO list_users (list_id, user_id) VALUES ($1, $2)",
@@ -134,3 +159,38 @@ func (r *ListRepo) UpdateListItem(ctx context.Context, listItemID string, title
return nil
}
func (r *ListRepo) SetListItemCompleted(ctx context.Context, listItemID string, isCompleted bool) error {
_, err := r.db.ExecContext(ctx, "UPDATE list_items SET is_completed = $1, modified_at = NOW() WHERE id = $2",
isCompleted, listItemID,
)
if err != nil {
return fmt.Errorf("failed to complete list item: %w", err)
}
return nil
}
func (r *ListRepo) GetListItems(ctx context.Context, listID string) ([]domain.ListItem, error) {
rows, err := r.db.QueryContext(ctx,
"SELECT id, title, is_completed, created_at, modified_at FROM list_items WHERE list_id = $1",
listID,
)
if err != nil {
return nil, fmt.Errorf("failed to get list items: %w", err)
}
defer rows.Close()
var items []domain.ListItem
for rows.Next() {
var l domain.ListItem
err = rows.Scan(&l.ID, &l.Title, &l.IsCompleted, &l.CreatedAt, &l.ModifiedAt)
if err != nil {
return nil, err
}
items = append(items, l)
}
return items, nil
}