diff --git a/backend/internal/app/auth/domain.go b/backend/internal/app/auth/domain.go index 33f8f99..8168750 100644 --- a/backend/internal/app/auth/domain.go +++ b/backend/internal/app/auth/domain.go @@ -39,3 +39,12 @@ type GithubUserResponse struct { IsAdmin bool `json:"isAdmin"` } +type AdminLoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type Admin struct { + User + JwtToken string `json:"jwtToken"` +} diff --git a/backend/internal/app/auth/handler.go b/backend/internal/app/auth/handler.go index 3d40c85..e909161 100644 --- a/backend/internal/app/auth/handler.go +++ b/backend/internal/app/auth/handler.go @@ -1,6 +1,7 @@ package auth import ( + "encoding/json" "fmt" "log/slog" "net/http" @@ -20,6 +21,7 @@ type Handler interface { GithubOAuthLoginUrl(w http.ResponseWriter, r *http.Request) GithubOAuthLoginCallback(w http.ResponseWriter, r *http.Request) GetLoggedInUser(w http.ResponseWriter, r *http.Request) + LoginAdmin(w http.ResponseWriter, r *http.Request) } func NewHandler(authService Service, appConfig config.AppConfig) Handler { @@ -82,3 +84,25 @@ func (h *handler) GetLoggedInUser(w http.ResponseWriter, r *http.Request) { response.WriteJson(w, http.StatusOK, "logged in user fetched successfully", userInfo) } + +func (h *handler) LoginAdmin(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var requestBody AdminLoginRequest + err := json.NewDecoder(r.Body).Decode(&requestBody) + if err != nil { + slog.Error(apperrors.ErrFailedMarshal.Error(), "error", err) + response.WriteJson(w, http.StatusBadRequest, apperrors.ErrInvalidRequestBody.Error(), nil) + return + } + + adminInfo, err := h.authService.VerifyAdminCredentials(ctx, requestBody) + if err != nil { + slog.Error("failed to verify admin credentials", "error", err) + status, errorMessage := apperrors.MapError(apperrors.ErrContextValue) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "admin logged in successfully", adminInfo) +} diff --git a/backend/internal/app/auth/service.go b/backend/internal/app/auth/service.go index abcaf19..3e237d2 100644 --- a/backend/internal/app/auth/service.go +++ b/backend/internal/app/auth/service.go @@ -9,6 +9,7 @@ import ( "github.com/joshsoftware/code-curiosity-2025/internal/config" "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" "github.com/joshsoftware/code-curiosity-2025/internal/pkg/jwt" + "golang.org/x/crypto/bcrypt" "golang.org/x/oauth2" "golang.org/x/oauth2/github" ) @@ -23,6 +24,7 @@ type Service interface { GithubOAuthLoginUrl(ctx context.Context) string GithubOAuthLoginCallback(ctx context.Context, code string) (string, error) GetLoggedInUser(ctx context.Context, userId int) (User, error) + VerifyAdminCredentials(ctx context.Context, adminCredentials AdminLoginRequest) (Admin, error) } func NewService(userService user.Service, appCfg config.AppConfig) Service { @@ -102,3 +104,30 @@ func (s *service) GetLoggedInUser(ctx context.Context, userId int) (User, error) return User(user), nil } + +func (s *service) VerifyAdminCredentials(ctx context.Context, adminCredentials AdminLoginRequest) (Admin, error) { + adminInfo, err := s.userService.GetLoggedInAdmin(ctx, user.AdminLoginRequest(adminCredentials)) + if err != nil { + slog.Error("failed to verify admin", "error", err) + return Admin{}, err + } + + err = bcrypt.CompareHashAndPassword([]byte(adminInfo.Password), []byte(adminCredentials.Password)) + if err != nil { + slog.Error("failed to verify admin, invalid password", "error", err) + return Admin{}, apperrors.ErrInvalidCredentials + } + + jwtToken, err := jwt.GenerateJWT(adminInfo.Id, adminInfo.IsAdmin, s.appCfg) + if err != nil { + slog.Error("failed to generate jwt token", "error", err) + return Admin{}, apperrors.ErrInternalServer + } + + admin := Admin{ + User: User(adminInfo), + JwtToken: jwtToken, + } + + return admin, nil +} diff --git a/backend/internal/app/contribution/domain.go b/backend/internal/app/contribution/domain.go index daaea56..0fc11c5 100644 --- a/backend/internal/app/contribution/domain.go +++ b/backend/internal/app/contribution/domain.go @@ -73,3 +73,8 @@ type FetchUserContributionsResponse struct { Contribution Repository } + +type ConfigureContributionTypeScore struct { + ContributionType string `json:"contributionType"` + Score int `json:"score"` +} \ No newline at end of file diff --git a/backend/internal/app/contribution/handler.go b/backend/internal/app/contribution/handler.go index 49ea7bf..dfac84c 100644 --- a/backend/internal/app/contribution/handler.go +++ b/backend/internal/app/contribution/handler.go @@ -1,6 +1,7 @@ package contribution import ( + "encoding/json" "log/slog" "net/http" @@ -17,6 +18,8 @@ type handler struct { type Handler interface { FetchUserContributions(w http.ResponseWriter, r *http.Request) ListMonthlyContributionSummary(w http.ResponseWriter, r *http.Request) + ListAllContributionTypes(w http.ResponseWriter, r *http.Request) + ConfigureContributionTypeScore(w http.ResponseWriter, r *http.Request) } func NewHandler(contributionService Service) Handler { @@ -88,3 +91,48 @@ func (h *handler) ListMonthlyContributionSummary(w http.ResponseWriter, r *http. response.WriteJson(w, http.StatusOK, "contribution type overview for month fetched successfully", monthlyContributionSummary) } + +func (h *handler) ListAllContributionTypes(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + contributionTypes, err := h.contributionService.ListAllContributionTypes(ctx) + if err != nil { + slog.Error("error fetching all contribution types", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "all contribution types fetched successfully", contributionTypes) +} + +func (h *handler) ConfigureContributionTypeScore(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // isAdminValue := ctx.Value(middleware.IsAdminKey) + // isAdmin, ok := isAdminValue.(bool) + // if !ok { + // slog.Error("error verifying admin from context") + // status, errorMessage := apperrors.MapError(apperrors.ErrContextValue) + // response.WriteJson(w, status, errorMessage, nil) + // return + // } + + var configureContributionTypeScores []ConfigureContributionTypeScore + err := json.NewDecoder(r.Body).Decode(&configureContributionTypeScores) + if err != nil { + slog.Error(apperrors.ErrFailedMarshal.Error(), "error", err) + response.WriteJson(w, http.StatusBadRequest, apperrors.ErrInvalidRequestBody.Error(), nil) + return + } + + contributionTypeScores, err := h.contributionService.ConfigureContributionTypeScore(ctx, configureContributionTypeScores) + if err != nil { + slog.Error("error configuring contribution type scores", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "contribution types fscores configured successfully", contributionTypeScores) +} diff --git a/backend/internal/app/contribution/service.go b/backend/internal/app/contribution/service.go index 9a7e85c..900d871 100644 --- a/backend/internal/app/contribution/service.go +++ b/backend/internal/app/contribution/service.go @@ -67,6 +67,8 @@ type Service interface { FetchUserContributions(ctx context.Context, userId int) ([]FetchUserContributionsResponse, error) GetContributionByGithubEventId(ctx context.Context, githubEventId string) (Contribution, error) ListMonthlyContributionSummary(ctx context.Context, year int, monthParam int, userId int) ([]MonthlyContributionSummary, error) + ListAllContributionTypes(ctx context.Context) ([]ContributionScore, error) + ConfigureContributionTypeScore(ctx context.Context, configureContributionTypeScore []ConfigureContributionTypeScore) ([]ContributionScore, error) } func NewService(bigqueryService bigquery.Service, contributionRepository repository.ContributionRepository, repositoryService repoService.Service, userService user.Service, transactionService transaction.Service, httpClient *http.Client) Service { @@ -310,3 +312,38 @@ func (s *service) ListMonthlyContributionSummary(ctx context.Context, year int, return serviceMonthlyContributionSummaries, nil } + +func (s *service) ListAllContributionTypes(ctx context.Context) ([]ContributionScore, error) { + contributionTypes, err := s.contributionRepository.GetAllContributionTypes(ctx, nil) + if err != nil { + slog.Error("error fetching all contribution types", "error", err) + return nil, err + } + + serviceContributionTypes := make([]ContributionScore, len(contributionTypes)) + for i, c := range contributionTypes { + serviceContributionTypes[i] = ContributionScore(c) + } + + return serviceContributionTypes, nil +} + +func (s *service) ConfigureContributionTypeScore(ctx context.Context, configureContributionTypeScore []ConfigureContributionTypeScore) ([]ContributionScore, error) { + repoConfigureContributionScore := make([]repository.ConfigureContributionTypeScore, len(configureContributionTypeScore)) + for i, c := range configureContributionTypeScore { + repoConfigureContributionScore[i] = repository.ConfigureContributionTypeScore(c) + } + + contributionTypeScores, err := s.contributionRepository.UpdateContributionTypeScore(ctx, nil, repoConfigureContributionScore) + if err != nil { + slog.Error("error updating contritbution types score", "error", err) + return nil, err + } + + serviceContributionTypeScores := make([]ContributionScore, len(contributionTypeScores)) + for i, c := range contributionTypeScores { + serviceContributionTypeScores[i] = ContributionScore(c) + } + + return serviceContributionTypeScores, nil +} diff --git a/backend/internal/app/goal/domain.go b/backend/internal/app/goal/domain.go index e822e84..9d3e482 100644 --- a/backend/internal/app/goal/domain.go +++ b/backend/internal/app/goal/domain.go @@ -24,3 +24,9 @@ type CustomGoalLevelTarget struct { ContributionType string `json:"contributionType"` Target int `json:"target"` } + +type UserGoalLevelProgress struct { + ContributionType string `json:"contributionType"` + TargetCount int `json:"targetCount"` + AchievedCount int `json:"achievedCount"` +} diff --git a/backend/internal/app/goal/handler.go b/backend/internal/app/goal/handler.go index 4fb1227..d163740 100644 --- a/backend/internal/app/goal/handler.go +++ b/backend/internal/app/goal/handler.go @@ -16,9 +16,9 @@ type handler struct { type Handler interface { ListGoalLevels(w http.ResponseWriter, r *http.Request) - ListGoalLevelTargets(w http.ResponseWriter, r *http.Request) + GetUserActiveGoalLevel(w http.ResponseWriter, r *http.Request) CreateCustomGoalLevelTarget(w http.ResponseWriter, r *http.Request) - ListGoalLevelAchievedTarget(w http.ResponseWriter, r *http.Request) + ListUserGoalLevelProgress(w http.ResponseWriter, r *http.Request) } func NewHandler(goalService Service) Handler { @@ -41,7 +41,7 @@ func (h *handler) ListGoalLevels(w http.ResponseWriter, r *http.Request) { response.WriteJson(w, http.StatusOK, "goal levels fetched successfully", gaols) } -func (h *handler) ListGoalLevelTargets(w http.ResponseWriter, r *http.Request) { +func (h *handler) GetUserActiveGoalLevel(w http.ResponseWriter, r *http.Request) { ctx := r.Context() userIdCtxVal := ctx.Value(middleware.UserIdKey) @@ -53,15 +53,15 @@ func (h *handler) ListGoalLevelTargets(w http.ResponseWriter, r *http.Request) { return } - goalLevelTargets, err := h.goalService.ListGoalLevelTargetDetail(ctx, userId) + userGoalLevel, err := h.goalService.GetUserActiveGoalLevel(ctx, userId) if err != nil { - slog.Error("error fetching goal level targets", "error", err) + slog.Error("error fetching users active goal level", "error", err) status, errorMessage := apperrors.MapError(err) response.WriteJson(w, status, errorMessage, nil) return } - response.WriteJson(w, http.StatusOK, "goal level targets fetched successfully", goalLevelTargets) + response.WriteJson(w, http.StatusOK, "user active goal level fetched successfully", userGoalLevel) } func (h *handler) CreateCustomGoalLevelTarget(w http.ResponseWriter, r *http.Request) { @@ -94,7 +94,7 @@ func (h *handler) CreateCustomGoalLevelTarget(w http.ResponseWriter, r *http.Req response.WriteJson(w, http.StatusOK, "custom goal level targets created successfully", createdCustomGoalLevelTargets) } -func (h *handler) ListGoalLevelAchievedTarget(w http.ResponseWriter, r *http.Request) { +func (h *handler) ListUserGoalLevelProgress(w http.ResponseWriter, r *http.Request) { ctx := r.Context() userIdCtxVal := ctx.Value(middleware.UserIdKey) @@ -106,12 +106,13 @@ func (h *handler) ListGoalLevelAchievedTarget(w http.ResponseWriter, r *http.Req return } - goalLevelAchievedTarget, err := h.goalService.ListGoalLevelAchievedTarget(ctx, userId) + userGoalLevelProgress, err := h.goalService.ListUserGoalLevelProgress(ctx, userId) if err != nil { - slog.Error("error failed to list goal level achieved targets", "error", err) + slog.Error("error failed to fetch user goal level progress", "error", err) response.WriteJson(w, http.StatusBadRequest, err.Error(), nil) return } - response.WriteJson(w, http.StatusOK, "goal level achieved targets fetched successfully", goalLevelAchievedTarget) + response.WriteJson(w, http.StatusOK, "user goal level progress fetched successfully", userGoalLevelProgress) + } diff --git a/backend/internal/app/goal/service.go b/backend/internal/app/goal/service.go index 33a71f3..1824c91 100644 --- a/backend/internal/app/goal/service.go +++ b/backend/internal/app/goal/service.go @@ -18,9 +18,9 @@ type service struct { type Service interface { ListGoalLevels(ctx context.Context) ([]Goal, error) GetGoalIdByGoalLevel(ctx context.Context, level string) (int, error) - ListGoalLevelTargetDetail(ctx context.Context, userId int) ([]GoalContribution, error) + GetUserActiveGoalLevel(ctx context.Context, userId int) (string, error) CreateCustomGoalLevelTarget(ctx context.Context, userId int, customGoalLevelTarget []CustomGoalLevelTarget) ([]GoalContribution, error) - ListGoalLevelAchievedTarget(ctx context.Context, userId int) (map[string]int, error) + ListUserGoalLevelProgress(ctx context.Context, userId int) ([]UserGoalLevelProgress, error) } func NewService(goalRepository repository.GoalRepository, contributionRepository repository.ContributionRepository, badgeService badge.Service) Service { @@ -58,19 +58,14 @@ func (s *service) GetGoalIdByGoalLevel(ctx context.Context, level string) (int, return goalId, err } -func (s *service) ListGoalLevelTargetDetail(ctx context.Context, userId int) ([]GoalContribution, error) { - goalLevelTargets, err := s.goalRepository.ListUserGoalLevelTargets(ctx, nil, userId) +func (s *service) GetUserActiveGoalLevel(ctx context.Context, userId int) (string, error) { + userGoalLevel, err := s.goalRepository.GetUserActiveGoalLevel(ctx, nil, userId) if err != nil { - slog.Error("error fetching goal level targets", "error", err) - return nil, err - } - - serviceGoalLevelTargets := make([]GoalContribution, len(goalLevelTargets)) - for i, g := range goalLevelTargets { - serviceGoalLevelTargets[i] = GoalContribution(g) + slog.Error("error fetching user active gaol level", "error", err) + return "", err } - return serviceGoalLevelTargets, nil + return userGoalLevel, nil } func (s *service) CreateCustomGoalLevelTarget(ctx context.Context, userId int, customGoalLevelTarget []CustomGoalLevelTarget) ([]GoalContribution, error) { @@ -107,24 +102,14 @@ func (s *service) CreateCustomGoalLevelTarget(ctx context.Context, userId int, c return goalContributions, nil } -func (s *service) ListGoalLevelAchievedTarget(ctx context.Context, userId int) (map[string]int, error) { + +func (s *service) ListUserGoalLevelProgress(ctx context.Context, userId int) ([]UserGoalLevelProgress, error) { goalLevelSetTargets, err := s.goalRepository.ListUserGoalLevelTargets(ctx, nil, userId) if err != nil { slog.Error("error fetching goal level targets", "error", err) return nil, err } - contributionTypes := make([]CustomGoalLevelTarget, len(goalLevelSetTargets)) - for i, g := range goalLevelSetTargets { - contributionTypes[i].ContributionType, err = s.contributionRepository.GetContributionTypeByContributionScoreId(ctx, nil, g.ContributionScoreId) - if err != nil { - slog.Error("error fetching contribution type by contribution score id", "error", err) - return nil, err - } - - contributionTypes[i].Target = g.TargetCount - } - year := int(time.Now().Year()) month := int(time.Now().Month()) monthlyContributionCount, err := s.contributionRepository.ListMonthlyContributionSummary(ctx, nil, year, month, userId) @@ -133,32 +118,43 @@ func (s *service) ListGoalLevelAchievedTarget(ctx context.Context, userId int) ( return nil, err } - contributionsAchievedTarget := make(map[string]int, len(monthlyContributionCount)) - + contributionCountMap := make(map[string]int) for _, m := range monthlyContributionCount { - contributionsAchievedTarget[m.Type] = m.Count + contributionCountMap[m.Type] = m.Count } - var completedTarget int - for _, c := range contributionTypes { - if c.Target == contributionsAchievedTarget[c.ContributionType] { - completedTarget += 1 - } - } + userGoalLevelProgress := make([]UserGoalLevelProgress, len(goalLevelSetTargets)) + var contributionsCompleted int - if completedTarget == len(goalLevelSetTargets) { - userGoalLevel, err := s.goalRepository.GetUserActiveGoalLevel(ctx, nil, userId) + for i, g := range goalLevelSetTargets { + contributionType, err := s.contributionRepository.GetContributionTypeByContributionScoreId(ctx, nil, g.ContributionScoreId) if err != nil { - slog.Error("error fetching user active gaol level", "error", err) + slog.Error("error") return nil, err } - _, err = s.badgeService.HandleBadgeCreation(ctx, userId, userGoalLevel) - if err != nil { - slog.Error("error handling user badge creation", "error", err) - return nil, err + userGoalLevelProgress[i].ContributionType = contributionType + userGoalLevelProgress[i].TargetCount = g.TargetCount + userGoalLevelProgress[i].AchievedCount = contributionCountMap[contributionType] + + if userGoalLevelProgress[i].AchievedCount == g.TargetCount { + contributionsCompleted++ + } + + if contributionsCompleted == len(goalLevelSetTargets) { + userGoalLevel, err := s.goalRepository.GetUserActiveGoalLevel(ctx, nil, userId) + if err != nil { + slog.Error("error fetching user active gaol level", "error", err) + return nil, err + } + + _, err = s.badgeService.HandleBadgeCreation(ctx, userId, userGoalLevel) + if err != nil { + slog.Error("error handling user badge creation", "error", err) + return nil, err + } } } - return contributionsAchievedTarget, nil + return userGoalLevelProgress, nil } diff --git a/backend/internal/app/router.go b/backend/internal/app/router.go index 13ce566..2fc197b 100644 --- a/backend/internal/app/router.go +++ b/backend/internal/app/router.go @@ -17,12 +17,14 @@ func NewRouter(deps Dependencies) http.Handler { router.HandleFunc("GET /api/v1/auth/github", deps.AuthHandler.GithubOAuthLoginUrl) router.HandleFunc("GET /api/v1/auth/github/callback", deps.AuthHandler.GithubOAuthLoginCallback) router.HandleFunc("GET /api/v1/auth/user", middleware.Authentication(deps.AuthHandler.GetLoggedInUser, deps.AppCfg)) + router.HandleFunc("GET /api/v1/auth/admin", deps.AuthHandler.LoginAdmin) router.HandleFunc("PATCH /api/v1/user/email", middleware.Authentication(deps.UserHandler.UpdateUserEmail, deps.AppCfg)) router.HandleFunc("DELETE /api/v1/user/delete/{user_id}", middleware.Authentication(deps.UserHandler.SoftDeleteUser, deps.AppCfg)) router.HandleFunc("GET /api/v1/user/contributions/all", middleware.Authentication(deps.ContributionHandler.FetchUserContributions, deps.AppCfg)) router.HandleFunc("GET /api/v1/user/overview", middleware.Authentication(deps.ContributionHandler.ListMonthlyContributionSummary, deps.AppCfg)) + router.HandleFunc("GET /api/v1/contributions/types", middleware.Authentication(deps.ContributionHandler.ListAllContributionTypes, deps.AppCfg)) router.HandleFunc("GET /api/v1/user/repositories", middleware.Authentication(deps.RepositoryHandler.FetchUsersContributedRepos, deps.AppCfg)) router.HandleFunc("GET /api/v1/user/repositories/{repo_id}", middleware.Authentication(deps.RepositoryHandler.FetchParticularRepoDetails, deps.AppCfg)) @@ -33,13 +35,17 @@ func NewRouter(deps Dependencies) http.Handler { 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)) - router.HandleFunc("GET /api/v1/user/goal/level", middleware.Authentication(deps.GoalHandler.ListGoalLevels, deps.AppCfg)) + router.HandleFunc("GET /api/v1/goal/level", middleware.Authentication(deps.GoalHandler.ListGoalLevels, deps.AppCfg)) router.HandleFunc("PATCH /api/v1/user/goal/level", middleware.Authentication(deps.UserHandler.UpdateCurrentActiveGoalId, deps.AppCfg)) - router.HandleFunc("GET /api/v1/user/goal/level/targets", middleware.Authentication(deps.GoalHandler.ListGoalLevelTargets, deps.AppCfg)) + router.HandleFunc("GET /api/v1/user/goal/level", middleware.Authentication(deps.GoalHandler.GetUserActiveGoalLevel, deps.AppCfg)) router.HandleFunc("POST /api/v1/user/goal/level/custom/targets", middleware.Authentication(deps.GoalHandler.CreateCustomGoalLevelTarget, deps.AppCfg)) - router.HandleFunc("GET /api/v1/user/goal/level/targets/achieved", middleware.Authentication(deps.GoalHandler.ListGoalLevelAchievedTarget, deps.AppCfg)) + router.HandleFunc("GET /api/v1/user/goal/level/progress", middleware.Authentication(deps.GoalHandler.ListUserGoalLevelProgress, deps.AppCfg)) router.HandleFunc("GET /api/v1/user/badges", middleware.Authentication(deps.BadgeHandler.GetBadgeDetailsOfUser, deps.AppCfg)) + router.HandleFunc("PATCH /api/v1/contributions/scores/configure", middleware.Authentication(middleware.AuthorizeAdmin(deps.ContributionHandler.ConfigureContributionTypeScore), deps.AppCfg)) + router.HandleFunc("GET /api/v1/users", middleware.Authentication(middleware.AuthorizeAdmin(deps.UserHandler.ListAllUsers), deps.AppCfg)) + router.HandleFunc("PATCH /api/v1/users/{user_id}", middleware.Authentication(middleware.AuthorizeAdmin(deps.UserHandler.BlockOrUnblockUser), deps.AppCfg)) + return middleware.CorsMiddleware(router, deps.AppCfg) } diff --git a/backend/internal/app/user/domain.go b/backend/internal/app/user/domain.go index 4599fe8..8c9a9cc 100644 --- a/backend/internal/app/user/domain.go +++ b/backend/internal/app/user/domain.go @@ -58,3 +58,12 @@ type LeaderboardUser struct { type GoalLevel struct { Level string `json:"level"` } + +type AdminLoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type BlockOrUnblockUserRequest struct { + Block bool `json:"block"` +} diff --git a/backend/internal/app/user/handler.go b/backend/internal/app/user/handler.go index 8133c9f..28f01a4 100644 --- a/backend/internal/app/user/handler.go +++ b/backend/internal/app/user/handler.go @@ -4,6 +4,7 @@ import ( "encoding/json" "log/slog" "net/http" + "strconv" "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" "github.com/joshsoftware/code-curiosity-2025/internal/pkg/middleware" @@ -20,6 +21,8 @@ type Handler interface { ListUserRanks(w http.ResponseWriter, r *http.Request) GetCurrentUserRank(w http.ResponseWriter, r *http.Request) UpdateCurrentActiveGoalId(w http.ResponseWriter, r *http.Request) + ListAllUsers(w http.ResponseWriter, r *http.Request) + BlockOrUnblockUser(w http.ResponseWriter, r *http.Request) } func NewHandler(userService Service) Handler { @@ -149,3 +152,48 @@ func (h *handler) UpdateCurrentActiveGoalId(w http.ResponseWriter, r *http.Reque response.WriteJson(w, http.StatusOK, "Goal updated successfully", goalId) } + +func (h *handler) ListAllUsers(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + users, err := h.userService.ListAllUsers(ctx) + if err != nil { + slog.Error("failed to fetch all users", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "users fetched successfully", users) +} + +func (h *handler) BlockOrUnblockUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + userIdPath := r.PathValue("user_id") + userId, err := strconv.Atoi(userIdPath) + if err != nil { + slog.Error("error getting user id from request url", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + var status BlockOrUnblockUserRequest + err = json.NewDecoder(r.Body).Decode(&status) + if err != nil { + slog.Error(apperrors.ErrFailedMarshal.Error(), "error", err) + response.WriteJson(w, http.StatusBadRequest, apperrors.ErrInvalidRequestBody.Error(), nil) + return + } + + err = h.userService.BlockOrUnblockUser(ctx, userId, status.Block) + if err != nil { + slog.Error("failed to block/unblock user", "error", err) + status, message := apperrors.MapError(err) + response.WriteJson(w, status, message, nil) + return + } + + response.WriteJson(w, http.StatusOK, "user status updated successfully", nil) +} diff --git a/backend/internal/app/user/service.go b/backend/internal/app/user/service.go index 2e58ba7..160777e 100644 --- a/backend/internal/app/user/service.go +++ b/backend/internal/app/user/service.go @@ -30,6 +30,9 @@ type Service interface { GetAllUsersRank(ctx context.Context) ([]LeaderboardUser, error) GetCurrentUserRank(ctx context.Context, userId int) (LeaderboardUser, error) UpdateCurrentActiveGoalId(ctx context.Context, userId int, level string) (int, error) + GetLoggedInAdmin(ctx context.Context, adminInfo AdminLoginRequest) (User, error) + ListAllUsers(ctx context.Context) ([]User, error) + BlockOrUnblockUser(ctx context.Context, userID int, block bool) error } func NewService(userRepository repository.UserRepository, goalService goal.Service, repositoryService repoService.Service) Service { @@ -200,3 +203,39 @@ func (s *service) UpdateCurrentActiveGoalId(ctx context.Context, userId int, lev return goalId, err } + +func (s *service) GetLoggedInAdmin(ctx context.Context, adminInfo AdminLoginRequest) (User, error) { + admin, err := s.userRepository.GetAdminByCredentials(ctx, nil, repository.AdminLoginRequest(adminInfo)) + if err != nil { + slog.Error("failed to verify admin credentials", "error", err) + return User{}, err + } + + return User(admin), nil +} + +func (s *service) ListAllUsers(ctx context.Context) ([]User, error) { + users, err := s.userRepository.GetAllUsers(ctx, nil) + if err != nil { + slog.Error("failed to fetch all users", "error", err) + return nil, apperrors.ErrInternalServer + } + + serviceUsers := make([]User, len(users)) + + for i, u := range users { + serviceUsers[i] = User(u) + } + + return serviceUsers, nil +} + +func (s *service) BlockOrUnblockUser(ctx context.Context, userID int, block bool) error { + err := s.userRepository.UpdateUserBlockStatus(ctx, nil, userID, block) + if err != nil { + slog.Error("failed to block/unblock user", "error", err) + return err + } + + return nil +} diff --git a/backend/internal/pkg/apperrors/errors.go b/backend/internal/pkg/apperrors/errors.go index c1e6041..ff32c72 100644 --- a/backend/internal/pkg/apperrors/errors.go +++ b/backend/internal/pkg/apperrors/errors.go @@ -58,6 +58,8 @@ var ( ErrCustomGoalTargetCreationFailed = errors.New("failed to create targets for custom goal level") ErrBadgeCreationFailed = errors.New("failed to create badge for user") + + ErrInvalidCredentials = errors.New("error invalid credentials") ) func MapError(err error) (statusCode int, errMessage string) { diff --git a/backend/internal/pkg/middleware/middleware.go b/backend/internal/pkg/middleware/middleware.go index 4c40789..3f0a9f3 100644 --- a/backend/internal/pkg/middleware/middleware.go +++ b/backend/internal/pkg/middleware/middleware.go @@ -72,3 +72,21 @@ func Authentication(next http.HandlerFunc, appCfg config.AppConfig) http.Handler next.ServeHTTP(w, r) }) } + +func AuthorizeAdmin(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + isAdmin, ok := ctx.Value(IsAdminKey).(bool) + if !ok { + response.WriteJson(w, http.StatusInternalServerError, apperrors.ErrContextValue.Error(), nil) + return + } + + if !isAdmin { + response.WriteJson(w, http.StatusUnauthorized, apperrors.ErrUnauthorizedAccess.Error(), nil) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/backend/internal/repository/contribution.go b/backend/internal/repository/contribution.go index d361846..2d4e17a 100644 --- a/backend/internal/repository/contribution.go +++ b/backend/internal/repository/contribution.go @@ -23,6 +23,7 @@ type ContributionRepository interface { GetAllContributionTypes(ctx context.Context, tx *sqlx.Tx) ([]ContributionScore, error) ListMonthlyContributionSummary(ctx context.Context, tx *sqlx.Tx, year int, month int, userId int) ([]MonthlyContributionSummary, error) GetContributionTypeByContributionScoreId(ctx context.Context, tx *sqlx.Tx, contributionScoreId int) (string, error) + UpdateContributionTypeScore(ctx context.Context, tx *sqlx.Tx, configureContributionTypeScore []ConfigureContributionTypeScore) ([]ContributionScore, error) } func NewContributionRepository(db *sqlx.DB) ContributionRepository { @@ -66,6 +67,8 @@ const ( month, contribution_type;` getContributionTypeByContributionScoreIdQuery = `SELECT contribution_type from contribution_score where id=$1` + + updateContributionTypeScoreQuery = "UPDATE contribution_score SET score = $1 where contribution_type = $2" ) func (cr *contributionRepository) CreateContribution(ctx context.Context, tx *sqlx.Tx, contributionInfo Contribution) (Contribution, error) { @@ -176,3 +179,24 @@ func (cr *contributionRepository) GetContributionTypeByContributionScoreId(ctx c return contributionType, nil } + +func (cr *contributionRepository) UpdateContributionTypeScore(ctx context.Context, tx *sqlx.Tx, configureContributionTypeScore []ConfigureContributionTypeScore) ([]ContributionScore, error) { + executer := cr.BaseRepository.initiateQueryExecuter(tx) + + for _, c := range configureContributionTypeScore { + _, err := executer.ExecContext(ctx, updateContributionTypeScoreQuery, c.Score, c.ContributionType) + if err != nil { + slog.Error("failed to update score for contribution type", "error", err) + return nil, apperrors.ErrInternalServer + } + } + + var contributionTypeScores []ContributionScore + err := executer.SelectContext(ctx, &contributionTypeScores, getAllContributionTypesQuery) + if err != nil { + slog.Error("error fetching all contribution type scores", "error", err) + return nil, apperrors.ErrFetchingContributionTypes + } + + return contributionTypeScores, nil +} diff --git a/backend/internal/repository/domain.go b/backend/internal/repository/domain.go index c3faf14..b31e667 100644 --- a/backend/internal/repository/domain.go +++ b/backend/internal/repository/domain.go @@ -119,3 +119,13 @@ type Badge struct { CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } + +type AdminLoginRequest struct { + Email string `db:"email"` + Password string `db:"password"` +} + +type ConfigureContributionTypeScore struct { + ContributionType string `db:"contribution_type"` + Score int `db:"score"` +} diff --git a/backend/internal/repository/goal.go b/backend/internal/repository/goal.go index f56bc5e..ef5a59d 100644 --- a/backend/internal/repository/goal.go +++ b/backend/internal/repository/goal.go @@ -131,6 +131,11 @@ func (gr *goalRepository) GetUserActiveGoalLevel(ctx context.Context, tx *sqlx.T var userActiveGoalLevel string err := executer.GetContext(ctx, &userActiveGoalLevel, getUserActiveGoalLevelQuery, userId) if err != nil { + if errors.Is(err, sql.ErrNoRows) { + slog.Info("user does not have any active goal level") + return "", nil + } + slog.Error("error getting users current active goal level name", "error", err) return userActiveGoalLevel, apperrors.ErrInternalServer } diff --git a/backend/internal/repository/user.go b/backend/internal/repository/user.go index e75db67..7333694 100644 --- a/backend/internal/repository/user.go +++ b/backend/internal/repository/user.go @@ -29,6 +29,9 @@ type UserRepository interface { GetAllUsersRank(ctx context.Context, tx *sqlx.Tx) ([]LeaderboardUser, error) GetCurrentUserRank(ctx context.Context, tx *sqlx.Tx, userId int) (LeaderboardUser, error) UpdateCurrentActiveGoalId(ctx context.Context, tx *sqlx.Tx, userId int, goalId int) (int, error) + GetAdminByCredentials(ctx context.Context, tx *sqlx.Tx, adminInfo AdminLoginRequest) (User, error) + GetAllUsers(ctx context.Context, tx *sqlx.Tx) ([]User, error) + UpdateUserBlockStatus(ctx context.Context, tx *sqlx.Tx, userID int, block bool) error } func NewUserRepository(db *sqlx.DB) UserRepository { @@ -60,7 +63,7 @@ const ( hardDeleteUsersQuery = "DELETE FROM users WHERE is_deleted = TRUE AND deleted_at <= $1" - getAllUsersGithubIdQuery = "SELECT github_id from users" + getAllUsersGithubIdQuery = "SELECT github_id from users where is_admin=false" updateUserCurrentBalanceQuery = "UPDATE users SET current_balance=$1, updated_at=$2 where id=$3" @@ -92,6 +95,12 @@ const ( WHERE id = $1;` updateCurrentActiveGoalIdQuery = "UPDATE users SET current_active_goal_id=$1 where id=$2" + + verifyAdminCredentialsQuery = "SELECT * FROM users where email = $1 and is_admin=true" + + getAllUsersQuery = "SELECT * FROM users where is_admin=false" + + updateUserBlockStatusQuery = "UPDATE users SET is_blocked=$1 where id=$2" ) func (ur *userRepository) GetUserById(ctx context.Context, tx *sqlx.Tx, userId int) (User, error) { @@ -260,3 +269,45 @@ func (ur *userRepository) UpdateCurrentActiveGoalId(ctx context.Context, tx *sql return goalId, nil } + +func (ur *userRepository) GetAdminByCredentials(ctx context.Context, tx *sqlx.Tx, adminInfo AdminLoginRequest) (User, error) { + executer := ur.BaseRepository.initiateQueryExecuter(tx) + + var admin User + err := executer.GetContext(ctx, &admin, verifyAdminCredentialsQuery, adminInfo.Email) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + slog.Error("invalid admin credentials", "error", err) + return User{}, apperrors.ErrInvalidCredentials + } + slog.Error("failed to verify admin credentials", "error", err) + return User{}, apperrors.ErrInternalServer + } + + return admin, nil +} + +func (ur *userRepository) GetAllUsers(ctx context.Context, tx *sqlx.Tx) ([]User, error) { + executer := ur.BaseRepository.initiateQueryExecuter(tx) + + var users []User + err := executer.SelectContext(ctx, &users, getAllUsersQuery) + if err != nil { + slog.Error("error occurred while getting all users", "error", err) + return nil, apperrors.ErrInternalServer + } + + return users, nil +} + +func (ur *userRepository) UpdateUserBlockStatus(ctx context.Context, tx *sqlx.Tx, userID int, block bool) error { + executer := ur.BaseRepository.initiateQueryExecuter(tx) + + _, err := executer.ExecContext(ctx, updateUserBlockStatusQuery, block, userID) + if err != nil { + slog.Error("failed to update user block status", "error", err) + return apperrors.ErrInternalServer + } + + return nil +} diff --git a/frontend/package.json b/frontend/package.json index f6ce37b..465c623 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,8 @@ "format:fix": "npm run prettier -- --write" }, "dependencies": { + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-separator": "^1.1.7", diff --git a/frontend/src/api/queries/Contributors.ts b/frontend/src/api/queries/Contributors.ts new file mode 100644 index 0000000..76fa858 --- /dev/null +++ b/frontend/src/api/queries/Contributors.ts @@ -0,0 +1,25 @@ +import type { ApiResponse } from "@/shared/types/api"; +import type { Contributor } from "@/shared/types/types"; +import { api } from "../axios"; +import { BACKEND_URL } from "@/shared/constants/endpoints"; +import { REPOSITORY_CONTRIBUTORS_QUERY_KEY } from "@/shared/constants/query-keys"; +import { useQuery } from "@tanstack/react-query"; + +const fetchRepositoryContributors = async ( + repoId: number +): Promise> => { + const response = await api.get<{ + message: string; + data: Contributor[]; + }>(`${BACKEND_URL}/api/v1/user/repositories/contributors/${repoId}`); + + return response.data; +}; + +export const useRepositoryContributors = (repoId: number) => { + return useQuery({ + queryKey: [REPOSITORY_CONTRIBUTORS_QUERY_KEY, repoId], + queryFn: () => fetchRepositoryContributors(repoId), + enabled: !!repoId + }); +}; diff --git a/frontend/src/api/queries/Languages.ts b/frontend/src/api/queries/Languages.ts new file mode 100644 index 0000000..3ab8ec6 --- /dev/null +++ b/frontend/src/api/queries/Languages.ts @@ -0,0 +1,24 @@ +import type { ApiResponse } from "@/shared/types/api"; +import type { Language } from "@/shared/types/types"; +import { api } from "../axios"; +import { BACKEND_URL } from "@/shared/constants/endpoints"; +import { REPOSITORY_LANGUAGES_QUERY_KEY } from "@/shared/constants/query-keys"; +import { useQuery } from "@tanstack/react-query"; + +const fetchRepositoryLanguages = async ( + id: number +): Promise> => { + const response = await api.get<{ + message: string; + data: Language[]; + }>(`${BACKEND_URL}/api/v1/user/repositories/languages/${id}`); + + return response.data; +}; + +export const useRepositoryLanguages = (id: number) => { + return useQuery({ + queryKey: [REPOSITORY_LANGUAGES_QUERY_KEY, id], + queryFn: () => fetchRepositoryLanguages(id) + }); +}; diff --git a/frontend/src/api/queries/Repositories.ts b/frontend/src/api/queries/Repositories.ts new file mode 100644 index 0000000..b0e70a4 --- /dev/null +++ b/frontend/src/api/queries/Repositories.ts @@ -0,0 +1,22 @@ +import type { Repositories } from "@/shared/types/types"; +import { api } from "../axios"; +import type { ApiResponse } from "@/shared/types/api"; +import { BACKEND_URL } from "@/shared/constants/endpoints"; +import { useQuery } from "@tanstack/react-query"; +import { REPOSITORIES_KEY } from "@/shared/constants/query-keys"; + +const fetchRepositories = async (): Promise> => { + const response = await api.get<{ + message: string; + data: Repositories[]; + }>(`${BACKEND_URL}/api/v1/user/repositories`); + + return response.data; +}; + +export const useRepositories = () => { + return useQuery({ + queryKey: [REPOSITORIES_KEY], + queryFn: fetchRepositories + }); +}; diff --git a/frontend/src/api/queries/Repository.tsx b/frontend/src/api/queries/Repository.tsx new file mode 100644 index 0000000..fa9434a --- /dev/null +++ b/frontend/src/api/queries/Repository.tsx @@ -0,0 +1,24 @@ +import { BACKEND_URL } from "@/shared/constants/endpoints"; +import type { ApiResponse } from "@/shared/types/api"; +import type { Repository } from "@/shared/types/types"; +import { api } from "../axios"; +import { REPOSITORY_KEY } from "@/shared/constants/query-keys"; +import { useQuery } from "@tanstack/react-query"; + +const fetchRepository = async ( + repoId: number +): Promise> => { + const response = await api.get<{ + message: string; + data: Repository; + }>(`${BACKEND_URL}/api/v1/user/repositories/${repoId}`); + + return response.data; +}; + +export const useRepository = (repoId: number) => { + return useQuery({ + queryKey: [REPOSITORY_KEY, repoId], + queryFn: () => fetchRepository(repoId) + }); +}; diff --git a/frontend/src/api/queries/RepostoryActivities.ts b/frontend/src/api/queries/RepostoryActivities.ts new file mode 100644 index 0000000..5610284 --- /dev/null +++ b/frontend/src/api/queries/RepostoryActivities.ts @@ -0,0 +1,25 @@ +import type { ApiResponse } from "@/shared/types/api"; +import type { RepositoryActivity } from "@/shared/types/types"; +import { api } from "../axios"; +import { BACKEND_URL } from "@/shared/constants/endpoints"; +import { useQuery } from "@tanstack/react-query"; +import { REPOSITORY_ACTIVITIES_QUERY_KEY } from "@/shared/constants/query-keys"; + +const fetchRepositoryActivivties = async ( + repoId: number +): Promise> => { + const response = await api.get<{ + message: string; + data: RepositoryActivity[]; + }>(`${BACKEND_URL}/api/v1/user/repositories/contributions/recent/${repoId}`); + + return response.data; +}; + +export const useRepositoryActivities = (repoId: number) => { + return useQuery({ + queryKey: [REPOSITORY_ACTIVITIES_QUERY_KEY, repoId], + queryFn: () => fetchRepositoryActivivties(repoId), + enabled: !!repoId + }); +}; diff --git a/frontend/src/api/queries/UserBadges.ts b/frontend/src/api/queries/UserBadges.ts index 9f50e85..34271e2 100644 --- a/frontend/src/api/queries/UserBadges.ts +++ b/frontend/src/api/queries/UserBadges.ts @@ -6,17 +6,17 @@ import { USER_BADGES_QUERY_KEY } from "@/shared/constants/query-keys"; import { useQuery } from "@tanstack/react-query"; const fetchUserBadges = async (): Promise> => { - const response = await api.get<{ - message: string; - data: Badge[]; - }>(`${BACKEND_URL}/api/v1/user/badges`); + const response = await api.get<{ + message: string; + data: Badge[]; + }>(`${BACKEND_URL}/api/v1/user/badges`); - return response.data; -} + return response.data; +}; export const useUserBadges = () => { - return useQuery({ - queryKey: [USER_BADGES_QUERY_KEY], - queryFn: fetchUserBadges, - }); -} \ No newline at end of file + return useQuery({ + queryKey: [USER_BADGES_QUERY_KEY], + queryFn: fetchUserBadges + }); +}; diff --git a/frontend/src/features/Login/components/LoginComponent.tsx b/frontend/src/features/Login/components/LoginComponent.tsx index 9084984..a76bf34 100644 --- a/frontend/src/features/Login/components/LoginComponent.tsx +++ b/frontend/src/features/Login/components/LoginComponent.tsx @@ -12,7 +12,10 @@ import { ACCESS_TOKEN_KEY } from "@/shared/constants/local-storage"; const LoginComponent = () => { const handleGithubLogin = () => { window.location.href = GITHUB_AUTH_URL || ""; - localStorage.setItem(ACCESS_TOKEN_KEY, 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VySWQiOjIsIklzQWRtaW4iOmZhbHNlLCJleHAiOjE3NTQxMzI3NTh9.VKEboNEvSeVKYnqLuBrvTyvx9IglhYzEyeE57x7Qzto') + localStorage.setItem( + ACCESS_TOKEN_KEY, + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VySWQiOjIsIklzQWRtaW4iOmZhbHNlLCJleHAiOjE3NTQxMzI3NTh9.VKEboNEvSeVKYnqLuBrvTyvx9IglhYzEyeE57x7Qzto" + ); }; return ( diff --git a/frontend/src/features/Login/index.tsx b/frontend/src/features/Login/index.tsx index 4114e41..e254c72 100644 --- a/frontend/src/features/Login/index.tsx +++ b/frontend/src/features/Login/index.tsx @@ -1,12 +1,7 @@ -import AuthLayout from "@/shared/layout/AuthLayout"; import LoginComponent from "@/features/Login/components/LoginComponent"; const Login = () => { - return ( - - - - ); + return ; }; export default Login; diff --git a/frontend/src/features/MyContributions/components/Repositories.tsx b/frontend/src/features/MyContributions/components/Repositories.tsx new file mode 100644 index 0000000..b7fb346 --- /dev/null +++ b/frontend/src/features/MyContributions/components/Repositories.tsx @@ -0,0 +1,29 @@ +import { useRepositories } from "@/api/queries/Repositories"; +import { Separator } from "@/shared/components/ui/separator"; +import RepositoriesCard from "./RepositoriesCard"; + +const Repositories = () => { + const { data } = useRepositories(); + const repositoriesData = data?.data; + + return ( +
+ {repositoriesData?.map(repo => ( + <> + + + + ))} +
+ ); +}; + +export default Repositories; diff --git a/frontend/src/features/MyContributions/components/RepositoriesCard.tsx b/frontend/src/features/MyContributions/components/RepositoriesCard.tsx new file mode 100644 index 0000000..266df62 --- /dev/null +++ b/frontend/src/features/MyContributions/components/RepositoriesCard.tsx @@ -0,0 +1,65 @@ +import Coin from "@/shared/components/common/Coin"; +import { Card, CardContent } from "@/shared/components/ui/card"; +import { LangColor } from "@/shared/constants/constants"; +import { format } from "date-fns"; +import type { FC } from "react"; +import { useNavigate } from "react-router-dom"; + +interface RepositoriesCardProps { + id: number; + name: string; + languages: string[]; + description: string; + updatedOn: string; + coins: number; +} + +const RepositoriesCard: FC = ({ + id, + name, + languages, + description, + updatedOn, + coins +}) => { + const navigate = useNavigate(); + + return ( + + +
navigate(`/repositories/${id}`)} + > +

{name}

+
+ + {coins} +
+
+ +
+ {languages?.map((language, index) => { + const color = LangColor[language] || "bg-gray-400"; + return ( +
+
+ {language} +
+ ); + })} +
+ +

+ {description || "No description for the given repository"} +

+ +

+ Updated on {format(new Date(updatedOn), "MMM d, yyyy")} +

+
+
+ ); +}; + +export default RepositoriesCard; diff --git a/frontend/src/features/MyContributions/index.tsx b/frontend/src/features/MyContributions/index.tsx index 3a454a7..c2b3170 100644 --- a/frontend/src/features/MyContributions/index.tsx +++ b/frontend/src/features/MyContributions/index.tsx @@ -1,7 +1,7 @@ -import UserDashboardLayout from "@/shared/layout/UserDashboardLayout"; +import Repositories from "./components/Repositories"; const MyContributions = () => { - return ; + return ; }; export default MyContributions; diff --git a/frontend/src/features/RepositoryDetails.tsx/components/Contributors.tsx b/frontend/src/features/RepositoryDetails.tsx/components/Contributors.tsx new file mode 100644 index 0000000..7582493 --- /dev/null +++ b/frontend/src/features/RepositoryDetails.tsx/components/Contributors.tsx @@ -0,0 +1,63 @@ +import { useRepositoryContributors } from "@/api/queries/Contributors"; +import { useParams } from "react-router-dom"; +import ContributorsCard from "./ContributorsCard"; +import { useState } from "react"; +import { Button } from "@/shared/components/ui/button"; +import clsx from "clsx"; + +const ContributorsList = () => { + const [viewAll, setViewAll] = useState(false); + + const handleViewAll = () => { + setViewAll(!viewAll); + }; + + const { repoid } = useParams(); + const repoId = Number(repoid); + const { data, isLoading } = useRepositoryContributors(repoId); + const contributors = data?.data ?? []; + + const contributorsData = viewAll ? contributors : contributors?.slice(0, 20); + + return ( +
+
+

+ Contributors {contributors.length} +

+ +
+ {isLoading ? ( +
+
+
+ ) : ( +
+
+ {contributorsData?.map(contributor => ( + + ))} +
+
+ )} +
+ ); +}; + +export default ContributorsList; diff --git a/frontend/src/features/RepositoryDetails.tsx/components/ContributorsCard.tsx b/frontend/src/features/RepositoryDetails.tsx/components/ContributorsCard.tsx new file mode 100644 index 0000000..883224a --- /dev/null +++ b/frontend/src/features/RepositoryDetails.tsx/components/ContributorsCard.tsx @@ -0,0 +1,39 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@radix-ui/react-avatar"; +import type { FC } from "react"; +import { Link } from "react-router-dom"; + +interface ContributorsCardProps { + name: string; + avatarUrl: string; + contributions: number; + githubUrl: string; +} + +const ContributorsCard: FC = ({ + name, + avatarUrl, + contributions, + githubUrl +}) => { + return ( +
+ + + + Contributors-Image +
+ {name}
+ {contributions} Contributions +
+
+ +
+
+ ); +}; + +export default ContributorsCard; diff --git a/frontend/src/features/RepositoryDetails.tsx/components/Languages.tsx b/frontend/src/features/RepositoryDetails.tsx/components/Languages.tsx new file mode 100644 index 0000000..3238ed4 --- /dev/null +++ b/frontend/src/features/RepositoryDetails.tsx/components/Languages.tsx @@ -0,0 +1,26 @@ +import { type FC } from "react"; +import LanguageCard from "../../RepositoryDetails.tsx/components/LanguagesCard"; +import { useRepositoryLanguages } from "@/api/queries/Languages"; +import { useParams } from "react-router-dom"; + +interface LanguagesProps { + className?: string; +} + +const Languages: FC = ({ className }) => { + const { repoid } = useParams(); + const repoId = Number(repoid); + const { data } = useRepositoryLanguages(repoId); + + const languagesData = data?.data; + return ( + + ); +}; + +export default Languages; +export type { LanguagesProps }; diff --git a/frontend/src/features/RepositoryDetails.tsx/components/LanguagesCard.tsx b/frontend/src/features/RepositoryDetails.tsx/components/LanguagesCard.tsx new file mode 100644 index 0000000..15d0fde --- /dev/null +++ b/frontend/src/features/RepositoryDetails.tsx/components/LanguagesCard.tsx @@ -0,0 +1,61 @@ +import { type FC } from "react"; +import clsx from "clsx"; +import type { Language } from "@/shared/types/types"; +import { LangColor } from "@/shared/constants/constants"; + +interface LanguageCardProps { + title?: string; + languages: Language[]; + className?: string; +} + +const LanguageCard: FC = ({ + title = "Languages", + languages, + className +}) => { + return ( +
+

{title}

+ +
+ {languages.map((language, index) => { + console.log(language.name); + const bgColor = LangColor[language.name] || "bg-gray-400"; + return ( +
+ ); + })} +
+ +
+ {languages.map((language, index) => { + const color = LangColor[language.name] || "bg-gray-400"; + return ( +
+
+ + {language.name} {language.percentage}% + +
+ ); + })} +
+
+ ); +}; + +export default LanguageCard; +export type { LanguageCardProps }; diff --git a/frontend/src/features/RepositoryDetails.tsx/components/Repository.tsx b/frontend/src/features/RepositoryDetails.tsx/components/Repository.tsx new file mode 100644 index 0000000..72e592f --- /dev/null +++ b/frontend/src/features/RepositoryDetails.tsx/components/Repository.tsx @@ -0,0 +1,26 @@ +import RepositoryCard from "./RepositoryCard"; +import { useRepository } from "@/api/queries/Repository"; +import { useParams } from "react-router-dom"; + +const Repository = () => { + const { repoid } = useParams(); + const repoId = Number(repoid); + const { data } = useRepository(repoId); + const repo = data?.data; + + return ( +
+ +
+ ); +}; + +export default Repository; diff --git a/frontend/src/features/RepositoryDetails.tsx/components/RepositoryActivities.tsx b/frontend/src/features/RepositoryDetails.tsx/components/RepositoryActivities.tsx new file mode 100644 index 0000000..094c87f --- /dev/null +++ b/frontend/src/features/RepositoryDetails.tsx/components/RepositoryActivities.tsx @@ -0,0 +1,91 @@ +import { useState, type FC } from "react"; +import clsx from "clsx"; +import { Button } from "@/shared/components/ui/button"; +import { Card } from "@/shared/components/ui/card"; +import ActivityCard from "@/shared/components/common/ActivityCard"; +import { Link, useParams } from "react-router-dom"; +import { TrendingUp } from "lucide-react"; +import { useRepositoryActivities } from "@/api/queries/RepostoryActivities"; + +interface RepositoryActivitiesProps { + className?: string; +} + +const RepositoryActivities: FC = ({ className }) => { + const [viewAll, setViewAll] = useState(false); + + const handleViewAll = () => { + setViewAll(!viewAll); + }; + + const { repoid } = useParams(); + const repoId = Number(repoid); + const { data, isLoading } = useRepositoryActivities(repoId); + const repositoryActivities = data?.data ?? []; + const repositoryActivitiesData = viewAll + ? repositoryActivities + : repositoryActivities?.slice(0, 4); + + return ( + +
+

Recent Activities

+ +
+ + {isLoading ? ( +
+
+
+ ) : repositoryActivitiesData?.length === 0 ? ( +
+ +

+ No recent activities found +

+
+ ) : ( +
+ {repositoryActivitiesData?.map((activity, index) => ( + + ))} + {!viewAll && ( +
+ + How does points work? + +
+ )} +
+ )} +
+ ); +}; + +export default RepositoryActivities; diff --git a/frontend/src/features/RepositoryDetails.tsx/components/RepositoryCard.tsx b/frontend/src/features/RepositoryDetails.tsx/components/RepositoryCard.tsx new file mode 100644 index 0000000..ec53fac --- /dev/null +++ b/frontend/src/features/RepositoryDetails.tsx/components/RepositoryCard.tsx @@ -0,0 +1,60 @@ +import { LangColor } from "@/shared/constants/constants"; +import { ExternalLink } from "lucide-react"; +import type { FC } from "react"; + +interface RepositoriesCardProps { + name: string; + languages: string[]; + description: string; + updatedOn: string; + owner: string; + repoUrl: string; +} + +const RepositoryCard: FC = ({ + name, + languages, + description, + owner, + repoUrl +}) => { + return ( +
+
+
+ {name} + + + +
+

+ Owned By: {owner} +

+
+ +
+ {languages?.map((language, index) => { + const color = LangColor[language] || "bg-gray-400"; + return ( +
+
+ {language} +
+ ); + })} +
+ +

+ {description || + "No description for the given repository. Lorem ipsum dolor sit amet."} +

+
+ ); +}; + +export default RepositoryCard; diff --git a/frontend/src/features/RepositoryDetails.tsx/index.tsx b/frontend/src/features/RepositoryDetails.tsx/index.tsx new file mode 100644 index 0000000..953dadf --- /dev/null +++ b/frontend/src/features/RepositoryDetails.tsx/index.tsx @@ -0,0 +1,37 @@ +import Repository from "./components/Repository"; +import Languages from "./components/Languages"; +import RecentActivities from "./components/RepositoryActivities"; +import ContributorsList from "./components/Contributors"; +import { ArrowLeft } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { Separator } from "@/shared/components/ui/separator"; + +const RepositoryDetails = () => { + const navigate = useNavigate(); + return ( +
+
+ + navigate("/my-contributions")} + > + Repository Details + +
+
+
+ + + +
+
+ + +
+
+
+ ); +}; + +export default RepositoryDetails; diff --git a/frontend/src/features/UserDashboard/components/RecentActivities.tsx b/frontend/src/features/UserDashboard/components/RecentActivities.tsx index 614807a..5b016b4 100644 --- a/frontend/src/features/UserDashboard/components/RecentActivities.tsx +++ b/frontend/src/features/UserDashboard/components/RecentActivities.tsx @@ -68,6 +68,7 @@ const RecentActivities: FC = ({ className }) => { contributedAt={activity.contributedAt} balanceChange={activity.balanceChange} showLine={index < recentActivitiesData.length - 1} + isRepositoryActivity={false} /> ))} {!viewAll && ( diff --git a/frontend/src/features/UserDashboard/index.tsx b/frontend/src/features/UserDashboard/index.tsx index dcc6332..554ee80 100644 --- a/frontend/src/features/UserDashboard/index.tsx +++ b/frontend/src/features/UserDashboard/index.tsx @@ -1,12 +1,7 @@ -import UserDashboardLayout from "@/shared/layout/UserDashboardLayout"; import UserDashboardComponent from "@/features/UserDashboard/components/UserDashboardComponent"; const UserDashboard = () => { - return ( - - - - ); + return ; }; export default UserDashboard; diff --git a/frontend/src/root/Router.tsx b/frontend/src/root/Router.tsx index dc00ece..c6a6a35 100644 --- a/frontend/src/root/Router.tsx +++ b/frontend/src/root/Router.tsx @@ -2,15 +2,28 @@ import { RouterProvider, createBrowserRouter } from "react-router-dom"; import WithAuth from "@/shared/HOC/WithAuth"; import { type RoutesType, routesConfig } from "@/root/routes-config"; +import { Layout } from "@/shared/constants/layout"; +import AuthLayout from "@/shared/layout/AuthLayout"; +import UserDashboardLayout from "@/shared/layout/UserDashboardLayout"; const generateRoutes = (routes: RoutesType[]) => { - return routes.map(({ path, element, isProtected }) => { + return routes.map(({ path, element, isProtected, layout }) => { let wrappedElement = element; if (isProtected) { wrappedElement = {wrappedElement}; } + if (layout == Layout.AuthLayout) { + wrappedElement = {wrappedElement}; + } + + if (layout == Layout.DashboardLayout) { + wrappedElement = ( + {wrappedElement} + ); + } + return { path, element: wrappedElement }; }); }; diff --git a/frontend/src/root/routes-config.tsx b/frontend/src/root/routes-config.tsx index 62633f1..4b98220 100644 --- a/frontend/src/root/routes-config.tsx +++ b/frontend/src/root/routes-config.tsx @@ -1,34 +1,46 @@ import type { ReactNode } from "react"; - +import { Layout, type LayoutType } from "@/shared/constants/layout"; import Login from "@/features/Login"; import MyContributions from "@/features/MyContributions"; import UserDashboard from "@/features/UserDashboard"; import { LOGIN_PATH, MY_CONTRIBUTIONS_PATH, + REPOSITORY_DETAILS_PATH, USER_DASHBOARD_PATH } from "@/shared/constants/routes"; +import RepositoryDetails from "@/features/RepositoryDetails.tsx"; export interface RoutesType { path: string; element: ReactNode; isProtected?: boolean; + layout: LayoutType; } export const routesConfig: RoutesType[] = [ { path: LOGIN_PATH, element: , - isProtected: false + isProtected: false, + layout: Layout.AuthLayout }, { path: USER_DASHBOARD_PATH, element: , - isProtected: false + isProtected: false, + layout: Layout.DashboardLayout }, { path: MY_CONTRIBUTIONS_PATH, element: , - isProtected: false + isProtected: false, + layout: Layout.DashboardLayout + }, + { + path: REPOSITORY_DETAILS_PATH, + element: , + isProtected: false, + layout: Layout.DashboardLayout } ]; diff --git a/frontend/src/shared/components/common/ActivityCard.tsx b/frontend/src/shared/components/common/ActivityCard.tsx index 5d55a75..fb473e9 100644 --- a/frontend/src/shared/components/common/ActivityCard.tsx +++ b/frontend/src/shared/components/common/ActivityCard.tsx @@ -1,12 +1,14 @@ import type { FC } from "react"; import Coin from "@/shared/components/common/Coin"; +import { format } from "date-fns"; interface ActivityCardProps { contributionType: string; - repositoryName: string; + repositoryName?: string; contributedAt: string; balanceChange: number; showLine: boolean; + isRepositoryActivity?: boolean; } const ActivityCard: FC = ({ @@ -14,7 +16,8 @@ const ActivityCard: FC = ({ repositoryName, contributedAt, balanceChange, - showLine = true + showLine = true, + isRepositoryActivity }) => { return (
@@ -29,14 +32,17 @@ const ActivityCard: FC = ({
{contributionType}
+ {isRepositoryActivity ? null : ( +
+ Contributed to + + <{repositoryName}> + +
+ )} +
- Contributed to{" "} - - <{repositoryName}> - -
-
- Contributed on {contributedAt} + Contributed on {format(new Date(contributedAt), "MMM d yyyy")}
diff --git a/frontend/src/shared/components/ui/avatar.tsx b/frontend/src/shared/components/ui/avatar.tsx new file mode 100644 index 0000000..834f26f --- /dev/null +++ b/frontend/src/shared/components/ui/avatar.tsx @@ -0,0 +1,51 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/shared/utils/tailwindcss" + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/frontend/src/shared/components/ui/dialog.tsx b/frontend/src/shared/components/ui/dialog.tsx new file mode 100644 index 0000000..72339f3 --- /dev/null +++ b/frontend/src/shared/components/ui/dialog.tsx @@ -0,0 +1,141 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@/shared/utils/tailwindcss" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/frontend/src/shared/constants/constants.ts b/frontend/src/shared/constants/constants.ts new file mode 100644 index 0000000..862fd73 --- /dev/null +++ b/frontend/src/shared/constants/constants.ts @@ -0,0 +1,25 @@ +export const AuthLayoutDetails = [ + "Earn and Upskill", + "Set Your Goals", + "Leader Board", + "Open Source Contribution" +]; + +export const LangColor: Record = { + JavaScript: "bg-yellow-400", + TypeScript: "bg-blue-500", + Python: "bg-green-500", + Java: "bg-red-500", + Go: "bg-cyan-500", + Rust: "bg-orange-700", + C: "bg-gray-500", + "C++": "bg-purple-600", + Ruby: "bg-pink-500", + PHP: "bg-indigo-500", + Swift: "bg-orange-400", + Kotlin: "bg-violet-500", + Dart: "bg-sky-500", + HTML: "bg-orange-300", + CSS: "bg-blue-300", + Shell: "bg-zinc-600" +}; diff --git a/frontend/src/shared/constants/layout.ts b/frontend/src/shared/constants/layout.ts new file mode 100644 index 0000000..9be0dcd --- /dev/null +++ b/frontend/src/shared/constants/layout.ts @@ -0,0 +1,8 @@ +export const Layout = { + AuthLayout: "AuthLayout", + DashboardLayout: "DashboardLayout", + None: "None" +} as const; + +export type LayoutType = (typeof Layout)[keyof typeof Layout]; + diff --git a/frontend/src/shared/constants/query-keys.ts b/frontend/src/shared/constants/query-keys.ts index 5f5b57c..f344fec 100644 --- a/frontend/src/shared/constants/query-keys.ts +++ b/frontend/src/shared/constants/query-keys.ts @@ -3,4 +3,9 @@ export const USER_BADGES_QUERY_KEY = "user-badges" export const LEADERBOARD_QUERY_KEY="leaderboard" export const CURRENT_USER_RANK_QUERY_KEY="current-user-rank" export const RECENT_ACTIVITIES_QUERY_KEY="recent-activities" -export const OVERVIEW_QUERY_KEY="overview" \ No newline at end of file +export const OVERVIEW_QUERY_KEY="overview" +export const REPOSITORIES_KEY="repositories" +export const REPOSITORY_KEY="repository" +export const REPOSITORY_CONTRIBUTORS_QUERY_KEY = "repository-contributors" +export const REPOSITORY_LANGUAGES_QUERY_KEY = "repository-languages" +export const REPOSITORY_ACTIVITIES_QUERY_KEY="repository-activites" diff --git a/frontend/src/shared/constants/routes.ts b/frontend/src/shared/constants/routes.ts index 8cbdabf..3294ebf 100644 --- a/frontend/src/shared/constants/routes.ts +++ b/frontend/src/shared/constants/routes.ts @@ -2,3 +2,4 @@ export const LOGIN_PATH = "/login"; export const USER_DASHBOARD_PATH = "/"; export const MY_CONTRIBUTIONS_PATH = "/my-contributions"; +export const REPOSITORY_DETAILS_PATH = "/repositories/:repoid"; diff --git a/frontend/src/shared/layout/AuthLayout.tsx b/frontend/src/shared/layout/AuthLayout.tsx index 9cbad0b..083ac3a 100644 --- a/frontend/src/shared/layout/AuthLayout.tsx +++ b/frontend/src/shared/layout/AuthLayout.tsx @@ -5,6 +5,7 @@ import { CheckCircle } from "lucide-react"; import { Card } from "@/shared/components/ui/card"; import { LOGIN_PATH, USER_DASHBOARD_PATH } from "@/shared/constants/routes"; import { getAccessToken } from "@/shared/utils/local-storage"; +import { AuthLayoutDetails } from "../constants/constants"; interface AuthLayoutProps { children: ReactNode; @@ -45,12 +46,7 @@ const AuthLayout: FC = ({ children }) => {
- {[ - "Earn and Upskill", - "Set Your Goals", - "Leader Board", - "Open Source Contribution" - ].map((text, i) => ( + {AuthLayoutDetails.map((text, i) => (