diff --git a/cmd/main.go b/cmd/main.go index 1a5b091..c0fc99e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -45,7 +45,7 @@ func main() { router := app.NewRouter(dependencies) newCronSchedular := cronJob.NewCronSchedular() - newCronSchedular.InitCronJobs(dependencies.ContributionService) + newCronSchedular.InitCronJobs(dependencies.ContributionService, dependencies.UserService) server := http.Server{ Addr: fmt.Sprintf(":%s", cfg.HTTPServer.Port), diff --git a/internal/app/auth/service.go b/internal/app/auth/service.go index 3bca6c0..c0cbeb4 100644 --- a/internal/app/auth/service.go +++ b/internal/app/auth/service.go @@ -83,6 +83,14 @@ func (s *service) GithubOAuthLoginCallback(ctx context.Context, code string) (st return "", apperrors.ErrInternalServer } + if userData.IsDeleted { + err = s.userService.RecoverAccountInGracePeriod(ctx, userData.Id) + if err != nil { + slog.Error("error in recovering account in grace period during login", "error", err) + return "", apperrors.ErrInternalServer + } + } + return jwtToken, nil } diff --git a/internal/app/contribution/domain.go b/internal/app/contribution/domain.go index 9316048..238f5dc 100644 --- a/internal/app/contribution/domain.go +++ b/internal/app/contribution/domain.go @@ -51,20 +51,20 @@ type ContributionScore struct { } type Transaction struct { - Id int `db:"id"` - UserId int `db:"user_id"` - ContributionId int `db:"contribution_id"` - IsRedeemed bool `db:"is_redeemed"` - IsGained bool `db:"is_gained"` - TransactedBalance int `db:"transacted_balance"` - TransactedAt time.Time `db:"transacted_at"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + Id int + UserId int + ContributionId int + IsRedeemed bool + IsGained bool + TransactedBalance int + TransactedAt time.Time + CreatedAt time.Time + UpdatedAt time.Time } -type ContributionTypeSummary struct { - ContributionType string `db:"contribution_type"` - ContributionCount int `db:"contribution_count"` - TotalCoins int `db:"total_coins"` - Month time.Time `db:"month"` +type MonthlyContributionSummary struct { + Type string + Count int + TotalCoins int + Month time.Time } diff --git a/internal/app/contribution/handler.go b/internal/app/contribution/handler.go index 85c5e7d..8d604ad 100644 --- a/internal/app/contribution/handler.go +++ b/internal/app/contribution/handler.go @@ -5,7 +5,9 @@ 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" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/utils" ) type handler struct { @@ -14,7 +16,7 @@ type handler struct { type Handler interface { FetchUserContributions(w http.ResponseWriter, r *http.Request) - GetContributionTypeSummaryForMonth(w http.ResponseWriter, r *http.Request) + ListMonthlyContributionSummary(w http.ResponseWriter, r *http.Request) } func NewHandler(contributionService Service) Handler { @@ -28,7 +30,7 @@ func (h *handler) FetchUserContributions(w http.ResponseWriter, r *http.Request) userContributions, err := h.contributionService.FetchUserContributions(ctx) if err != nil { - slog.Error("error fetching user contributions") + slog.Error("error fetching user contributions", "error", err) status, errorMessage := apperrors.MapError(err) response.WriteJson(w, status, errorMessage, nil) return @@ -37,18 +39,43 @@ func (h *handler) FetchUserContributions(w http.ResponseWriter, r *http.Request) response.WriteJson(w, http.StatusOK, "user contributions fetched successfully", userContributions) } -func (h *handler) GetContributionTypeSummaryForMonth(w http.ResponseWriter, r *http.Request) { +func (h *handler) ListMonthlyContributionSummary(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - month := r.URL.Query().Get("month") + 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 + } + + yearVal := r.URL.Query().Get("year") + year, err := utils.ValidateYearQueryParam(yearVal) + if err != nil { + slog.Error("error converting year value to integer", "error", err) + status, errorMessage := apperrors.MapError(apperrors.ErrContextValue) + response.WriteJson(w, status, errorMessage, nil) + return + } + + monthVal := r.URL.Query().Get("month") + month, err := utils.ValidateMonthQueryParam(monthVal) + if err != nil { + slog.Error("error converting month value to integer", "error", err) + status, errorMessage := apperrors.MapError(apperrors.ErrContextValue) + response.WriteJson(w, status, errorMessage, nil) + return + } - contributionTypeSummaryForMonth, err := h.contributionService.GetContributionTypeSummaryForMonth(ctx, month) + monthlyContributionSummary, err := h.contributionService.ListMonthlyContributionSummary(ctx, year, month, userId) if err != nil { - slog.Error("error fetching contribution type summary for month") + slog.Error("error fetching contribution type summary for month", "error", err) status, errorMessage := apperrors.MapError(err) response.WriteJson(w, status, errorMessage, nil) return } - response.WriteJson(w, http.StatusOK, "contribution type overview for month fetched successfully", contributionTypeSummaryForMonth) + response.WriteJson(w, http.StatusOK, "contribution type overview for month fetched successfully", monthlyContributionSummary) } diff --git a/internal/app/contribution/service.go b/internal/app/contribution/service.go index 88d4d77..610918a 100644 --- a/internal/app/contribution/service.go +++ b/internal/app/contribution/service.go @@ -3,10 +3,8 @@ package contribution import ( "context" "encoding/json" - "errors" "log/slog" "net/http" - "time" "github.com/joshsoftware/code-curiosity-2025/internal/app/bigquery" repoService "github.com/joshsoftware/code-curiosity-2025/internal/app/repository" @@ -68,7 +66,7 @@ type Service interface { GetContributionScoreDetailsByContributionType(ctx context.Context, contributionType string) (ContributionScore, error) FetchUserContributions(ctx context.Context) ([]Contribution, error) GetContributionByGithubEventId(ctx context.Context, githubEventId string) (Contribution, error) - GetContributionTypeSummaryForMonth(ctx context.Context, monthParam string) ([]ContributionTypeSummary, error) + ListMonthlyContributionSummary(ctx context.Context, year int, monthParam int, userId int) ([]MonthlyContributionSummary, error) } func NewService(bigqueryService bigquery.Service, contributionRepository repository.ContributionRepository, repositoryService repoService.Service, userService user.Service, transactionService transaction.Service, httpClient *http.Client) Service { @@ -291,34 +289,19 @@ func (s *service) GetContributionByGithubEventId(ctx context.Context, githubEven return Contribution(contribution), nil } -func (s *service) GetContributionTypeSummaryForMonth(ctx context.Context, monthParam string) ([]ContributionTypeSummary, error) { - month, err := time.Parse("2006-01", monthParam) - if err != nil { - slog.Error("error parsing month query parameter", "error", err) - return nil, err - } +func (s *service) ListMonthlyContributionSummary(ctx context.Context, year int, month int, userId int) ([]MonthlyContributionSummary, error) { - contributionTypes, err := s.contributionRepository.GetAllContributionTypes(ctx, nil) + MonthlyContributionSummaries, err := s.contributionRepository.ListMonthlyContributionSummary(ctx, nil, year, month, userId) if err != nil { - slog.Error("error fetching contribution types", "error", err) + slog.Error("error fetching monthly contribution summary", "error", err) return nil, err } - var contributionTypeSummaryForMonth []ContributionTypeSummary - - for _, contributionType := range contributionTypes { - contributionTypeSummary, err := s.contributionRepository.GetContributionTypeSummaryForMonth(ctx, nil, contributionType.ContributionType, month) - if err != nil { - if errors.Is(err, apperrors.ErrNoContributionForContributionType) { - contributionTypeSummaryForMonth = append(contributionTypeSummaryForMonth, ContributionTypeSummary{ContributionType: contributionType.ContributionType}) - continue - } - slog.Error("error fetching contribution type summary", "error", err) - return nil, err - } + serviceMonthlyContributionSummaries := make([]MonthlyContributionSummary, len(MonthlyContributionSummaries)) - contributionTypeSummaryForMonth = append(contributionTypeSummaryForMonth, ContributionTypeSummary(contributionTypeSummary)) + for i, c := range MonthlyContributionSummaries { + serviceMonthlyContributionSummaries[i] = MonthlyContributionSummary(c) } - return contributionTypeSummaryForMonth, nil + return serviceMonthlyContributionSummaries, nil } diff --git a/internal/app/cronJob/cleanupJob.go b/internal/app/cronJob/cleanupJob.go new file mode 100644 index 0000000..d87b3c9 --- /dev/null +++ b/internal/app/cronJob/cleanupJob.go @@ -0,0 +1,32 @@ +package cronJob + +import ( + "context" + + "github.com/joshsoftware/code-curiosity-2025/internal/app/user" +) + +type CleanupJob struct { + CronJob + userService user.Service +} + +func NewCleanupJob(userService user.Service) *CleanupJob { + return &CleanupJob{ + userService: userService, + CronJob: CronJob{Name: "User Cleanup Job Daily"}, + } +} + +func (c *CleanupJob) Schedule(s *CronSchedular) error { + _, err := s.cron.AddFunc("00 18 * * *", func() { c.Execute(context.Background(), c.run) }) + if err != nil { + return err + } + + return nil +} + +func (c *CleanupJob) run(ctx context.Context) { + c.userService.HardDeleteUsers(ctx) +} diff --git a/internal/app/cronJob/init.go b/internal/app/cronJob/init.go index 7fba0f3..77d9f78 100644 --- a/internal/app/cronJob/init.go +++ b/internal/app/cronJob/init.go @@ -5,6 +5,7 @@ import ( "time" "github.com/joshsoftware/code-curiosity-2025/internal/app/contribution" + "github.com/joshsoftware/code-curiosity-2025/internal/app/user" "github.com/robfig/cron/v3" ) @@ -23,9 +24,10 @@ func NewCronSchedular() *CronSchedular { } } -func (c *CronSchedular) InitCronJobs(contributionService contribution.Service) { +func (c *CronSchedular) InitCronJobs(contributionService contribution.Service, userService user.Service) { jobs := []Job{ NewDailyJob(contributionService), + NewCleanupJob(userService), } for _, job := range jobs { diff --git a/internal/app/dependencies.go b/internal/app/dependencies.go index 86d4c3d..cb95a53 100644 --- a/internal/app/dependencies.go +++ b/internal/app/dependencies.go @@ -18,6 +18,7 @@ import ( type Dependencies struct { ContributionService contribution.Service + UserService user.Service AuthHandler auth.Handler UserHandler user.Handler ContributionHandler contribution.Handler @@ -47,6 +48,7 @@ func InitDependencies(db *sqlx.DB, appCfg config.AppConfig, client config.Bigque return Dependencies{ ContributionService: contributionService, + UserService: userService, AuthHandler: authHandler, UserHandler: userHandler, RepositoryHandler: repositoryHandler, diff --git a/internal/app/router.go b/internal/app/router.go index c61e717..76759a1 100644 --- a/internal/app/router.go +++ b/internal/app/router.go @@ -19,16 +19,17 @@ func NewRouter(deps Dependencies) http.Handler { router.HandleFunc("GET /api/v1/auth/user", middleware.Authentication(deps.AuthHandler.GetLoggedInUser, deps.AppCfg)) 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/monthlyoverview", middleware.Authentication(deps.ContributionHandler.GetContributionTypeSummaryForMonth, deps.AppCfg)) + router.HandleFunc("GET /api/v1/user/overview", middleware.Authentication(deps.ContributionHandler.ListMonthlyContributionSummary, 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)) 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.GetAllUsersRank, 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/handler.go b/internal/app/user/handler.go index 3f085b8..1a0dc22 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,7 +16,8 @@ type handler struct { type Handler interface { UpdateUserEmail(w http.ResponseWriter, r *http.Request) - GetAllUsersRank(w http.ResponseWriter, r *http.Request) + SoftDeleteUser(w http.ResponseWriter, r *http.Request) + ListUserRanks(w http.ResponseWriter, r *http.Request) GetCurrentUserRank(w http.ResponseWriter, r *http.Request) } @@ -47,7 +49,31 @@ func (h *handler) UpdateUserEmail(w http.ResponseWriter, r *http.Request) { response.WriteJson(w, http.StatusOK, "email updated successfully", nil) } -func (h *handler) GetAllUsersRank(w http.ResponseWriter, r *http.Request) { +func (h *handler) SoftDeleteUser(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 + } + + err := h.userService.SoftDeleteUser(ctx, userId) + if err != nil { + slog.Error("failed to softdelete user", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "user scheduled for deletion", nil) +} + +func (h *handler) ListUserRanks(w http.ResponseWriter, r *http.Request) { ctx := r.Context() leaderboard, err := h.userService.GetAllUsersRank(ctx) @@ -64,7 +90,17 @@ func (h *handler) GetAllUsersRank(w http.ResponseWriter, r *http.Request) { func (h *handler) GetCurrentUserRank(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - currentUserRank, err := h.userService.GetCurrentUserRank(ctx) + 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) diff --git a/internal/app/user/service.go b/internal/app/user/service.go index 0aed35e..fa37e18 100644 --- a/internal/app/user/service.go +++ b/internal/app/user/service.go @@ -3,6 +3,7 @@ package user import ( "context" "log/slog" + "time" "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" "github.com/joshsoftware/code-curiosity-2025/internal/pkg/middleware" @@ -18,9 +19,12 @@ type Service interface { GetUserByGithubId(ctx context.Context, githubId int) (User, error) CreateUser(ctx context.Context, userInfo CreateUserRequestBody) (User, error) UpdateUserEmail(ctx context.Context, email string) error + SoftDeleteUser(ctx context.Context, userId int) error + HardDeleteUsers(ctx context.Context) error + RecoverAccountInGracePeriod(ctx context.Context, userID int) error UpdateUserCurrentBalance(ctx context.Context, transaction Transaction) error GetAllUsersRank(ctx context.Context) ([]LeaderboardUser, error) - GetCurrentUserRank(ctx context.Context) (LeaderboardUser, error) + GetCurrentUserRank(ctx context.Context, userId int) (LeaderboardUser, error) } func NewService(userRepository repository.UserRepository) Service { @@ -78,6 +82,35 @@ func (s *service) UpdateUserEmail(ctx context.Context, email string) error { return nil } +func (s *service) SoftDeleteUser(ctx context.Context, userID int) error { + now := time.Now() + err := s.userRepository.MarkUserAsDeleted(ctx, nil, userID, now) + if err != nil { + slog.Error("unable to softdelete user", "error", err) + return apperrors.ErrInternalServer + } + return nil +} + +func (s *service) HardDeleteUsers(ctx context.Context) error { + err := s.userRepository.HardDeleteUsers(ctx, nil) + if err != nil { + slog.Error("error deleting users that are soft deleted for more than three months", "error", err) + return err + } + + return nil +} + +func (s *service) RecoverAccountInGracePeriod(ctx context.Context, userID int) error { + err := s.userRepository.RecoverAccountInGracePeriod(ctx, nil, userID) + if err != nil { + slog.Error("failed to recover account in grace period", "error", err) + return err + } + return nil +} + func (s *service) UpdateUserCurrentBalance(ctx context.Context, transaction Transaction) error { user, err := s.GetUserById(ctx, transaction.UserId) if err != nil { @@ -102,22 +135,22 @@ func (s *service) UpdateUserCurrentBalance(ctx context.Context, transaction Tran } func (s *service) GetAllUsersRank(ctx context.Context) ([]LeaderboardUser, error) { - leaderboard, err := s.userRepository.GetAllUsersRank(ctx, nil) + userRanks, err := s.userRepository.GetAllUsersRank(ctx, nil) if err != nil { slog.Error("error obtaining all users rank", "error", err) return nil, err } - serviceLeaderboard := make([]LeaderboardUser, len(leaderboard)) - for i, l := range leaderboard { - serviceLeaderboard[i] = LeaderboardUser((l)) + Leaderboard := make([]LeaderboardUser, len(userRanks)) + for i, l := range userRanks { + Leaderboard[i] = LeaderboardUser(l) } - return serviceLeaderboard, nil + return Leaderboard, nil } -func (s *service) GetCurrentUserRank(ctx context.Context) (LeaderboardUser, error) { - currentUserRank, err := s.userRepository.GetCurrentUserRank(ctx, 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 diff --git a/internal/db/migrate.go b/internal/db/migrate.go index f2020e4..6b9a159 100644 --- a/internal/db/migrate.go +++ b/internal/db/migrate.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "os" + "strconv" "time" "github.com/golang-migrate/migrate/v4" @@ -41,23 +42,51 @@ func InitMainDBMigrations(config config.AppConfig) (migration Migration, er erro return } -// MigrationsUp used to make migrations up -func (migration Migration) MigrationsUp() { +func (migration Migration) MigrationsUpAll() { err := migration.m.Up() if err != nil { if err == migrate.ErrNoChange { slog.Error("No new migrations to apply") return } - slog.Error("*******" + err.Error()) + slog.Error("An error occurred while making migrations up", "error", err) return } - migration.MigrationVersion() + slog.Info("Current migration version:", "version", migration.MigrationVersion()) slog.Info("Migration up completed") } -// MigrationsDown used to make migrations down -func (migration Migration) MigrationsDown() { +func (migration Migration) MigrationsUpWithSteps(steps int) { + if err := migration.m.Steps(steps); err != nil { + if err == migrate.ErrNoChange { + slog.Error("No new migrations to apply") + return + } + + slog.Error("An error occurred while making migrations up", "error", err) + return + } + + slog.Info("Current migration version:", "version", migration.MigrationVersion()) + slog.Info("Migration up completed") +} + +// MigrationsUp used to make migrations up +func (migration Migration) MigrationsUp(steps string) { + if len(steps) == 0 { + migration.MigrationsUpAll() + } else { + stepsCnt, err := strconv.Atoi(steps) + if err != nil { + slog.Error("Failed to parse steps argument to integer", "error", err) + return + } + + migration.MigrationsUpWithSteps(stepsCnt) + } +} + +func (migration Migration) MigrationsDownAll() { err := migration.m.Down() if err != nil { if err == migrate.ErrNoChange { @@ -65,13 +94,43 @@ func (migration Migration) MigrationsDown() { return } - slog.Error(err.Error()) + slog.Error("An error occurred while making migrations down", "error", err) + return + } + slog.Info("Current migration version:", "version", migration.MigrationVersion()) + slog.Info("Migration down completed") +} + +func (migration Migration) MigrationsDownWithSteps(steps int) { + if err := migration.m.Steps(-1 * steps); err != nil { + if err == migrate.ErrNoChange { + slog.Error("No migrations to revert") + return + } + + slog.Error("An error occurred while making migrations down", "error", err) return } - migration.MigrationVersion() + + slog.Info("Current migration version:", "version", migration.MigrationVersion()) slog.Info("Migration down completed") } +// MigrationsDown used to make migrations down +func (migration Migration) MigrationsDown(steps string) { + if len(steps) == 0 { + migration.MigrationsDownAll() + } else { + stepsCnt, err := strconv.Atoi(steps) + if err != nil { + slog.Error("Failed to parse steps argument to integer", "error", err) + return + } + + migration.MigrationsDownWithSteps(stepsCnt) + } +} + // CreateMigrationFile creates new migration files func (migration Migration) CreateMigrationFile(filename string) (err error) { if len(filename) == 0 { @@ -136,7 +195,7 @@ func main() { } if len(os.Args) < 2 { - slog.Error("Missing action argument. Use 'up' or 'down' or 'create.") + slog.Error("Missing action argument. Use 'up' or 'down' or 'create'.") os.Exit(1) } @@ -147,14 +206,18 @@ func main() { } action := os.Args[1] + var steps string + if len(os.Args) > 2 { + steps = os.Args[2] + } switch action { case "up": - migration.MigrationsUp() + migration.MigrationsUp(steps) case "down": - migration.MigrationsDown() + migration.MigrationsDown(steps) case "create": - migration.CreateMigrationFile(os.Args[2]) + migration.CreateMigrationFile(steps) default: - slog.Info("Invalid action. Use 'up' or 'down'.") + slog.Info("Invalid action. Use 'up' or 'down' or 'create'.") } } diff --git a/internal/db/migrations/1748862201_init.up.sql b/internal/db/migrations/1748862201_init.up.sql index 476693a..c06fb1d 100644 --- a/internal/db/migrations/1748862201_init.up.sql +++ b/internal/db/migrations/1748862201_init.up.sql @@ -113,28 +113,28 @@ CREATE TABLE "goal_contribution"( ); ALTER TABLE - "goal_contribution" ADD CONSTRAINT "goal_contribution_set_by_user_id_foreign" FOREIGN KEY("set_by_user_id") REFERENCES "users"("id"); + "goal_contribution" ADD CONSTRAINT "goal_contribution_set_by_user_id_foreign" FOREIGN KEY("set_by_user_id") REFERENCES "users"("id") ON DELETE CASCADE; ALTER TABLE "goal_contribution" ADD CONSTRAINT "goal_contribution_contribution_score_id_foreign" FOREIGN KEY("contribution_score_id") REFERENCES "contribution_score"("id"); ALTER TABLE - "contribution_score" ADD CONSTRAINT "contribution_score_admin_id_foreign" FOREIGN KEY("admin_id") REFERENCES "users"("id"); + "contribution_score" ADD CONSTRAINT "contribution_score_admin_id_foreign" FOREIGN KEY("admin_id") REFERENCES "users"("id") ON DELETE CASCADE; ALTER TABLE - "summary" ADD CONSTRAINT "summary_user_id_foreign" FOREIGN KEY("user_id") REFERENCES "users"("id"); + "summary" ADD CONSTRAINT "summary_user_id_foreign" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE; ALTER TABLE - "transactions" ADD CONSTRAINT "transactions_user_id_foreign" FOREIGN KEY("user_id") REFERENCES "users"("id"); + "transactions" ADD CONSTRAINT "transactions_user_id_foreign" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE; ALTER TABLE "contributions" ADD CONSTRAINT "contributions_contribution_score_id_foreign" FOREIGN KEY("contribution_score_id") REFERENCES "contribution_score"("id"); ALTER TABLE - "badges" ADD CONSTRAINT "badges_user_id_foreign" FOREIGN KEY("user_id") REFERENCES "users"("id"); + "badges" ADD CONSTRAINT "badges_user_id_foreign" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE; ALTER TABLE "goal_contribution" ADD CONSTRAINT "goal_contribution_goal_id_foreign" FOREIGN KEY("goal_id") REFERENCES "goal"("id"); ALTER TABLE "transactions" ADD CONSTRAINT "transactions_contribution_id_foreign" FOREIGN KEY("contribution_id") REFERENCES "contributions"("id"); ALTER TABLE - "contributions" ADD CONSTRAINT "contributions_user_id_foreign" FOREIGN KEY("user_id") REFERENCES "users"("id"); + "contributions" ADD CONSTRAINT "contributions_user_id_foreign" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE; ALTER TABLE "contributions" ADD CONSTRAINT "contributions_repository_id_foreign" FOREIGN KEY("repository_id") REFERENCES "repositories"("id"); ALTER TABLE - "leaderboard_hourly" ADD CONSTRAINT "leaderboard_hourly_user_id_foreign" FOREIGN KEY("user_id") REFERENCES "users"("id"); + "leaderboard_hourly" ADD CONSTRAINT "leaderboard_hourly_user_id_foreign" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE; ALTER TABLE "summary" ADD CONSTRAINT "summary_contribution_id_foreign" FOREIGN KEY("contribution_id") REFERENCES "contributions"("id"); \ No newline at end of file diff --git a/internal/db/migrations/1751016438_allow-null-contribution-id.down.sql b/internal/db/migrations/1751016438_allow-null-contribution-id.down.sql index c44d425..ff2f133 100644 --- a/internal/db/migrations/1751016438_allow-null-contribution-id.down.sql +++ b/internal/db/migrations/1751016438_allow-null-contribution-id.down.sql @@ -1,2 +1,2 @@ ALTER TABLE transactions -ALTER COLUMN contribution_id SET NOT NULL DEFAULT 0; \ No newline at end of file +ALTER COLUMN contribution_id SET NOT NULL; \ No newline at end of file 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 930ce96..4c0430c 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") @@ -54,7 +55,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/pkg/utils/helper.go b/internal/pkg/utils/helper.go index 3fa0492..e324c8d 100644 --- a/internal/pkg/utils/helper.go +++ b/internal/pkg/utils/helper.go @@ -5,7 +5,11 @@ import ( "io" "log/slog" "net/http" + "strconv" "strings" + "time" + + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" ) func FormatIntSliceForQuery(ids []int) string { @@ -43,3 +47,33 @@ func DoGet(httpClient *http.Client, url string, headers map[string]string) ([]by return body, nil } + +func ValidateYearQueryParam(yearVal string) (int, error) { + year, err := strconv.Atoi(yearVal) + if err != nil { + slog.Error("error converting year string value to int") + return 0, err + } + + if year < 2025 || year > time.Now().Year() { + slog.Error("invalid year value") + return 0, apperrors.ErrInvalidQueryParams + } + + return year, nil +} + +func ValidateMonthQueryParam(monthVal string) (int, error) { + month, err := strconv.Atoi(monthVal) + if err != nil { + slog.Error("error converting month string value to int") + return 0, err + } + + if month < 0 || month > 12 { + slog.Error("invalid month value") + return 0, apperrors.ErrInvalidQueryParams + } + + return month, nil +} diff --git a/internal/repository/contribution.go b/internal/repository/contribution.go index 697028b..da5163a 100644 --- a/internal/repository/contribution.go +++ b/internal/repository/contribution.go @@ -5,7 +5,6 @@ import ( "database/sql" "errors" "log/slog" - "time" "github.com/jmoiron/sqlx" "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" @@ -23,7 +22,7 @@ type ContributionRepository interface { FetchUserContributions(ctx context.Context, tx *sqlx.Tx) ([]Contribution, error) GetContributionByGithubEventId(ctx context.Context, tx *sqlx.Tx, githubEventId string) (Contribution, error) GetAllContributionTypes(ctx context.Context, tx *sqlx.Tx) ([]ContributionScore, error) - GetContributionTypeSummaryForMonth(ctx context.Context, tx *sqlx.Tx, contributionType string, month time.Time) (ContributionTypeSummary, error) + ListMonthlyContributionSummary(ctx context.Context, tx *sqlx.Tx, year int, month int, userId int) ([]MonthlyContributionSummary, error) } func NewContributionRepository(db *sqlx.DB) ContributionRepository { @@ -54,17 +53,15 @@ const ( getAllContributionTypesQuery = `SELECT * from contribution_score` - GetContributionTypeSummaryForMonthQuery = ` + getMonthlyContributionSummaryQuery = ` SELECT DATE_TRUNC('month', contributed_at) AS month, - contribution_type, + contribution_type, COUNT(*) AS contribution_count, SUM(balance_change) AS total_coins FROM contributions WHERE user_id = $1 - AND contribution_type = $2 - AND contributed_at >= DATE_TRUNC('month', $3::timestamptz) - AND contributed_at < DATE_TRUNC('month', $3::timestamptz) + INTERVAL '1 month' + AND DATE_TRUNC('month', contributed_at) = MAKE_DATE($2, $3, 1)::timestamptz GROUP BY month, contribution_type;` ) @@ -160,25 +157,14 @@ func (cr *contributionRepository) GetAllContributionTypes(ctx context.Context, t return contributionTypes, nil } -func (cr *contributionRepository) GetContributionTypeSummaryForMonth(ctx context.Context, tx *sqlx.Tx, contributionType string, month time.Time) (ContributionTypeSummary, error) { - userIdValue := ctx.Value(middleware.UserIdKey) - - userId, ok := userIdValue.(int) - if !ok { - slog.Error("error obtaining user id from context") - return ContributionTypeSummary{}, apperrors.ErrInternalServer - } - +func (cr *contributionRepository) ListMonthlyContributionSummary(ctx context.Context, tx *sqlx.Tx, year int, month int, userId int) ([]MonthlyContributionSummary, error) { executer := cr.BaseRepository.initiateQueryExecuter(tx) - var contributionTypeSummary ContributionTypeSummary - err := executer.GetContext(ctx, &contributionTypeSummary, GetContributionTypeSummaryForMonthQuery, userId, contributionType, month) + var contributionTypeSummary []MonthlyContributionSummary + err := executer.SelectContext(ctx, &contributionTypeSummary, getMonthlyContributionSummaryQuery, userId, month) if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return ContributionTypeSummary{}, apperrors.ErrNoContributionForContributionType - } - slog.Error("error fetching contribution summary for contribution type", "error", err) - return ContributionTypeSummary{}, apperrors.ErrInternalServer + slog.Error("error fetching monthly contribution summary for user", "error", err) + return nil, apperrors.ErrInternalServer } return contributionTypeSummary, nil diff --git a/internal/repository/domain.go b/internal/repository/domain.go index 9d75663..4ac6312 100644 --- a/internal/repository/domain.go +++ b/internal/repository/domain.go @@ -86,9 +86,9 @@ type LeaderboardUser struct { Rank int `db:"rank"` } -type ContributionTypeSummary struct { - ContributionType string `db:"contribution_type"` - ContributionCount int `db:"contribution_count"` - TotalCoins int `db:"total_coins"` - Month time.Time `db:"month"` +type MonthlyContributionSummary struct { + Type string `db:"contribution_type"` + Count int `db:"contribution_count"` + TotalCoins int `db:"total_coins"` + Month time.Time `db:"month"` } diff --git a/internal/repository/user.go b/internal/repository/user.go index 82f660a..d9a6cc9 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -9,7 +9,6 @@ import ( "github.com/jmoiron/sqlx" "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" - "github.com/joshsoftware/code-curiosity-2025/internal/pkg/middleware" ) type userRepository struct { @@ -22,10 +21,13 @@ type UserRepository interface { GetUserByGithubId(ctx context.Context, tx *sqlx.Tx, githubId int) (User, error) CreateUser(ctx context.Context, tx *sqlx.Tx, userInfo CreateUserRequestBody) (User, error) UpdateUserEmail(ctx context.Context, tx *sqlx.Tx, userId int, email string) error + MarkUserAsDeleted(ctx context.Context, tx *sqlx.Tx, userID int, deletedAt time.Time) error + RecoverAccountInGracePeriod(ctx context.Context, tx *sqlx.Tx, userID int) error + HardDeleteUsers(ctx context.Context, tx *sqlx.Tx) 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) (LeaderboardUser, error) + GetCurrentUserRank(ctx context.Context, tx *sqlx.Tx, userId int) (LeaderboardUser, error) } func NewUserRepository(db *sqlx.DB) UserRepository { @@ -51,6 +53,12 @@ const ( updateEmailQuery = "UPDATE users SET email=$1, updated_at=$2 where id=$3" + markUserAsDeletedQuery = "UPDATE users SET is_deleted = TRUE, deleted_at=$1 where id = $2" + + recoverAccountInGracePeriodQuery = "UPDATE users SET is_deleted = false, deleted_at = NULL where id = $1" + + hardDeleteUsersQuery = "DELETE FROM users WHERE is_deleted = TRUE AND deleted_at <= $1" + getAllUsersGithubIdQuery = "SELECT github_id from users" updateUserCurrentBalanceQuery = "UPDATE users SET current_balance=$1, updated_at=$2 where id=$3" @@ -146,6 +154,44 @@ func (ur *userRepository) UpdateUserEmail(ctx context.Context, tx *sqlx.Tx, user return nil } +func (ur *userRepository) MarkUserAsDeleted(ctx context.Context, tx *sqlx.Tx, userID int, deletedAt time.Time) error { + executer := ur.BaseRepository.initiateQueryExecuter(tx) + + _, err := executer.ExecContext(ctx, markUserAsDeletedQuery, deletedAt, userID) + if err != nil { + slog.Error("unable to mark user as deleted", "error", err) + return apperrors.ErrInternalServer + } + + return nil +} + +func (ur *userRepository) RecoverAccountInGracePeriod(ctx context.Context, tx *sqlx.Tx, userID int) error { + executer := ur.BaseRepository.initiateQueryExecuter(tx) + + _, err := executer.ExecContext(ctx, recoverAccountInGracePeriodQuery, userID) + if err != nil { + slog.Error("unable to reverse the soft delete ", "error", err) + return apperrors.ErrInternalServer + } + + return nil +} + +func (ur *userRepository) HardDeleteUsers(ctx context.Context, tx *sqlx.Tx) error { + executer := ur.BaseRepository.initiateQueryExecuter(tx) + + threshold := time.Now().Add(-90 * 1 * time.Second) + + _, err := executer.ExecContext(ctx, hardDeleteUsersQuery, threshold) + if err != nil { + slog.Error("error deleting users that are soft deleted for more than three months", "error", err) + return apperrors.ErrInternalServer + } + + return err +} + func (ur *userRepository) GetAllUsersGithubId(ctx context.Context, tx *sqlx.Tx) ([]int, error) { executer := ur.BaseRepository.initiateQueryExecuter(tx) @@ -184,14 +230,7 @@ func (ur *userRepository) GetAllUsersRank(ctx context.Context, tx *sqlx.Tx) ([]L return leaderboard, nil } -func (ur *userRepository) GetCurrentUserRank(ctx context.Context, tx *sqlx.Tx) (LeaderboardUser, error) { - userIdValue := ctx.Value(middleware.UserIdKey) - - userId, ok := userIdValue.(int) - if !ok { - slog.Error("error obtaining user id from context") - return LeaderboardUser{}, apperrors.ErrInternalServer - } +func (ur *userRepository) GetCurrentUserRank(ctx context.Context, tx *sqlx.Tx, userId int) (LeaderboardUser, error) { executer := ur.BaseRepository.initiateQueryExecuter(tx) @@ -203,4 +242,4 @@ func (ur *userRepository) GetCurrentUserRank(ctx context.Context, tx *sqlx.Tx) ( } return currentUserRank, nil -} +} \ No newline at end of file