diff --git a/cmd/main.go b/cmd/main.go index 947cd10..1a5b091 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -12,6 +12,7 @@ import ( "time" "github.com/joshsoftware/code-curiosity-2025/internal/app" + "github.com/joshsoftware/code-curiosity-2025/internal/app/cronJob" "github.com/joshsoftware/code-curiosity-2025/internal/config" ) @@ -43,6 +44,9 @@ func main() { router := app.NewRouter(dependencies) + newCronSchedular := cronJob.NewCronSchedular() + newCronSchedular.InitCronJobs(dependencies.ContributionService) + server := http.Server{ Addr: fmt.Sprintf(":%s", cfg.HTTPServer.Port), Handler: router, diff --git a/go.mod b/go.mod index f308cae..e73f2e2 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/ilyakaznacheev/cleanenv v1.5.0 github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.10.9 + github.com/robfig/cron/v3 v3.0.1 golang.org/x/oauth2 v0.29.0 google.golang.org/api v0.231.0 ) diff --git a/go.sum b/go.sum index d272ace..a599248 100644 --- a/go.sum +++ b/go.sum @@ -133,6 +133,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgm github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= diff --git a/internal/app/bigquery/domain.go b/internal/app/bigquery/domain.go index 3a8575a..bedd595 100644 --- a/internal/app/bigquery/domain.go +++ b/internal/app/bigquery/domain.go @@ -2,6 +2,35 @@ package bigquery import "time" +const DailyQuery = `SELECT + id, + type, + public, + actor.id AS actor_id, + actor.login AS actor_login, + actor.gravatar_id AS actor_gravatar_id, + actor.url AS actor_url, + actor.avatar_url AS actor_avatar_url, + repo.id AS repo_id, + repo.name AS repo_name, + repo.url AS repo_url, + payload, + created_at, + other +FROM + githubarchive.day.%s +WHERE + type IN ( + 'IssuesEvent', + 'PullRequestEvent', + 'PullRequestReviewEvent', + 'IssueCommentEvent', + 'PullRequestReviewCommentEvent' + ) + AND ( + actor.id IN (%s) + )` + type ContributionResponse struct { ID string `bigquery:"id"` Type string `bigquery:"type"` diff --git a/internal/app/bigquery/service.go b/internal/app/bigquery/service.go index 7a15d7c..21a2064 100644 --- a/internal/app/bigquery/service.go +++ b/internal/app/bigquery/service.go @@ -4,12 +4,12 @@ import ( "context" "fmt" "log/slog" - "strings" "time" bq "cloud.google.com/go/bigquery" "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/utils" "github.com/joshsoftware/code-curiosity-2025/internal/repository" ) @@ -30,51 +30,17 @@ func NewService(bigqueryInstance config.Bigquery, userRepository repository.User } func (s *service) FetchDailyContributions(ctx context.Context) (*bq.RowIterator, error) { - YesterdayDate := time.Now().AddDate(0, 0, -1) - YesterdayYearMonthDay := YesterdayDate.Format("20060102") - - usersNamesList, err := s.userRepository.GetAllUsersGithubUsernames(ctx, nil) + usersGithubId, err := s.userRepository.GetAllUsersGithubId(ctx, nil) if err != nil { slog.Error("error fetching users github usernames") return nil, apperrors.ErrInternalServer } - var quotedUsernamesList []string - for _, username := range usersNamesList { - quotedUsernamesList = append(quotedUsernamesList, fmt.Sprintf("'%s'", username)) - } + formattedGithubIds := utils.FormatIntSliceForQuery(usersGithubId) - githubUsernames := strings.Join(quotedUsernamesList, ",") - fetchDailyContributionsQuery := fmt.Sprintf(` -SELECT - id, - type, - public, - actor.id AS actor_id, - actor.login AS actor_login, - actor.gravatar_id AS actor_gravatar_id, - actor.url AS actor_url, - actor.avatar_url AS actor_avatar_url, - repo.id AS repo_id, - repo.name AS repo_name, - repo.url AS repo_url, - payload, - created_at, - other -FROM - githubarchive.day.%s -WHERE - type IN ( - 'IssuesEvent', - 'PullRequestEvent', - 'PullRequestReviewEvent', - 'IssueCommentEvent', - 'PullRequestReviewCommentEvent' - ) - AND ( - actor.login IN (%s) - ) -`, YesterdayYearMonthDay, githubUsernames) + YesterdayDate := time.Now().AddDate(0, 0, -1) + YesterdayYearMonthDay := YesterdayDate.Format("20060102") + fetchDailyContributionsQuery := fmt.Sprintf(DailyQuery, YesterdayYearMonthDay, formattedGithubIds) bigqueryQuery := s.bigqueryInstance.Client.Query(fetchDailyContributionsQuery) contributionRows, err := bigqueryQuery.Read(ctx) diff --git a/internal/app/contribution/domain.go b/internal/app/contribution/domain.go index 5fecd14..295c851 100644 --- a/internal/app/contribution/domain.go +++ b/internal/app/contribution/domain.go @@ -36,6 +36,7 @@ type Contribution struct { ContributionType string BalanceChange int ContributedAt time.Time + GithubEventId string CreatedAt time.Time UpdatedAt time.Time } diff --git a/internal/app/contribution/handler.go b/internal/app/contribution/handler.go index 19b9d2e..2c2045d 100644 --- a/internal/app/contribution/handler.go +++ b/internal/app/contribution/handler.go @@ -13,8 +13,7 @@ type handler struct { } type Handler interface { - FetchUserLatestContributions(w http.ResponseWriter, r *http.Request) - FetchUsersAllContributions(w http.ResponseWriter, r *http.Request) + FetchUserContributions(w http.ResponseWriter, r *http.Request) } func NewHandler(contributionService Service) Handler { @@ -23,30 +22,16 @@ func NewHandler(contributionService Service) Handler { } } -func (h *handler) FetchUserLatestContributions(w http.ResponseWriter, r *http.Request) { +func (h *handler) FetchUserContributions(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - err := h.contributionService.ProcessFetchedContributions(ctx) + userContributions, err := h.contributionService.FetchUserContributions(ctx) if err != nil { - slog.Error("error fetching latest contributions") + slog.Error("error fetching user contributions") status, errorMessage := apperrors.MapError(err) response.WriteJson(w, status, errorMessage, nil) return } - response.WriteJson(w, http.StatusOK, "contribution fetched successfully", nil) -} - -func (h *handler) FetchUsersAllContributions(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - usersAllContributions, err := h.contributionService.FetchUsersAllContributions(ctx) - if err != nil { - slog.Error("error fetching all contributions for user") - status, errorMessage := apperrors.MapError(err) - response.WriteJson(w, status, errorMessage, nil) - return - } - - response.WriteJson(w, http.StatusOK, "all contributions for user fetched successfully", usersAllContributions) + response.WriteJson(w, http.StatusOK, "user contributions fetched successfully", userContributions) } diff --git a/internal/app/contribution/service.go b/internal/app/contribution/service.go index 046e617..b8e832b 100644 --- a/internal/app/contribution/service.go +++ b/internal/app/contribution/service.go @@ -9,10 +9,44 @@ import ( "github.com/joshsoftware/code-curiosity-2025/internal/app/bigquery" repoService "github.com/joshsoftware/code-curiosity-2025/internal/app/repository" "github.com/joshsoftware/code-curiosity-2025/internal/app/user" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" "github.com/joshsoftware/code-curiosity-2025/internal/repository" "google.golang.org/api/iterator" ) +// github event names +const ( + pullRequestEvent = "PullRequestEvent" + issuesEvent = "IssuesEvent" + pushEvent = "PushEvent" + issueCommentEvent = "IssueCommentEvent" +) + +// app contribution types +const ( + pullRequestMerged = "PullRequestMerged" + pullRequestOpened = "PullRequestOpened" + issueOpened = "IssueOpened" + issueClosed = "IssueClosed" + issueResolved = "IssueResolved" + pullRequestUpdated = "PullRequestUpdated" + issueComment = "IssueComment" + pullRequestComment = "PullRequestComment" +) + +// payload +const ( + payloadActionKey = "action" + payloadPullRequestKey = "pull_request" + PayloadMergedKey = "merged" + PayloadIssueKey = "issue" + PayloadStateReasonKey = "state_reason" + PayloadClosedKey = "closed" + PayloadOpenedKey = "opened" + PayloadNotPlannedKey = "not_planned" + PayloadCompletedKey = "completed" +) + type service struct { bigqueryService bigquery.Service contributionRepository repository.ContributionRepository @@ -23,9 +57,11 @@ type service struct { type Service interface { ProcessFetchedContributions(ctx context.Context) error + ProcessEachContribution(ctx context.Context, contribution ContributionResponse) error CreateContribution(ctx context.Context, contributionType string, contributionDetails ContributionResponse, repositoryId int, userId int) (Contribution, error) GetContributionScoreDetailsByContributionType(ctx context.Context, contributionType string) (ContributionScore, error) - FetchUsersAllContributions(ctx context.Context) ([]Contribution, error) + FetchUserContributions(ctx context.Context) ([]Contribution, error) + GetContributionByGithubEventId(ctx context.Context, githubEventId string) (Contribution, error) } func NewService(bigqueryService bigquery.Service, contributionRepository repository.ContributionRepository, repositoryService repoService.Service, userService user.Service, httpClient *http.Client) Service { @@ -42,55 +78,83 @@ func (s *service) ProcessFetchedContributions(ctx context.Context) error { contributions, err := s.bigqueryService.FetchDailyContributions(ctx) if err != nil { slog.Error("error fetching daily contributions", "error", err) - return err + return apperrors.ErrFetchingFromBigquery } + //using a local copy here to copy contribution so that I can implement retry mechanism in future + //thinking of batch processing to be implemented later on, to handle memory overflow + var fetchedContributions []ContributionResponse + for { var contribution ContributionResponse - if err := contributions.Next(&contribution); err == iterator.Done { - break - } else if err != nil { + err := contributions.Next(&contribution) + if err != nil { + if err == iterator.Done { + break + } + slog.Error("error iterating contribution rows", "error", err) - break + return apperrors.ErrNextContribution } - contributionType, err := s.GetContributionType(ctx, contribution) + fetchedContributions = append(fetchedContributions, contribution) + } + + for _, contribution := range fetchedContributions { + err := s.ProcessEachContribution(ctx, contribution) if err != nil { - slog.Error("error getting contribution type") + slog.Error("error processing contribution with github event id", "github event id", "error", contribution.ID, err) return err } + } - var repositoryId int - repoFetched, err := s.repositoryService.GetRepoByRepoId(ctx, contribution.RepoID) //err no rows - if err != nil { - repo, err := s.repositoryService.FetchRepositoryDetails(ctx, contribution.RepoUrl) - if err != nil { - slog.Error("error fetching repository details") - return err - } + return nil +} - repositoryCreated, err := s.repositoryService.CreateRepository(ctx, contribution.RepoID, repo) - if err != nil { - slog.Error("error creating repository", "error", err) - return err - } +func (s *service) ProcessEachContribution(ctx context.Context, contribution ContributionResponse) error { + _, err := s.GetContributionByGithubEventId(ctx, contribution.ID) + if err == nil { + return nil + } - repositoryId = repositoryCreated.Id - } else { - repositoryId = repoFetched.Id - } + if err != apperrors.ErrContributionNotFound { + slog.Error("error fetching contribution by github event id", "error", err) + return err + } - user, err := s.userService.GetUserByGithubId(ctx, contribution.ActorID) + var repositoryId int + repoFetched, err := s.repositoryService.GetRepoByGithubId(ctx, contribution.RepoID) + if err == nil { + repositoryId = repoFetched.Id + } else if err == apperrors.ErrRepoNotFound { + repositoryCreated, err := s.repositoryService.CreateRepository(ctx, contribution.RepoID, contribution.RepoUrl) if err != nil { - slog.Error("error getting user id", "error", err) + slog.Error("error creating repository", "error", err) return err } - _, err = s.CreateContribution(ctx, contributionType, contribution, repositoryId, user.Id) - if err != nil { - slog.Error("error creating contribution", "error", err) - return err - } + repositoryId = repositoryCreated.Id + } else { + slog.Error("error fetching repo by repo id", "error", err) + return err + } + + user, err := s.userService.GetUserByGithubId(ctx, contribution.ActorID) + if err != nil { + slog.Error("error getting user id", "error", err) + return err + } + + contributionType, err := s.GetContributionType(ctx, contribution) + if err != nil { + slog.Error("error getting contribution type", "error", err) + return err + } + + _, err = s.CreateContribution(ctx, contributionType, contribution, repositoryId, user.Id) + if err != nil { + slog.Error("error creating contribution", "error", err) + return err } return nil @@ -105,50 +169,50 @@ func (s *service) GetContributionType(ctx context.Context, contribution Contribu } var action string - if actionVal, ok := contributionPayload["action"]; ok { + if actionVal, ok := contributionPayload[payloadActionKey]; ok { action = actionVal.(string) } var pullRequest map[string]interface{} var isMerged bool - if pullRequestPayload, ok := contributionPayload["pull_request"]; ok { + if pullRequestPayload, ok := contributionPayload[payloadPullRequestKey]; ok { pullRequest = pullRequestPayload.(map[string]interface{}) - isMerged = pullRequest["merged"].(bool) + isMerged = pullRequest[PayloadMergedKey].(bool) } var issue map[string]interface{} var stateReason string - if issuePayload, ok := contributionPayload["issue"]; ok { + if issuePayload, ok := contributionPayload[PayloadIssueKey]; ok { issue = issuePayload.(map[string]interface{}) - stateReason = issue["state_reason"].(string) + stateReason = issue[PayloadStateReasonKey].(string) } var contributionType string switch contribution.Type { - case "PullRequestEvent": - if action == "closed" && isMerged { - contributionType = "PullRequestMerged" - } else if action == "opened" { - contributionType = "PullRequestOpened" + case pullRequestEvent: + if action == PayloadClosedKey && isMerged { + contributionType = pullRequestMerged + } else if action == PayloadOpenedKey { + contributionType = pullRequestOpened } - case "IssuesEvent": - if action == "opened" { - contributionType = "IssueOpened" - } else if action == "closed" && stateReason == "not_planned" { - contributionType = "IssueClosed" - } else if action == "closed" && stateReason == "completed" { - contributionType = "IssueResolved" + case issuesEvent: + if action == PayloadOpenedKey { + contributionType = issueOpened + } else if action == PayloadClosedKey && stateReason == PayloadNotPlannedKey { + contributionType = issueClosed + } else if action == PayloadClosedKey && stateReason == PayloadCompletedKey { + contributionType = issueResolved } - case "PushEvent": - contributionType = "PullRequestUpdated" + case pushEvent: + contributionType = pullRequestUpdated - case "IssueCommentEvent": - contributionType = "IssueComment" + case issueCommentEvent: + contributionType = issueComment - case "PullRequestComment ": - contributionType = "PullRequestComment" + case pullRequestComment: + contributionType = pullRequestComment } return contributionType, nil @@ -161,6 +225,7 @@ func (s *service) CreateContribution(ctx context.Context, contributionType strin RepositoryId: repositoryId, ContributionType: contributionType, ContributedAt: contributionDetails.CreatedAt, + GithubEventId: contributionDetails.ID, } contributionScoreDetails, err := s.GetContributionScoreDetailsByContributionType(ctx, contributionType) @@ -191,17 +256,27 @@ func (s *service) GetContributionScoreDetailsByContributionType(ctx context.Cont return ContributionScore(contributionScoreDetails), nil } -func (s *service) FetchUsersAllContributions(ctx context.Context) ([]Contribution, error) { - usersAllContributions, err := s.contributionRepository.FetchUsersAllContributions(ctx, nil) +func (s *service) FetchUserContributions(ctx context.Context) ([]Contribution, error) { + userContributions, err := s.contributionRepository.FetchUserContributions(ctx, nil) if err != nil { - slog.Error("error occured while fetching all contributions for user", "error", err) + slog.Error("error occured while fetching user contributions", "error", err) return nil, err } - serviceContributions := make([]Contribution, len(usersAllContributions)) - for i, c := range usersAllContributions { + serviceContributions := make([]Contribution, len(userContributions)) + for i, c := range userContributions { serviceContributions[i] = Contribution((c)) } return serviceContributions, nil } + +func (s *service) GetContributionByGithubEventId(ctx context.Context, githubEventId string) (Contribution, error) { + contribution, err := s.contributionRepository.GetContributionByGithubEventId(ctx, nil, githubEventId) + if err != nil { + slog.Error("error fetching contribution by github event id", "error", err) + return Contribution{}, err + } + + return Contribution(contribution), nil +} diff --git a/internal/app/cronJob/cronjob.go b/internal/app/cronJob/cronjob.go new file mode 100644 index 0000000..f8f3a21 --- /dev/null +++ b/internal/app/cronJob/cronjob.go @@ -0,0 +1,24 @@ +package cronJob + +import ( + "context" + "log/slog" + "time" +) + +type Job interface { + Schedule(c *CronSchedular) error +} + +type CronJob struct { + Name string +} + +func (c *CronJob) Execute(ctx context.Context, fn func(context.Context)) { + slog.Info("cron job started at", "time ", time.Now()) + defer func() { + slog.Info("cron job completed") + }() + + fn(ctx) +} diff --git a/internal/app/cronJob/dailyJob.go b/internal/app/cronJob/dailyJob.go new file mode 100644 index 0000000..67f3e24 --- /dev/null +++ b/internal/app/cronJob/dailyJob.go @@ -0,0 +1,32 @@ +package cronJob + +import ( + "context" + + "github.com/joshsoftware/code-curiosity-2025/internal/app/contribution" +) + +type DailyJob struct { + CronJob + contributionService contribution.Service +} + +func NewDailyJob(contributionService contribution.Service) *DailyJob { + return &DailyJob{ + contributionService: contributionService, + CronJob: CronJob{Name: "Fetch Contributions Daily"}, + } +} + +func (d *DailyJob) Schedule(s *CronSchedular) error { + _, err := s.cron.AddFunc("0 1 * * *", func() { d.Execute(context.Background(), d.run) }) + if err != nil { + return err + } + + return nil +} + +func (d *DailyJob) run(ctx context.Context) { + d.contributionService.ProcessFetchedContributions(ctx) +} diff --git a/internal/app/cronJob/init.go b/internal/app/cronJob/init.go new file mode 100644 index 0000000..7fba0f3 --- /dev/null +++ b/internal/app/cronJob/init.go @@ -0,0 +1,38 @@ +package cronJob + +import ( + "log/slog" + "time" + + "github.com/joshsoftware/code-curiosity-2025/internal/app/contribution" + "github.com/robfig/cron/v3" +) + +type CronSchedular struct { + cron *cron.Cron +} + +func NewCronSchedular() *CronSchedular { + location, err := time.LoadLocation("Asia/Kolkata") + if err != nil { + slog.Error("failed to load IST timezone", "error", err) + } + + return &CronSchedular{ + cron: cron.New(cron.WithLocation(location)), + } +} + +func (c *CronSchedular) InitCronJobs(contributionService contribution.Service) { + jobs := []Job{ + NewDailyJob(contributionService), + } + + for _, job := range jobs { + if err := job.Schedule(c); err != nil { + slog.Error("failed to execute cron job") + } + } + + c.cron.Start() +} diff --git a/internal/app/dependencies.go b/internal/app/dependencies.go index 616e377..f390342 100644 --- a/internal/app/dependencies.go +++ b/internal/app/dependencies.go @@ -7,6 +7,7 @@ import ( "github.com/joshsoftware/code-curiosity-2025/internal/app/auth" "github.com/joshsoftware/code-curiosity-2025/internal/app/bigquery" "github.com/joshsoftware/code-curiosity-2025/internal/app/contribution" + "github.com/joshsoftware/code-curiosity-2025/internal/app/github" repoService "github.com/joshsoftware/code-curiosity-2025/internal/app/repository" "github.com/joshsoftware/code-curiosity-2025/internal/app/user" "github.com/joshsoftware/code-curiosity-2025/internal/config" @@ -15,11 +16,11 @@ import ( ) type Dependencies struct { - AuthService auth.Service - UserService user.Service + ContributionService contribution.Service AuthHandler auth.Handler UserHandler user.Handler ContributionHandler contribution.Handler + RepositoryHandler repoService.Handler AppCfg config.AppConfig Client config.Bigquery } @@ -32,18 +33,20 @@ func InitDependencies(db *sqlx.DB, appCfg config.AppConfig, client config.Bigque userService := user.NewService(userRepository) authService := auth.NewService(userService, appCfg) bigqueryService := bigquery.NewService(client, userRepository) - repositoryService := repoService.NewService(repositoryRepository, appCfg, httpClient) + githubService := github.NewService(appCfg, httpClient) + repositoryService := repoService.NewService(repositoryRepository, githubService) contributionService := contribution.NewService(bigqueryService, contributionRepository, repositoryService, userService, httpClient) authHandler := auth.NewHandler(authService, appCfg) userHandler := user.NewHandler(userService) + repositoryHandler := repoService.NewHandler(repositoryService) contributionHandler := contribution.NewHandler(contributionService) return Dependencies{ - AuthService: authService, - UserService: userService, + ContributionService: contributionService, AuthHandler: authHandler, UserHandler: userHandler, + RepositoryHandler: repositoryHandler, ContributionHandler: contributionHandler, AppCfg: appCfg, Client: client, diff --git a/internal/app/github/domain.go b/internal/app/github/domain.go new file mode 100644 index 0000000..efdd441 --- /dev/null +++ b/internal/app/github/domain.go @@ -0,0 +1,30 @@ +package github + +import "time" + +const AuthorizationKey = "Authorization" + +type RepoOwner struct { + Login string `json:"login"` +} + +type FetchRepositoryDetailsResponse struct { + Id int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + LanguagesURL string `json:"languages_url"` + UpdateDate time.Time `json:"updated_at"` + RepoOwnerName RepoOwner `json:"owner"` + ContributorsUrl string `json:"contributors_url"` + RepoUrl string `json:"html_url"` +} + +type RepoLanguages map[string]int + +type FetchRepoContributorsResponse struct { + Id int `json:"id"` + Name string `json:"login"` + AvatarUrl string `json:"avatar_url"` + GithubUrl string `json:"html_url"` + Contributions int `json:"contributions"` +} diff --git a/internal/app/github/service.go b/internal/app/github/service.go new file mode 100644 index 0000000..8b81912 --- /dev/null +++ b/internal/app/github/service.go @@ -0,0 +1,93 @@ +package github + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + + "github.com/joshsoftware/code-curiosity-2025/internal/config" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/utils" +) + +type service struct { + appCfg config.AppConfig + httpClient *http.Client +} + +type Service interface { + configureGithubApiHeaders() map[string]string + FetchRepositoryDetails(ctx context.Context, getUserRepoDetailsUrl string) (FetchRepositoryDetailsResponse, error) + FetchRepositoryLanguages(ctx context.Context, client *http.Client, getRepoLanguagesURL string) (RepoLanguages, error) + FetchRepositoryContributors(ctx context.Context, client *http.Client, getRepoContributorsURl string) ([]FetchRepoContributorsResponse, error) +} + +func NewService(appCfg config.AppConfig, httpClient *http.Client) Service { + return &service{ + appCfg: appCfg, + httpClient: httpClient, + } +} + +func (s *service) configureGithubApiHeaders() map[string]string { + return map[string]string{ + AuthorizationKey: s.appCfg.GithubPersonalAccessToken, + } +} + +func (s *service) FetchRepositoryDetails(ctx context.Context, getUserRepoDetailsUrl string) (FetchRepositoryDetailsResponse, error) { + headers := s.configureGithubApiHeaders() + + body, err := utils.DoGet(s.httpClient, getUserRepoDetailsUrl, headers) + if err != nil { + slog.Error("error making a GET request", "error", err) + return FetchRepositoryDetailsResponse{}, err + } + + var repoDetails FetchRepositoryDetailsResponse + err = json.Unmarshal(body, &repoDetails) + if err != nil { + slog.Error("error unmarshalling fetch repository details body", "error", err) + return FetchRepositoryDetailsResponse{}, err + } + + return repoDetails, nil +} + +func (s *service) FetchRepositoryLanguages(ctx context.Context, client *http.Client, getRepoLanguagesURL string) (RepoLanguages, error) { + headers := s.configureGithubApiHeaders() + + body, err := utils.DoGet(s.httpClient, getRepoLanguagesURL, headers) + if err != nil { + slog.Error("error making a GET request", "error", err) + return RepoLanguages{}, err + } + + var repoLanguages RepoLanguages + err = json.Unmarshal(body, &repoLanguages) + if err != nil { + slog.Error("error unmarshalling fetch repository languages body", "error", err) + return RepoLanguages{}, err + } + + return repoLanguages, nil +} + +func (s *service) FetchRepositoryContributors(ctx context.Context, client *http.Client, getRepoContributorsURl string) ([]FetchRepoContributorsResponse, error) { + headers := s.configureGithubApiHeaders() + + body, err := utils.DoGet(s.httpClient, getRepoContributorsURl, headers) + if err != nil { + slog.Error("error making a GET request", "error", err) + return []FetchRepoContributorsResponse{}, err + } + + var repoContributors []FetchRepoContributorsResponse + err = json.Unmarshal(body, &repoContributors) + if err != nil { + slog.Error("error unmarshalling fetch contributors body", "error", err) + return nil, err + } + + return repoContributors, nil +} diff --git a/internal/app/repository/domain.go b/internal/app/repository/domain.go index 90101b5..60b7400 100644 --- a/internal/app/repository/domain.go +++ b/internal/app/repository/domain.go @@ -2,21 +2,6 @@ package repository import "time" -type RepoOWner struct { - Login string `json:"login"` -} - -type FetchRepositoryDetailsResponse struct { - Id int `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - LanguagesURL string `json:"languages_url"` - UpdateDate time.Time `json:"updated_at"` - RepoOwnerName RepoOWner `json:"owner"` - ContributorsUrl string `json:"contributors_url"` - RepoUrl string `json:"html_url"` -} - type Repository struct { Id int GithubRepoId int @@ -30,3 +15,30 @@ type Repository struct { CreatedAt time.Time UpdatedAt time.Time } + +type RepoLanguages map[string]int + +type FetchUsersContributedReposResponse struct { + Repository + Languages []string + TotalCoinsEarned int +} + +type Contribution struct { + Id int + UserId int + RepositoryId int + ContributionScoreId int + ContributionType string + BalanceChange int + ContributedAt time.Time + GithubEventId string + CreatedAt time.Time + UpdatedAt time.Time +} + +type LanguagePercent struct { + Name string + Bytes int + Percentage float64 +} diff --git a/internal/app/repository/handler.go b/internal/app/repository/handler.go new file mode 100644 index 0000000..5edbb29 --- /dev/null +++ b/internal/app/repository/handler.go @@ -0,0 +1,164 @@ +package repository + +import ( + "log/slog" + "net/http" + "strconv" + + "github.com/joshsoftware/code-curiosity-2025/internal/app/github" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/response" +) + +type handler struct { + repositoryService Service + githubService github.Service +} + +type Handler interface { + FetchUsersContributedRepos(w http.ResponseWriter, r *http.Request) + FetchParticularRepoDetails(w http.ResponseWriter, r *http.Request) + FetchUserContributionsInRepo(w http.ResponseWriter, r *http.Request) + FetchLanguagePercentInRepo(w http.ResponseWriter, r *http.Request) +} + +func NewHandler(repositoryService Service) Handler { + return &handler{ + repositoryService: repositoryService, + } +} + +func (h *handler) FetchUsersContributedRepos(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + client := &http.Client{} + + usersContributedRepos, err := h.repositoryService.FetchUsersContributedRepos(ctx, client) + if err != nil { + slog.Error("error fetching users conributed repos", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "users contributed repositories fetched successfully", usersContributedRepos) +} + +func (h *handler) FetchParticularRepoDetails(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + repoIdPath := r.PathValue("repo_id") + repoId, err := strconv.Atoi(repoIdPath) + if err != nil { + slog.Error("error getting repo id from request url", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + repoDetails, err := h.repositoryService.GetRepoByRepoId(ctx, repoId) + if err != nil { + slog.Error("error fetching particular repo details", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "repository details fetched successfully", repoDetails) +} + +func (h *handler) FetchParticularRepoContributors(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + client := &http.Client{} + + repoIdPath := r.PathValue("repo_id") + repoId, err := strconv.Atoi(repoIdPath) + if err != nil { + slog.Error("error getting repo id from request url", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + repoDetails, err := h.repositoryService.GetRepoByRepoId(ctx, repoId) + if err != nil { + slog.Error("error fetching particular repo details", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + repoContributors, err := h.githubService.FetchRepositoryContributors(ctx, client, repoDetails.ContributorsUrl) + if err != nil { + slog.Error("error fetching repo contributors", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "contributors for repo fetched successfully", repoContributors) +} + +func (h *handler) FetchUserContributionsInRepo(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + repoIdPath := r.PathValue("repo_id") + repoId, err := strconv.Atoi(repoIdPath) + if err != nil { + slog.Error("error getting repo id from request url", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + usersContributionsInRepo, err := h.repositoryService.FetchUserContributionsInRepo(ctx, repoId) + if err != nil { + slog.Error("error fetching users contribution in repository", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "users contribution for repository fetched successfully", usersContributionsInRepo) +} + +func (h *handler) FetchLanguagePercentInRepo(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + client := &http.Client{} + + repoIdPath := r.PathValue("repo_id") + repoId, err := strconv.Atoi(repoIdPath) + if err != nil { + slog.Error("error getting repo id from request url", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + repoDetails, err := h.repositoryService.GetRepoByRepoId(ctx, repoId) + if err != nil { + slog.Error("error fetching particular repo details", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + repoLanguages, err := h.githubService.FetchRepositoryLanguages(ctx, client, repoDetails.LanguagesUrl) + if err != nil { + slog.Error("error fetching particular repo languages", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + langPercent, err := h.repositoryService.CalculateLanguagePercentInRepo(ctx, RepoLanguages(repoLanguages)) + if err != nil { + slog.Error("error fetching particular repo languages", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "language percentages for repo fetched successfully", langPercent) +} diff --git a/internal/app/repository/service.go b/internal/app/repository/service.go index 92c51e0..bbae42d 100644 --- a/internal/app/repository/service.go +++ b/internal/app/repository/service.go @@ -2,77 +2,62 @@ package repository import ( "context" - "encoding/json" - "io" "log/slog" + "math" "net/http" - "github.com/joshsoftware/code-curiosity-2025/internal/config" + "github.com/joshsoftware/code-curiosity-2025/internal/app/github" "github.com/joshsoftware/code-curiosity-2025/internal/repository" ) type service struct { repositoryRepository repository.RepositoryRepository - appCfg config.AppConfig - httpClient *http.Client + githubService github.Service } type Service interface { - GetRepoByRepoId(ctx context.Context, githubRepoId int) (Repository, error) - FetchRepositoryDetails(ctx context.Context, getUserRepoDetailsUrl string) (FetchRepositoryDetailsResponse, error) - CreateRepository(ctx context.Context, repoGithubId int, repo FetchRepositoryDetailsResponse) (Repository, error) + GetRepoByGithubId(ctx context.Context, githubRepoId int) (Repository, error) + GetRepoByRepoId(ctx context.Context, repoId int) (Repository, error) + CreateRepository(ctx context.Context, repoGithubId int, ContributionRepoDetailsUrl string) (Repository, error) + FetchUsersContributedRepos(ctx context.Context, client *http.Client) ([]FetchUsersContributedReposResponse, error) + FetchUserContributionsInRepo(ctx context.Context, githubRepoId int) ([]Contribution, error) + CalculateLanguagePercentInRepo(ctx context.Context, repoLanguages RepoLanguages) ([]LanguagePercent, error) } -func NewService(repositoryRepository repository.RepositoryRepository, appCfg config.AppConfig, httpClient *http.Client) Service { +func NewService(repositoryRepository repository.RepositoryRepository, githubService github.Service) Service { return &service{ repositoryRepository: repositoryRepository, - appCfg: appCfg, - httpClient: httpClient, + githubService: githubService, } } -func (s *service) GetRepoByRepoId(ctx context.Context, repoGithubId int) (Repository, error) { +func (s *service) GetRepoByGithubId(ctx context.Context, repoGithubId int) (Repository, error) { repoDetails, err := s.repositoryRepository.GetRepoByGithubId(ctx, nil, repoGithubId) if err != nil { - slog.Error("failed to get repository by github id") + slog.Error("failed to get repository by repo github id", "error", err) return Repository{}, err } return Repository(repoDetails), nil } -func (s *service) FetchRepositoryDetails(ctx context.Context, getUserRepoDetailsUrl string) (FetchRepositoryDetailsResponse, error) { - req, err := http.NewRequest("GET", getUserRepoDetailsUrl, nil) +func (s *service) GetRepoByRepoId(ctx context.Context, repobId int) (Repository, error) { + repoDetails, err := s.repositoryRepository.GetRepoByRepoId(ctx, nil, repobId) if err != nil { - slog.Error("error fetching user repositories details", "error", err) - return FetchRepositoryDetailsResponse{}, err + slog.Error("failed to get repository by repo id", "error", err) + return Repository{}, err } - req.Header.Add("Authorization", s.appCfg.GithubPersonalAccessToken) + return Repository(repoDetails), nil +} - resp, err := s.httpClient.Do(req) +func (s *service) CreateRepository(ctx context.Context, repoGithubId int, ContributionRepoDetailsUrl string) (Repository, error) { + repo, err := s.githubService.FetchRepositoryDetails(ctx, ContributionRepoDetailsUrl) if err != nil { slog.Error("error fetching user repositories details", "error", err) - return FetchRepositoryDetailsResponse{}, err - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - slog.Error("error freading body", "error", err) - return FetchRepositoryDetailsResponse{}, err - } - - var repoDetails FetchRepositoryDetailsResponse - err = json.Unmarshal(body, &repoDetails) - if err != nil { - slog.Error("error unmarshalling fetch repository details body", "error", err) - return FetchRepositoryDetailsResponse{}, err + return Repository{}, err } - return repoDetails, nil -} - -func (s *service) CreateRepository(ctx context.Context, repoGithubId int, repo FetchRepositoryDetailsResponse) (Repository, error) { createRepo := Repository{ GithubRepoId: repoGithubId, RepoName: repo.Name, @@ -91,3 +76,72 @@ func (s *service) CreateRepository(ctx context.Context, repoGithubId int, repo F return Repository(repositoryCreated), nil } + +func (s *service) FetchUsersContributedRepos(ctx context.Context, client *http.Client) ([]FetchUsersContributedReposResponse, error) { + usersContributedRepos, err := s.repositoryRepository.FetchUsersContributedRepos(ctx, nil) + if err != nil { + slog.Error("error fetching users conributed repos", "error", err) + return nil, err + } + + fetchUsersContributedReposResponse := make([]FetchUsersContributedReposResponse, len(usersContributedRepos)) + + for i, usersContributedRepo := range usersContributedRepos { + fetchUsersContributedReposResponse[i].Repository = Repository(usersContributedRepo) + + contributedRepoLanguages, err := s.githubService.FetchRepositoryLanguages(ctx, client, usersContributedRepo.LanguagesUrl) + if err != nil { + slog.Error("error fetching languages for repository", "error", err) + return nil, err + } + + for language := range contributedRepoLanguages { + fetchUsersContributedReposResponse[i].Languages = append(fetchUsersContributedReposResponse[i].Languages, language) + } + + userRepoTotalCoins, err := s.repositoryRepository.GetUserRepoTotalCoins(ctx, nil, usersContributedRepo.Id) + if err != nil { + slog.Error("error calculating total coins earned by user for the repository", "error", err) + return nil, err + } + + fetchUsersContributedReposResponse[i].TotalCoinsEarned = userRepoTotalCoins + } + + return fetchUsersContributedReposResponse, nil +} + +func (s *service) FetchUserContributionsInRepo(ctx context.Context, githubRepoId int) ([]Contribution, error) { + userContributionsInRepo, err := s.repositoryRepository.FetchUserContributionsInRepo(ctx, nil, githubRepoId) + if err != nil { + slog.Error("error fetching users contribution in repository", "error", err) + return nil, err + } + + serviceUserContributionsInRepo := make([]Contribution, len(userContributionsInRepo)) + for i, c := range userContributionsInRepo { + serviceUserContributionsInRepo[i] = Contribution(c) + } + + return serviceUserContributionsInRepo, nil +} + +func (s *service) CalculateLanguagePercentInRepo(ctx context.Context, repoLanguages RepoLanguages) ([]LanguagePercent, error) { + var total int + for _, bytes := range repoLanguages { + total += bytes + } + + var langPercent []LanguagePercent + + for lang, bytes := range repoLanguages { + percentage := (float64(bytes) / float64(total)) * 100 + langPercent = append(langPercent, LanguagePercent{ + Name: lang, + Bytes: bytes, + Percentage: math.Round(percentage*10) / 10, + }) + } + + return langPercent, nil +} diff --git a/internal/app/router.go b/internal/app/router.go index 6ab919a..bb97fd7 100644 --- a/internal/app/router.go +++ b/internal/app/router.go @@ -20,7 +20,10 @@ func NewRouter(deps Dependencies) http.Handler { router.HandleFunc("PATCH /api/v1/user/email", middleware.Authentication(deps.UserHandler.UpdateUserEmail, deps.AppCfg)) - router.HandleFunc("GET /api/v1/user/contributions/latest", middleware.Authentication(deps.ContributionHandler.FetchUserLatestContributions, deps.AppCfg)) - router.HandleFunc("GET /api/v1/user/contributions/all", middleware.Authentication(deps.ContributionHandler.FetchUsersAllContributions, 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)) return middleware.CorsMiddleware(router, deps.AppCfg) } diff --git a/internal/pkg/apperrors/errors.go b/internal/pkg/apperrors/errors.go index c9c7f2d..4efe8cf 100644 --- a/internal/pkg/apperrors/errors.go +++ b/internal/pkg/apperrors/errors.go @@ -31,12 +31,20 @@ var ( ErrJWTCreationFailed = errors.New("failed to create jwt token") ErrAuthorizationFailed = errors.New("failed to authorize user") - ErrRepoNotFound = errors.New("repository not found") - ErrRepoCreationFailed = errors.New("failed to create repo for user") + ErrRepoNotFound = errors.New("repository not found") + ErrRepoCreationFailed = errors.New("failed to create repo for user") + ErrCalculatingUserRepoTotalCoins = errors.New("error calculating total coins earned by user for the repository") + ErrFetchingUsersContributedRepos = errors.New("error fetching users contributed repositories") + ErrFetchingUserContributionsInRepo = errors.New("error fetching users contribution in repository") + ErrFetchingFromBigquery = errors.New("error fetching contributions from bigquery service") + ErrNextContribution = errors.New("error while loading next bigquery contribution") ErrContributionCreationFailed = errors.New("failed to create contrbitution") ErrFetchingRecentContributions = errors.New("failed to fetch users five recent contributions") ErrFetchingAllContributions = errors.New("failed to fetch all contributions for user") + ErrContributionScoreNotFound = errors.New("failed to get contributionscore details for given contribution type") + ErrFetchingContribution = errors.New("error fetching contribution by github repo id") + ErrContributionNotFound = errors.New("contribution not found") ) func MapError(err error) (statusCode int, errMessage string) { @@ -47,7 +55,7 @@ func MapError(err error) (statusCode int, errMessage string) { return http.StatusUnauthorized, err.Error() case ErrAccessForbidden: return http.StatusForbidden, err.Error() - case ErrUserNotFound: + case ErrUserNotFound, ErrRepoNotFound: return http.StatusNotFound, err.Error() case ErrInvalidToken: return http.StatusUnprocessableEntity, err.Error() diff --git a/internal/pkg/utils/helper.go b/internal/pkg/utils/helper.go new file mode 100644 index 0000000..3fa0492 --- /dev/null +++ b/internal/pkg/utils/helper.go @@ -0,0 +1,45 @@ +package utils + +import ( + "fmt" + "io" + "log/slog" + "net/http" + "strings" +) + +func FormatIntSliceForQuery(ids []int) string { + strIDs := make([]string, len(ids)) + for i, id := range ids { + strIDs[i] = fmt.Sprintf("%d", id) + } + + return strings.Join(strIDs, ",") +} + +func DoGet(httpClient *http.Client, url string, headers map[string]string) ([]byte, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + slog.Error("failed to create GET request", "error", err) + return nil, err + } + + for key, value := range headers { + req.Header.Add(key, value) + } + + resp, err := httpClient.Do(req) + if err != nil { + slog.Error("failed to send GET request", "error", err) + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + slog.Error("error reading body", "error", err) + return nil, err + } + + return body, nil +} diff --git a/internal/repository/base.go b/internal/repository/base.go index a38e9ba..e4b0795 100644 --- a/internal/repository/base.go +++ b/internal/repository/base.go @@ -24,6 +24,8 @@ type QueryExecuter interface { QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + GetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error + SelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error } func (b *BaseRepository) BeginTx(ctx context.Context) (*sqlx.Tx, error) { diff --git a/internal/repository/contribution.go b/internal/repository/contribution.go index 436dc11..eb1d1a5 100644 --- a/internal/repository/contribution.go +++ b/internal/repository/contribution.go @@ -2,6 +2,8 @@ package repository import ( "context" + "database/sql" + "errors" "log/slog" "github.com/jmoiron/sqlx" @@ -17,7 +19,8 @@ type ContributionRepository interface { RepositoryTransaction CreateContribution(ctx context.Context, tx *sqlx.Tx, contributionDetails Contribution) (Contribution, error) GetContributionScoreDetailsByContributionType(ctx context.Context, tx *sqlx.Tx, contributionType string) (ContributionScore, error) - FetchUsersAllContributions(ctx context.Context, tx *sqlx.Tx) ([]Contribution, error) + FetchUserContributions(ctx context.Context, tx *sqlx.Tx) ([]Contribution, error) + GetContributionByGithubEventId(ctx context.Context, tx *sqlx.Tx, githubEventId string) (Contribution, error) } func NewContributionRepository(db *sqlx.DB) ContributionRepository { @@ -34,37 +37,31 @@ const ( contribution_score_id, contribution_type, balance_change, - contributed_at + contributed_at, + github_event_id ) - VALUES ($1, $2, $3, $4, $5, $6) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *` getContributionScoreDetailsByContributionTypeQuery = `SELECT * from contribution_score where contribution_type=$1` - fetchUsersAllContributionsQuery = `SELECT * from contributions where user_id=$1 order by contributed_at desc` + fetchUserContributionsQuery = `SELECT * from contributions where user_id=$1 order by contributed_at desc` + + GetContributionByGithubEventIdQuery = `SELECT * from contributions where github_event_id=$1` ) func (cr *contributionRepository) CreateContribution(ctx context.Context, tx *sqlx.Tx, contributionInfo Contribution) (Contribution, error) { executer := cr.BaseRepository.initiateQueryExecuter(tx) var contribution Contribution - err := executer.QueryRowContext(ctx, createContributionQuery, + err := executer.GetContext(ctx, &contribution, createContributionQuery, contributionInfo.UserId, contributionInfo.RepositoryId, contributionInfo.ContributionScoreId, contributionInfo.ContributionType, contributionInfo.BalanceChange, contributionInfo.ContributedAt, - ).Scan( - &contribution.Id, - &contribution.UserId, - &contribution.RepositoryId, - &contribution.ContributionScoreId, - &contribution.ContributionType, - &contribution.BalanceChange, - &contribution.ContributedAt, - &contribution.CreatedAt, - &contribution.UpdatedAt, + contributionInfo.GithubEventId, ) if err != nil { slog.Error("error occured while inserting contributions", "error", err) @@ -78,15 +75,13 @@ func (cr *contributionRepository) GetContributionScoreDetailsByContributionType( executer := cr.BaseRepository.initiateQueryExecuter(tx) var contributionScoreDetails ContributionScore - err := executer.QueryRowContext(ctx, getContributionScoreDetailsByContributionTypeQuery, contributionType).Scan( - &contributionScoreDetails.Id, - &contributionScoreDetails.AdminId, - &contributionScoreDetails.ContributionType, - &contributionScoreDetails.Score, - &contributionScoreDetails.CreatedAt, - &contributionScoreDetails.UpdatedAt, - ) + err := executer.GetContext(ctx, &contributionScoreDetails, getContributionScoreDetailsByContributionTypeQuery, contributionType) if err != nil { + if errors.Is(err, sql.ErrNoRows) { + slog.Warn("no contribution score details found for contribution type", "contributionType", contributionType) + return ContributionScore{}, apperrors.ErrContributionScoreNotFound + } + slog.Error("error occured while getting contribution score details", "error", err) return ContributionScore{}, err } @@ -94,7 +89,7 @@ func (cr *contributionRepository) GetContributionScoreDetailsByContributionType( return contributionScoreDetails, nil } -func (cr *contributionRepository) FetchUsersAllContributions(ctx context.Context, tx *sqlx.Tx) ([]Contribution, error) { +func (cr *contributionRepository) FetchUserContributions(ctx context.Context, tx *sqlx.Tx) ([]Contribution, error) { userIdValue := ctx.Value(middleware.UserIdKey) userId, ok := userIdValue.(int) @@ -105,30 +100,30 @@ func (cr *contributionRepository) FetchUsersAllContributions(ctx context.Context executer := cr.BaseRepository.initiateQueryExecuter(tx) - rows, err := executer.QueryContext(ctx, fetchUsersAllContributionsQuery, userId) + var userContributions []Contribution + err := executer.SelectContext(ctx, &userContributions, fetchUserContributionsQuery, userId) if err != nil { - slog.Error("error fetching all contributions for user") + slog.Error("error fetching user contributions", "error", err) return nil, apperrors.ErrFetchingAllContributions } - defer rows.Close() - - var usersAllContributions []Contribution - for rows.Next() { - var currentContribution Contribution - if err = rows.Scan( - ¤tContribution.Id, - ¤tContribution.UserId, - ¤tContribution.RepositoryId, - ¤tContribution.ContributionScoreId, - ¤tContribution.ContributionType, - ¤tContribution.BalanceChange, - ¤tContribution.ContributedAt, - ¤tContribution.CreatedAt, ¤tContribution.UpdatedAt); err != nil { - return nil, err - } - usersAllContributions = append(usersAllContributions, currentContribution) + return userContributions, nil +} + +func (cr *contributionRepository) GetContributionByGithubEventId(ctx context.Context, tx *sqlx.Tx, githubEventId string) (Contribution, error) { + executer := cr.BaseRepository.initiateQueryExecuter(tx) + + var contribution Contribution + err := executer.GetContext(ctx, &contribution, GetContributionByGithubEventIdQuery, githubEventId) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + slog.Error("contribution not found", "error", err) + return Contribution{}, apperrors.ErrContributionNotFound + } + slog.Error("error fetching contribution by github event id", "error", err) + return Contribution{}, apperrors.ErrFetchingContribution } - return usersAllContributions, nil + return contribution, nil + } diff --git a/internal/repository/domain.go b/internal/repository/domain.go index d56a615..eb4ba97 100644 --- a/internal/repository/domain.go +++ b/internal/repository/domain.go @@ -6,61 +6,62 @@ import ( ) type User struct { - Id int - GithubId int - GithubUsername string - Email string - AvatarUrl string - CurrentBalance int - CurrentActiveGoalId sql.NullInt64 - IsBlocked bool - IsAdmin bool - Password string - IsDeleted bool - DeletedAt sql.NullTime - CreatedAt time.Time - UpdatedAt time.Time + Id int `db:"id"` + GithubId int `db:"github_id"` + GithubUsername string `db:"github_username"` + Email string `db:"email"` + AvatarUrl string `db:"avatar_url"` + CurrentBalance int `db:"current_balance"` + CurrentActiveGoalId sql.NullInt64 `db:"current_active_goal_id"` + IsBlocked bool `db:"is_blocked"` + IsAdmin bool `db:"is_admin"` + Password string `db:"password"` + IsDeleted bool `db:"is_deleted"` + DeletedAt sql.NullTime `db:"deleted_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } type CreateUserRequestBody struct { - GithubId int - GithubUsername string - AvatarUrl string - Email string - IsAdmin bool + GithubId int `db:"github_id"` + GithubUsername string `db:"github_username"` + AvatarUrl string `db:"avatar_url"` + Email string `db:"email"` + IsAdmin bool `db:"is_admin"` } type Contribution struct { - Id int - UserId int - RepositoryId int - ContributionScoreId int - ContributionType string - BalanceChange int - ContributedAt time.Time - CreatedAt time.Time - UpdatedAt time.Time + Id int `db:"id"` + UserId int `db:"user_id"` + RepositoryId int `db:"repository_id"` + ContributionScoreId int `db:"contribution_score_id"` + ContributionType string `db:"contribution_type"` + BalanceChange int `db:"balance_change"` + ContributedAt time.Time `db:"contributed_at"` + GithubEventId string `db:"github_event_id"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } type Repository struct { - Id int - GithubRepoId int - RepoName string - Description string - LanguagesUrl string - RepoUrl string - OwnerName string - UpdateDate time.Time - ContributorsUrl string - CreatedAt time.Time - UpdatedAt time.Time + Id int `db:"id"` + GithubRepoId int `db:"github_repo_id"` + RepoName string `db:"repo_name"` + Description string `db:"description"` + LanguagesUrl string `db:"languages_url"` + RepoUrl string `db:"repo_url"` + OwnerName string `db:"owner_name"` + UpdateDate time.Time `db:"update_date"` + ContributorsUrl string `db:"contributors_url"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } type ContributionScore struct { - Id int - AdminId int - ContributionType string - Score int - CreatedAt time.Time - UpdatedAt time.Time -} \ No newline at end of file + Id int `db:"id"` + AdminId int `db:"admin_id"` + ContributionType string `db:"contribution_type"` + Score int `db:"score"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 9a2fbf7..96a1d91 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -8,6 +8,7 @@ import ( "github.com/jmoiron/sqlx" "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/middleware" ) type repositoryRepository struct { @@ -17,7 +18,11 @@ type repositoryRepository struct { type RepositoryRepository interface { RepositoryTransaction GetRepoByGithubId(ctx context.Context, tx *sqlx.Tx, repoGithubId int) (Repository, error) + GetRepoByRepoId(ctx context.Context, tx *sqlx.Tx, repoId int) (Repository, error) CreateRepository(ctx context.Context, tx *sqlx.Tx, repository Repository) (Repository, error) + GetUserRepoTotalCoins(ctx context.Context, tx *sqlx.Tx, repoId int) (int, error) + FetchUsersContributedRepos(ctx context.Context, tx *sqlx.Tx) ([]Repository, error) + FetchUserContributionsInRepo(ctx context.Context, tx *sqlx.Tx, repoGithubId int) ([]Contribution, error) } func NewRepositoryRepository(db *sqlx.DB) RepositoryRepository { @@ -29,6 +34,8 @@ func NewRepositoryRepository(db *sqlx.DB) RepositoryRepository { const ( getRepoByGithubIdQuery = `SELECT * from repositories where github_repo_id=$1` + getrepoByRepoIdQuery = `SELECT * from repositories where id=$1` + createRepositoryQuery = ` INSERT INTO repositories ( github_repo_id, @@ -42,31 +49,25 @@ const ( ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *` + + getUserRepoTotalCoinsQuery = `SELECT sum(balance_change) from contributions where user_id = $1 and repository_id = $2;` + + fetchUsersContributedReposQuery = `SELECT * from repositories where id in (SELECT repository_id from contributions where user_id=$1);` + + fetchUserContributionsInRepoQuery = `SELECT * from contributions where repository_id=$1 and user_id=$2;` ) func (rr *repositoryRepository) GetRepoByGithubId(ctx context.Context, tx *sqlx.Tx, repoGithubId int) (Repository, error) { executer := rr.BaseRepository.initiateQueryExecuter(tx) var repository Repository - err := executer.QueryRowContext(ctx, getRepoByGithubIdQuery, repoGithubId).Scan( - &repository.Id, - &repository.GithubRepoId, - &repository.RepoName, - &repository.Description, - &repository.LanguagesUrl, - &repository.RepoUrl, - &repository.OwnerName, - &repository.UpdateDate, - &repository.CreatedAt, - &repository.UpdatedAt, - &repository.ContributorsUrl, - ) + err := executer.GetContext(ctx, &repository, getRepoByGithubIdQuery, repoGithubId) if err != nil { if errors.Is(err, sql.ErrNoRows) { slog.Error("repository not found", "error", err) return Repository{}, apperrors.ErrRepoNotFound } - slog.Error("error occurred while getting repository by id", "error", err) + slog.Error("error occurred while getting repository by repo github id", "error", err) return Repository{}, apperrors.ErrInternalServer } @@ -74,11 +75,28 @@ func (rr *repositoryRepository) GetRepoByGithubId(ctx context.Context, tx *sqlx. } +func (rr *repositoryRepository) GetRepoByRepoId(ctx context.Context, tx *sqlx.Tx, repoId int) (Repository, error) { + executer := rr.BaseRepository.initiateQueryExecuter(tx) + + var repository Repository + err := executer.GetContext(ctx, &repository, getrepoByRepoIdQuery, repoId) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + slog.Error("repository not found", "error", err) + return Repository{}, apperrors.ErrRepoNotFound + } + slog.Error("error occurred while getting repository by id", "error", err) + return Repository{}, apperrors.ErrInternalServer + } + + return repository, nil +} + func (rr *repositoryRepository) CreateRepository(ctx context.Context, tx *sqlx.Tx, repositoryInfo Repository) (Repository, error) { executer := rr.BaseRepository.initiateQueryExecuter(tx) var repository Repository - err := executer.QueryRowContext(ctx, createRepositoryQuery, + err := executer.GetContext(ctx, &repository, createRepositoryQuery, repositoryInfo.GithubRepoId, repositoryInfo.RepoName, repositoryInfo.Description, @@ -87,18 +105,6 @@ func (rr *repositoryRepository) CreateRepository(ctx context.Context, tx *sqlx.T repositoryInfo.OwnerName, repositoryInfo.UpdateDate, repositoryInfo.ContributorsUrl, - ).Scan( - &repository.Id, - &repository.GithubRepoId, - &repository.RepoName, - &repository.Description, - &repository.LanguagesUrl, - &repository.RepoUrl, - &repository.OwnerName, - &repository.UpdateDate, - &repository.CreatedAt, - &repository.UpdatedAt, - &repository.ContributorsUrl, ) if err != nil { slog.Error("error occured while creating repository", "error", err) @@ -108,3 +114,67 @@ func (rr *repositoryRepository) CreateRepository(ctx context.Context, tx *sqlx.T return repository, nil } + +func (r *repositoryRepository) GetUserRepoTotalCoins(ctx context.Context, tx *sqlx.Tx, repoId int) (int, error) { + userIdValue := ctx.Value(middleware.UserIdKey) + + userId, ok := userIdValue.(int) + if !ok { + slog.Error("error obtaining user id from context") + return 0, apperrors.ErrInternalServer + } + + executer := r.BaseRepository.initiateQueryExecuter(tx) + + var totalCoins int + + err := executer.GetContext(ctx, &totalCoins, getUserRepoTotalCoinsQuery, userId, repoId) + if err != nil { + slog.Error("error calculating total coins earned by user for the repository", "error", err) + return 0, apperrors.ErrCalculatingUserRepoTotalCoins + } + + return totalCoins, nil +} + +func (r *repositoryRepository) FetchUsersContributedRepos(ctx context.Context, tx *sqlx.Tx) ([]Repository, error) { + userIdValue := ctx.Value(middleware.UserIdKey) + + userId, ok := userIdValue.(int) + if !ok { + slog.Error("error obtaining user id from context") + return nil, apperrors.ErrInternalServer + } + + executer := r.BaseRepository.initiateQueryExecuter(tx) + + var usersContributedRepos []Repository + err := executer.SelectContext(ctx, &usersContributedRepos, fetchUsersContributedReposQuery, userId) + if err != nil { + slog.Error("error fetching users contributed repositories", "error", err) + return nil, apperrors.ErrFetchingUsersContributedRepos + } + + return usersContributedRepos, nil +} + +func (r *repositoryRepository) FetchUserContributionsInRepo(ctx context.Context, tx *sqlx.Tx, repoGithubId int) ([]Contribution, error) { + userIdValue := ctx.Value(middleware.UserIdKey) + + userId, ok := userIdValue.(int) + if !ok { + slog.Error("error obtaining user id from context") + return nil, apperrors.ErrInternalServer + } + + executer := r.BaseRepository.initiateQueryExecuter(tx) + + var userContributionsInRepo []Contribution + err := executer.SelectContext(ctx, &userContributionsInRepo, fetchUserContributionsInRepoQuery, repoGithubId, userId) + if err != nil { + slog.Error("error fetching users contribution in repository", "error", err) + return nil, apperrors.ErrFetchingUserContributionsInRepo + } + + return userContributionsInRepo, nil +} diff --git a/internal/repository/user.go b/internal/repository/user.go index c504048..254da1b 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -21,7 +21,7 @@ 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 - GetAllUsersGithubUsernames(ctx context.Context, tx *sqlx.Tx) ([]string, error) + GetAllUsersGithubId(ctx context.Context, tx *sqlx.Tx) ([]int, error) } func NewUserRepository(db *sqlx.DB) UserRepository { @@ -47,29 +47,14 @@ const ( updateEmailQuery = "UPDATE users SET email=$1, updated_at=$2 where id=$3" - getAllUsersGithubUsernamesQuery = "SELECT github_username from users" + getAllUsersGithubIdQuery = "SELECT github_id from users" ) func (ur *userRepository) GetUserById(ctx context.Context, tx *sqlx.Tx, userId int) (User, error) { executer := ur.BaseRepository.initiateQueryExecuter(tx) var user User - err := executer.QueryRowContext(ctx, getUserByIdQuery, userId).Scan( - &user.Id, - &user.GithubId, - &user.GithubUsername, - &user.AvatarUrl, - &user.Email, - &user.CurrentActiveGoalId, - &user.CurrentBalance, - &user.IsBlocked, - &user.IsAdmin, - &user.Password, - &user.IsDeleted, - &user.DeletedAt, - &user.CreatedAt, - &user.UpdatedAt, - ) + err := executer.GetContext(ctx, &user, getUserByIdQuery, userId) if err != nil { if errors.Is(err, sql.ErrNoRows) { slog.Error("user not found", "error", err) @@ -86,22 +71,7 @@ func (ur *userRepository) GetUserByGithubId(ctx context.Context, tx *sqlx.Tx, gi executer := ur.BaseRepository.initiateQueryExecuter(tx) var user User - err := executer.QueryRowContext(ctx, getUserByGithubIdQuery, githubId).Scan( - &user.Id, - &user.GithubId, - &user.GithubUsername, - &user.AvatarUrl, - &user.Email, - &user.CurrentActiveGoalId, - &user.CurrentBalance, - &user.IsBlocked, - &user.IsAdmin, - &user.Password, - &user.IsDeleted, - &user.DeletedAt, - &user.CreatedAt, - &user.UpdatedAt, - ) + err := executer.GetContext(ctx, &user, getUserByGithubIdQuery, githubId) if err != nil { if errors.Is(err, sql.ErrNoRows) { slog.Error("user not found", "error", err) @@ -118,27 +88,12 @@ func (ur *userRepository) CreateUser(ctx context.Context, tx *sqlx.Tx, userInfo executer := ur.BaseRepository.initiateQueryExecuter(tx) var user User - err := executer.QueryRowContext(ctx, createUserQuery, + err := executer.GetContext(ctx, &user, createUserQuery, userInfo.GithubId, userInfo.GithubUsername, userInfo.Email, - userInfo.AvatarUrl, - ).Scan( - &user.Id, - &user.GithubId, - &user.GithubUsername, - &user.AvatarUrl, - &user.Email, - &user.CurrentActiveGoalId, - &user.CurrentBalance, - &user.IsBlocked, - &user.IsAdmin, - &user.Password, - &user.IsDeleted, - &user.DeletedAt, - &user.CreatedAt, - &user.UpdatedAt, - ) + userInfo.AvatarUrl) + if err != nil { slog.Error("error occurred while creating user", "error", err) return User{}, apperrors.ErrUserCreationFailed @@ -160,23 +115,15 @@ func (ur *userRepository) UpdateUserEmail(ctx context.Context, tx *sqlx.Tx, user return nil } -func (ur *userRepository) GetAllUsersGithubUsernames(ctx context.Context, tx *sqlx.Tx) ([]string, error) { +func (ur *userRepository) GetAllUsersGithubId(ctx context.Context, tx *sqlx.Tx) ([]int, error) { executer := ur.BaseRepository.initiateQueryExecuter(tx) - rows, err := executer.QueryContext(ctx, getAllUsersGithubUsernamesQuery) + + var githubIds []int + err := executer.SelectContext(ctx, &githubIds, getAllUsersGithubIdQuery) if err != nil { slog.Error("failed to get github usernames", "error", err) return nil, apperrors.ErrInternalServer } - defer rows.Close() - - var githubUsernames []string - for rows.Next() { - var username string - if err := rows.Scan(&username); err != nil { - return nil, err - } - githubUsernames = append(githubUsernames, username) - } - return githubUsernames, nil + return githubIds, nil }