diff --git a/internal/app/router.go b/internal/app/router.go index 612efd1..f7047ab 100644 --- a/internal/app/router.go +++ b/internal/app/router.go @@ -26,5 +26,9 @@ func NewRouter(deps Dependencies) http.Handler { router.HandleFunc("GET /api/v1/user/repositories/{repo_id}", middleware.Authentication(deps.RepositoryHandler.FetchParticularRepoDetails, deps.AppCfg)) router.HandleFunc("GET /api/v1/user/repositories/contributions/recent/{repo_id}", middleware.Authentication(deps.RepositoryHandler.FetchUserContributionsInRepo, deps.AppCfg)) router.HandleFunc("GET /api/v1/user/repositories/languages/{repo_id}", middleware.Authentication(deps.RepositoryHandler.FetchLanguagePercentInRepo, deps.AppCfg)) + + router.HandleFunc("GET /api/v1/leaderboard", middleware.Authentication(deps.UserHandler.ListUserRanks, deps.AppCfg)) + router.HandleFunc("GET /api/v1/user/leaderboard", middleware.Authentication(deps.UserHandler.GetCurrentUserRank, deps.AppCfg)) + return middleware.CorsMiddleware(router, deps.AppCfg) } diff --git a/internal/app/user/domain.go b/internal/app/user/domain.go index 471f0b1..cf0e527 100644 --- a/internal/app/user/domain.go +++ b/internal/app/user/domain.go @@ -45,3 +45,11 @@ type Transaction struct { CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } + +type LeaderboardUser struct { + Id int `db:"id"` + GithubUsername string `db:"github_username"` + AvatarUrl string `db:"avatar_url"` + CurrentBalance int `db:"current_balance"` + Rank int `db:"rank"` +} diff --git a/internal/app/user/handler.go b/internal/app/user/handler.go index 00bcd51..e1000ad 100644 --- a/internal/app/user/handler.go +++ b/internal/app/user/handler.go @@ -6,6 +6,7 @@ import ( "net/http" "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/middleware" "github.com/joshsoftware/code-curiosity-2025/internal/pkg/response" ) @@ -15,6 +16,8 @@ type handler struct { type Handler interface { UpdateUserEmail(w http.ResponseWriter, r *http.Request) + ListUserRanks(w http.ResponseWriter, r *http.Request) + GetCurrentUserRank(w http.ResponseWriter, r *http.Request) } func NewHandler(userService Service) Handler { @@ -44,3 +47,41 @@ func (h *handler) UpdateUserEmail(w http.ResponseWriter, r *http.Request) { response.WriteJson(w, http.StatusOK, "email updated successfully", nil) } + +func (h *handler) ListUserRanks(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + leaderboard, err := h.userService.GetAllUsersRank(ctx) + if err != nil { + slog.Error("failed to get all users rank", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "leaderboard fetched successfully", leaderboard) +} + +func (h *handler) GetCurrentUserRank(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + userIdValue := ctx.Value(middleware.UserIdKey) + + userId, ok := userIdValue.(int) + if !ok { + slog.Error("error obtaining user id from context") + status, errorMessage := apperrors.MapError(apperrors.ErrContextValue) + response.WriteJson(w, status, errorMessage, nil) + return + } + + currentUserRank, err := h.userService.GetCurrentUserRank(ctx, userId) + if err != nil { + slog.Error("failed to get current user rank", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "current user rank fetched successfully", currentUserRank) +} diff --git a/internal/app/user/service.go b/internal/app/user/service.go index 6dad743..24a0c2d 100644 --- a/internal/app/user/service.go +++ b/internal/app/user/service.go @@ -19,6 +19,8 @@ type Service interface { CreateUser(ctx context.Context, userInfo CreateUserRequestBody) (User, error) UpdateUserEmail(ctx context.Context, email string) error UpdateUserCurrentBalance(ctx context.Context, transaction Transaction) error + GetAllUsersRank(ctx context.Context) ([]LeaderboardUser, error) + GetCurrentUserRank(ctx context.Context, userId int) (LeaderboardUser, error) } func NewService(userRepository repository.UserRepository) Service { @@ -98,3 +100,28 @@ func (s *service) UpdateUserCurrentBalance(ctx context.Context, transaction Tran return nil } + +func (s *service) GetAllUsersRank(ctx context.Context) ([]LeaderboardUser, error) { + userRanks, err := s.userRepository.GetAllUsersRank(ctx, nil) + if err != nil { + slog.Error("error obtaining all users rank", "error", err) + return nil, err + } + + Leaderboard := make([]LeaderboardUser, len(userRanks)) + for i, l := range userRanks { + Leaderboard[i] = LeaderboardUser(l) + } + + return Leaderboard, nil +} + +func (s *service) GetCurrentUserRank(ctx context.Context, userId int) (LeaderboardUser, error) { + currentUserRank, err := s.userRepository.GetCurrentUserRank(ctx, nil, userId) + if err != nil { + slog.Error("error obtaining current user rank", "error", err) + return LeaderboardUser{}, err + } + + return LeaderboardUser(currentUserRank), nil +} diff --git a/internal/db/migrations/1752476063_create-index-users-current-balance.down.sql b/internal/db/migrations/1752476063_create-index-users-current-balance.down.sql new file mode 100644 index 0000000..a987013 --- /dev/null +++ b/internal/db/migrations/1752476063_create-index-users-current-balance.down.sql @@ -0,0 +1 @@ +drop index idx_users_current_balance \ No newline at end of file diff --git a/internal/db/migrations/1752476063_create-index-users-current-balance.up.sql b/internal/db/migrations/1752476063_create-index-users-current-balance.up.sql new file mode 100644 index 0000000..a934bf1 --- /dev/null +++ b/internal/db/migrations/1752476063_create-index-users-current-balance.up.sql @@ -0,0 +1 @@ +CREATE INDEX idx_users_current_balance ON users(current_balance DESC); \ No newline at end of file diff --git a/internal/pkg/apperrors/errors.go b/internal/pkg/apperrors/errors.go index 31c9e49..1ebee57 100644 --- a/internal/pkg/apperrors/errors.go +++ b/internal/pkg/apperrors/errors.go @@ -6,6 +6,7 @@ import ( ) var ( + ErrContextValue = errors.New("error obtaining value from context") ErrInternalServer = errors.New("internal server error") ErrInvalidRequestBody = errors.New("invalid or missing parameters in the request body") @@ -52,7 +53,7 @@ var ( func MapError(err error) (statusCode int, errMessage string) { switch err { - case ErrInvalidRequestBody, ErrInvalidQueryParams: + case ErrInvalidRequestBody, ErrInvalidQueryParams, ErrContextValue: return http.StatusBadRequest, err.Error() case ErrUnauthorizedAccess: return http.StatusUnauthorized, err.Error() diff --git a/internal/repository/domain.go b/internal/repository/domain.go index 1a94621..d52494b 100644 --- a/internal/repository/domain.go +++ b/internal/repository/domain.go @@ -77,3 +77,11 @@ type Transaction struct { CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } + +type LeaderboardUser struct { + Id int `db:"id"` + GithubUsername string `db:"github_username"` + AvatarUrl string `db:"avatar_url"` + CurrentBalance int `db:"current_balance"` + Rank int `db:"rank"` +} diff --git a/internal/repository/user.go b/internal/repository/user.go index a3a748a..0a80cbe 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -23,6 +23,8 @@ type UserRepository interface { UpdateUserEmail(ctx context.Context, tx *sqlx.Tx, userId int, email string) error GetAllUsersGithubId(ctx context.Context, tx *sqlx.Tx) ([]int, error) UpdateUserCurrentBalance(ctx context.Context, tx *sqlx.Tx, user User) error + GetAllUsersRank(ctx context.Context, tx *sqlx.Tx) ([]LeaderboardUser, error) + GetCurrentUserRank(ctx context.Context, tx *sqlx.Tx, userId int) (LeaderboardUser, error) } func NewUserRepository(db *sqlx.DB) UserRepository { @@ -51,6 +53,31 @@ const ( getAllUsersGithubIdQuery = "SELECT github_id from users" updateUserCurrentBalanceQuery = "UPDATE users SET current_balance=$1, updated_at=$2 where id=$3" + + getAllUsersRankQuery = ` + SELECT + id, + github_username, + avatar_url, + current_balance, + RANK() over (ORDER BY current_balance DESC) AS rank + FROM users + ORDER BY current_balance DESC` + + getCurrentUserRankQuery = ` + SELECT * + FROM + ( + SELECT + id, + github_username, + avatar_url, + current_balance, + RANK() OVER (ORDER BY current_balance DESC) AS rank + FROM users + ) + ranked_users + WHERE id = $1;` ) func (ur *userRepository) GetUserById(ctx context.Context, tx *sqlx.Tx, userId int) (User, error) { @@ -142,3 +169,30 @@ func (ur *userRepository) UpdateUserCurrentBalance(ctx context.Context, tx *sqlx return nil } + +func (ur *userRepository) GetAllUsersRank(ctx context.Context, tx *sqlx.Tx) ([]LeaderboardUser, error) { + executer := ur.BaseRepository.initiateQueryExecuter(tx) + + var leaderboard []LeaderboardUser + err := executer.SelectContext(ctx, &leaderboard, getAllUsersRankQuery) + if err != nil { + slog.Error("failed to get users rank", "error", err) + return nil, apperrors.ErrInternalServer + } + + return leaderboard, nil +} + +func (ur *userRepository) GetCurrentUserRank(ctx context.Context, tx *sqlx.Tx, userId int) (LeaderboardUser, error) { + + executer := ur.BaseRepository.initiateQueryExecuter(tx) + + var currentUserRank LeaderboardUser + err := executer.GetContext(ctx, ¤tUserRank, getCurrentUserRankQuery, userId) + if err != nil { + slog.Error("failed to get user rank", "error", err) + return LeaderboardUser{}, apperrors.ErrInternalServer + } + + return currentUserRank, nil +}