diff --git a/docker/otel-config.yaml b/docker/otel-config.yaml index e0331ff..9e97d3d 100644 --- a/docker/otel-config.yaml +++ b/docker/otel-config.yaml @@ -13,3 +13,6 @@ service: traces: receivers: [otlp] exporters: [debug] + metrics: + receivers: [otlp] + exporters: [debug] diff --git a/internal/api/handler/auth.go b/internal/api/handler/auth.go index f756e56..b85a1cd 100644 --- a/internal/api/handler/auth.go +++ b/internal/api/handler/auth.go @@ -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) +} diff --git a/internal/api/handler/list.go b/internal/api/handler/list.go index 1295724..a1040ca 100644 --- a/internal/api/handler/list.go +++ b/internal/api/handler/list.go @@ -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) +} diff --git a/internal/api/request/auth.go b/internal/api/request/auth.go index 5d5f1c3..7674f5c 100644 --- a/internal/api/request/auth.go +++ b/internal/api/request/auth.go @@ -8,3 +8,7 @@ type LoginPayload struct { type RefreshPayload struct { RefreshToken string `json:"refresh_token"` } + +type LogoutPayload struct { + RefreshToken string `json:"refresh_token"` +} diff --git a/internal/api/request/list.go b/internal/api/request/list.go index 331d23a..d425b44 100644 --- a/internal/api/request/list.go +++ b/internal/api/request/list.go @@ -25,3 +25,7 @@ type UpdateListItemPayload struct { Title string `json:"title"` IsCompleted bool `json:"is_completed"` } + +type SetListItemCompletedPayload struct { + IsCompleted bool `json:"is_completed"` +} diff --git a/internal/api/router/router.go b/internal/api/router/router.go index ba0b395..7fc45d1 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -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)) diff --git a/internal/domain/auth.go b/internal/domain/auth.go index 467df9c..68754a0 100644 --- a/internal/domain/auth.go +++ b/internal/domain/auth.go @@ -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"` diff --git a/internal/domain/list.go b/internal/domain/list.go index 8434148..b8a7357 100644 --- a/internal/domain/list.go +++ b/internal/domain/list.go @@ -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 +} diff --git a/internal/repository/auth.go b/internal/repository/auth.go index 1c05973..37d50da 100644 --- a/internal/repository/auth.go +++ b/internal/repository/auth.go @@ -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 +} diff --git a/internal/repository/list.go b/internal/repository/list.go index b71b518..f64fe1f 100644 --- a/internal/repository/list.go +++ b/internal/repository/list.go @@ -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 +}