Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions internal/app/contribution/domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,10 @@ type Transaction struct {
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}

type ContributionTypeSummary struct {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update name

ContributionType string `db:"contribution_type"`
ContributionCount int `db:"contribution_count"`
Comment on lines +66 to +67
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rename to Type and Count

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also why this struct as db tags?

TotalCoins int `db:"total_coins"`
Month time.Time `db:"month"`
}
17 changes: 17 additions & 0 deletions internal/app/contribution/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type handler struct {

type Handler interface {
FetchUserContributions(w http.ResponseWriter, r *http.Request)
GetContributionTypeSummaryForMonth(w http.ResponseWriter, r *http.Request)
}

func NewHandler(contributionService Service) Handler {
Expand All @@ -35,3 +36,19 @@ 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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Name like ListMonlyContributionSummary would be more preferable

ctx := r.Context()

month := r.URL.Query().Get("month")

Comment on lines +43 to +44
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validate month before passing it to service layer

contributionTypeSummaryForMonth, err := h.contributionService.GetContributionTypeSummaryForMonth(ctx, month)
if err != nil {
slog.Error("error fetching contribution type summary for month")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also print error in the message

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)
}
35 changes: 35 additions & 0 deletions internal/app/contribution/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ 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"
Expand Down Expand Up @@ -66,6 +68,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)
}

func NewService(bigqueryService bigquery.Service, contributionRepository repository.ContributionRepository, repositoryService repoService.Service, userService user.Service, transactionService transaction.Service, httpClient *http.Client) Service {
Expand Down Expand Up @@ -287,3 +290,35 @@ func (s *service) GetContributionByGithubEventId(ctx context.Context, githubEven

return Contribution(contribution), nil
}

func (s *service) GetContributionTypeSummaryForMonth(ctx context.Context, monthParam string) ([]ContributionTypeSummary, error) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of taking a string, it would be good to take in month and year as int separatly in the body and pass to service.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can do GET /api/v1/user/overview?year=2025&month=06 this way. But to pass 'int' to service I need to convert string to integer in handler. This conversion is logic so shouldn't that be part of service layer?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The service should accept year and month as integers. Ideally, here repo layer should do the conversion to string, as the specific format is only used by the query. It will keep the service and handler layers decoupled from formatting concerns, making the service easier to use, callers won't need to be aware of the exact format required by the database.

month, err := time.Parse("2006-01", monthParam)
if err != nil {
slog.Error("error parsing month query parameter", "error", err)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Go programs, printing an error typically indicates that we've handled it. Therefore, it's best practice to either print the error or return it, but not both.
(same for all other)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to add more detail you can always wrap errors

return nil, err
}

contributionTypes, err := s.contributionRepository.GetAllContributionTypes(ctx, nil)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a practice in go, Use List as prefix and not get whenever fetching an array.

if err != nil {
slog.Error("error fetching contribution types", "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
}

contributionTypeSummaryForMonth = append(contributionTypeSummaryForMonth, ContributionTypeSummary(contributionTypeSummary))
}
Comment on lines +307 to +321
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could fetch all the Contribution summary for this month in a single db call then categorise them. This would save a lot of db calls.
(This problem is generally known as n + 1 query problem)


return contributionTypeSummaryForMonth, nil
}
1 change: 1 addition & 0 deletions internal/app/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ 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/all", middleware.Authentication(deps.ContributionHandler.FetchUserContributions, deps.AppCfg))
router.HandleFunc("GET /api/v1/user/monthlyoverview", middleware.Authentication(deps.ContributionHandler.GetContributionTypeSummaryForMonth, deps.AppCfg))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With standard naming convention it should be /api/v1/user/overview/monthly or monthly can be query param, like /api/v1/user/overview?type=monthly


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))
Expand Down
18 changes: 10 additions & 8 deletions internal/pkg/apperrors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,16 @@ var (
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")
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")
ErrFetchingContributionTypes = errors.New("failed to fetch all contribution types")
ErrNoContributionForContributionType = errors.New("contribution for contribution type does not exist")

ErrTransactionCreationFailed = errors.New("error failed to create transaction")
ErrTransactionNotFound = errors.New("error transaction for the contribution id does not exist")
Expand Down
60 changes: 58 additions & 2 deletions internal/repository/contribution.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"log/slog"
"time"

"github.com/jmoiron/sqlx"
"github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors"
Expand All @@ -21,6 +22,8 @@ type ContributionRepository interface {
GetContributionScoreDetailsByContributionType(ctx context.Context, tx *sqlx.Tx, contributionType string) (ContributionScore, error)
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)
}

func NewContributionRepository(db *sqlx.DB) ContributionRepository {
Expand All @@ -47,7 +50,23 @@ const (

fetchUserContributionsQuery = `SELECT * from contributions where user_id=$1 order by contributed_at desc`

GetContributionByGithubEventIdQuery = `SELECT * from contributions where github_event_id=$1`
getContributionByGithubEventIdQuery = `SELECT * from contributions where github_event_id=$1`

getAllContributionTypesQuery = `SELECT * from contribution_score`

GetContributionTypeSummaryForMonthQuery = `
SELECT
DATE_TRUNC('month', contributed_at) AS month,
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'
GROUP BY
month, contribution_type;`
)

func (cr *contributionRepository) CreateContribution(ctx context.Context, tx *sqlx.Tx, contributionInfo Contribution) (Contribution, error) {
Expand Down Expand Up @@ -114,7 +133,7 @@ func (cr *contributionRepository) GetContributionByGithubEventId(ctx context.Con
executer := cr.BaseRepository.initiateQueryExecuter(tx)

var contribution Contribution
err := executer.GetContext(ctx, &contribution, GetContributionByGithubEventIdQuery, githubEventId)
err := executer.GetContext(ctx, &contribution, getContributionByGithubEventIdQuery, githubEventId)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
slog.Error("contribution not found", "error", err)
Expand All @@ -127,3 +146,40 @@ func (cr *contributionRepository) GetContributionByGithubEventId(ctx context.Con
return contribution, nil

}

func (cr *contributionRepository) GetAllContributionTypes(ctx context.Context, tx *sqlx.Tx) ([]ContributionScore, error) {
executer := cr.BaseRepository.initiateQueryExecuter(tx)

var contributionTypes []ContributionScore
err := executer.SelectContext(ctx, &contributionTypes, getAllContributionTypesQuery)
if err != nil {
slog.Error("error fetching all contribution types", "error", err)
return nil, apperrors.ErrFetchingContributionTypes
}

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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

userId should be passed by the service layer, and ctx values are generally accessed in the handler layer itself.
It Makes the function independent of the caller state.


userId, ok := userIdValue.(int)
if !ok {
slog.Error("error obtaining user id from context")
return ContributionTypeSummary{}, apperrors.ErrInternalServer
}

executer := cr.BaseRepository.initiateQueryExecuter(tx)

var contributionTypeSummary ContributionTypeSummary
err := executer.GetContext(ctx, &contributionTypeSummary, GetContributionTypeSummaryForMonthQuery, userId, contributionType, 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
}

return contributionTypeSummary, nil
}
7 changes: 7 additions & 0 deletions internal/repository/domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,10 @@ type LeaderboardUser struct {
CurrentBalance int `db:"current_balance"`
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"`
}